9、列表与网格布局:VerticalGridFragment与HorizontalGridView的使用

做TV应用开发,列表和网格是躲不开的核心组件。你想想看,电视屏幕上最常出现的是什么?无非就是一行行的视频卡片、一页页的应用列表。今天我们就来聊聊Android TV上两个最实用的布局组件——VerticalGridFragmentHorizontalGridView

9.1 为什么TV上要用专门的布局组件?

刚接触TV开发时,我犯过一个低级错误——直接把手机上的RecyclerView搬过来用。结果呢?遥控器焦点乱跳,界面卡顿,用户根本没法操作。电视和手机的操作逻辑完全不同:

  • 焦点导航:电视靠遥控器方向键,不是触摸
  • 可视距离:用户坐在2-3米外,卡片要够大
  • 性能要求:TV设备配置普遍偏低,渲染要轻量

Google专门为TV设计了Leanback库,里面的VerticalGridFragmentHorizontalGridView就是来解决这些问题的。

核心结论:TV上的列表和网格,必须用Leanback组件。自己手写RecyclerView适配器?那是给自己挖坑。

9.2 VerticalGridFragment:电视上的网格布局

这个组件说白了就是一个垂直滚动的网格。它继承自BaseFragment,内部封装了VerticalGridView。我个人习惯用它来展示视频分类、应用列表这类场景。

9.2.1 基本用法

先看一个最简单的例子。假设我们要展示一个电影分类网格:

public class MovieGridFragment extends VerticalGridFragment {
    private static final int NUM_COLUMNS = 5;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        // 设置列数
        setGridStyle(NUM_COLUMNS, 
            getResources().getDimensionPixelSize(R.dimen.grid_item_width),
            getResources().getDimensionPixelSize(R.dimen.grid_item_height));
        
        // 创建适配器
        ArrayObjectAdapter adapter = new ArrayObjectAdapter(new CardPresenter());
        
        // 填充数据
        for (int i = 0; i < 20; i++) {
            Movie movie = new Movie("电影 " + i, "描述...");
            adapter.add(movie);
        }
        
        setAdapter(adapter);
    }
}

嗯,这里要注意——setGridStyle方法里的列数不是随便填的。我曾经在项目中把列数设成8,结果卡片挤在一起,遥控器焦点都看不清。电视上一般3到6列比较合适,具体取决于屏幕尺寸。

9.2.2 CardPresenter:卡片渲染器

网格里的每个卡片怎么显示?靠CardPresenter。它类似于RecyclerView的ViewHolder,但更专注于TV场景:

public class CardPresenter extends Presenter {
    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent) {
        // 创建卡片布局
        ImageCardView cardView = new ImageCardView(parent.getContext());
        cardView.setFocusable(true);
        cardView.setFocusableInTouchMode(true);
        
        // 设置卡片尺寸
        cardView.setMainImageDimensions(
            parent.getResources().getDimensionPixelSize(R.dimen.card_width),
            parent.getResources().getDimensionPixelSize(R.dimen.card_height));
        
        return new ViewHolder(cardView);
    }

    @Override
    public void onBindViewHolder(ViewHolder viewHolder, Object item) {
        Movie movie = (Movie) item;
        ImageCardView cardView = (ImageCardView) viewHolder.view;
        
        cardView.setTitleText(movie.getTitle());
        cardView.setContentText(movie.getDescription());
        // 加载图片...
    }

    @Override
    public void onUnbindViewHolder(ViewHolder viewHolder) {
        // 清理资源
    }
}

我的经验ImageCardView自带焦点效果,但默认的阴影有点丑。我一般会重写setFocusState方法,自定义焦点放大动画。记住,电视上焦点反馈一定要明显,用户隔着3米远呢。

9.3 HorizontalGridView:横向滚动列表

如果说VerticalGridFragment是电视上的"首页",那HorizontalGridView就是"详情页"的标配。它横向滚动,常用于展示推荐列表、相关视频等。

9.3.1 在Fragment中使用

我通常把它放在一个普通的Fragment里,配合其他布局使用:

public class DetailFragment extends Fragment {
    private HorizontalGridView horizontalGridView;
    
    @Override
    public View onCreateView(LayoutInflater inflater, 
                             ViewGroup container, 
                             Bundle savedInstanceState) {
        View root = inflater.inflate(R.layout.fragment_detail, container, false);
        
        horizontalGridView = root.findViewById(R.id.horizontal_grid);
        
        // 设置布局参数
        horizontalGridView.setNumRows(1);  // 单行
        horizontalGridView.setRowHeight(
            getResources().getDimensionPixelSize(R.dimen.row_height));
        horizontalGridView.setItemMargin(
            getResources().getDimensionPixelSize(R.dimen.item_margin));
        
        // 设置适配器
        ArrayObjectAdapter adapter = new ArrayObjectAdapter(new CardPresenter());
        // 填充数据...
        horizontalGridView.setAdapter(adapter);
        
        return root;
    }
}

布局文件里这样写:

<androidx.leanback.widget.HorizontalGridView
    android:id="@+id/horizontal_grid"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:focusable="true" />

注意HorizontalGridView默认会抢占焦点。如果你页面上还有其他可聚焦的控件,记得用android:descendantFocusability="afterDescendants"控制焦点顺序。我曾经因为这个被测试提了bug——遥控器按方向键,焦点死活跳不到上面的标题栏。

9.3.2 多行横向网格

有时候单行不够用,比如展示"更多推荐"时,可以设置多行:

horizontalGridView.setNumRows(2);  // 两行
horizontalGridView.setRowHeight(200);
horizontalGridView.setItemMargin(8);

这样就会形成一个2行×N列的网格,横向滚动。说白了就是电视版的"瀑布流"。

9.4 焦点管理:电视交互的灵魂

电视上没有触摸,全靠焦点。我见过太多新手在焦点管理上翻车。这里分享几个关键点:

9.4.1 默认焦点行为

VerticalGridFragmentHorizontalGridView都内置了焦点管理。默认情况下:

  • 进入页面时,第一个可聚焦的item获得焦点
  • 按方向键,焦点在item之间移动
  • 按确定键,触发item的点击事件

但有时候默认行为不够用。比如,我希望进入页面时焦点落在第三个item上:

// 在VerticalGridFragment中
setSelectedPosition(2);  // 索引从0开始

9.4.2 自定义焦点搜索

我曾经遇到一个需求:横向列表滚动到末尾时,焦点要跳到下面的按钮上。默认的焦点搜索算法搞不定,需要重写:

horizontalGridView.setFocusSearchListener(new HorizontalGridView.FocusSearchListener() {
    @Override
    public View focusSearch(View focused, int direction) {
        if (direction == View.FOCUS_RIGHT && isLastItem(focused)) {
            return nextButton;  // 跳到下一个控件
        }
        return null;  // 使用默认算法
    }
});

避坑指南:我曾经在焦点搜索里返回了错误的View,导致遥控器按下去焦点直接消失。调试了半天才发现是返回了一个不可聚焦的View。记住,focusSearch返回的View必须isFocusable() == true

9.5 性能优化:电视设备很脆弱

电视的硬件配置普遍不如手机。我踩过的坑包括:

  • 图片加载:用Glide时记得设置override(),别加载原图
  • ViewHolder复用CardPresenter默认会复用,但如果你在onBindViewHolder里做了耗时操作,滚动就会卡
  • 数据量控制:一次加载50条以内,多了用分页
// 图片加载优化示例
Glide.with(cardView.getContext())
    .load(movie.getImageUrl())
    .override(300, 200)  // 限制尺寸
    .format(DecodeFormat.PREFER_RGB_565)  // 减少内存
    .into(cardView.getMainImageView());

9.6 知识体系总览

下面这张图总结了本章的核心内容,你可以对照着回顾:

TV列表与网格布局知识体系 VerticalGridFragment HorizontalGridView setGridStyle() 设置列数 CardPresenter 卡片渲染 ArrayObjectAdapter 数据适配 setNumRows() 设置行数 setItemMargin() 间距控制 FocusSearchListener 焦点监听 焦点管理 + 性能优化 核心:用Leanback组件替代原生RecyclerView,专注TV交互体验

9.7 总结

做TV开发,VerticalGridFragmentHorizontalGridView是绕不开的两个组件。记住几个要点:

  • 网格用VerticalGridFragment,横向列表用HorizontalGridView
  • 卡片渲染用CardPresenter,别自己写ViewHolder
  • 焦点管理要细心,遥控器操作和触摸完全不同
  • 性能优化从图片加载和复用做起

我在项目中见过太多人把TV开发想得太简单,结果在焦点和性能上栽跟头。嗯,希望今天的内容能帮你少走弯路。

最后说一句:如果你在真机上测试,记得用遥控器操作,别用鼠标点。鼠标点出来的效果和遥控器完全是两码事——我吃过这个亏。


公众号:蓝海资料掘金营,微信deep3321