6、详情页设计:DetailsFragment的使用、动作按钮与元数据展示

详情页,说白了就是用户点进一部电影或一个节目后看到的那个页面。在Android TV上,这个页面通常由Leanback库中的DetailsFragment来承载。我最早接触它的时候,觉得这东西有点重,但用熟了才发现——它把很多电视端特有的交互细节都帮你处理好了。

为什么用DetailsFragment?

你想想看,手机上的详情页可以随便滑动、点击、缩放。但电视上不一样,用户手里只有遥控器,焦点移动是核心交互。DetailsFragment天然支持:

  • 左侧海报区 + 右侧元数据区的标准布局
  • 动作按钮行(播放、收藏、分享等)
  • 上下左右焦点自动管理
  • 与BrowseFragment风格统一

我在项目中遇到过直接用普通Fragment硬撸详情页的情况,结果焦点跳转、按键处理写了一堆bug。后来全换成DetailsFragment,省了一半代码量。

核心结构:DetailsFragment + DetailsPresenter

DetailsFragment本身不直接渲染UI,它依赖一个Presenter来生成视图。这个模式跟BrowseFragment如出一辙。我们来看一个典型的实现:

class MovieDetailsFragment : DetailsFragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val movie = arguments?.getParcelable<Movie>("movie")
        val adapter = ArrayObjectAdapter(MovieDetailsPresenter())
        adapter.add(movie)

        // 设置详情页的适配器
        setAdapter(adapter)
    }
}

嗯,这里要注意:setAdapter只能调用一次,而且传入的Adapter必须包含且仅包含一个对象。DetailsFragment的设计理念就是——一次只展示一个详情项。

Presenter里画什么?

Presenter负责生成详情页的视图。通常包含三块内容:

  • 海报/背景图:占据左侧或全屏背景
  • 元数据区:标题、评分、年份、时长、简介
  • 动作按钮行:播放、加入收藏、分享等

我习惯用FullWidthDetailsOverviewRowPresenter来做这个,它自带了一个标准布局模板:

class MovieDetailsPresenter : FullWidthDetailsOverviewRowPresenter(
    MovieDetailsDescriptionPresenter()
) {
    override fun onCreateRowView(): ViewGroup {
        val root = super.onCreateRowView() as ViewGroup
        // 可以在这里自定义背景、添加额外元素
        return root
    }
}

但说实话,这个模板有时候不够灵活。如果你需要完全自定义布局,可以直接继承DetailsOverviewRowPresenter,自己写XML。

元数据展示:DescriptionPresenter

元数据那块,Leanback提供了一个AbstractDetailsDescriptionPresenter。你只需要重写onBindDescription方法:

class MovieDetailsDescriptionPresenter : AbstractDetailsDescriptionPresenter() {

    override fun onBindDescription(
        viewHolder: ViewHolder,
        item: Any
    ) {
        val movie = item as Movie
        viewHolder.title.text = movie.title
        viewHolder.subtitle.text = "${movie.year} · ${movie.duration}分钟"
        viewHolder.body.text = movie.description
    }
}

这里有个坑——subtitle默认只显示一行,如果内容长了会被截断。我曾经因为这个被测试提了bug,后来在XML里加了android:maxLines="2"才搞定。

动作按钮:ActionsRow

动作按钮是详情页的灵魂。用户拿着遥控器,最想干的就是点「播放」。DetailsFragment通过ActionsRow来管理这些按钮:

val actionsAdapter = ArrayObjectAdapter()
actionsAdapter.add(Action(PLAY_ACTION_ID, "播放"))
actionsAdapter.add(Action(FAV_ACTION_ID, "收藏"))
actionsAdapter.add(Action(SHARE_ACTION_ID, "分享"))

// 绑定到详情行
val detailsRow = DetailsOverviewRow(movie)
detailsRow.actionsAdapter = actionsAdapter

按钮的点击事件在Fragment中处理:

override fun onActionClicked(action: Action) {
    when (action.id) {
        PLAY_ACTION_ID -> startPlayback(movie)
        FAV_ACTION_ID -> toggleFavorite(movie)
        SHARE_ACTION_ID -> shareMovie(movie)
    }
}

我个人建议动作按钮不要超过5个。遥控器左右切换太多按钮,用户会烦躁。3个是黄金数量。

避坑指南:焦点与背景

我曾经在详情页上踩过一个坑——背景图加载太慢,导致用户按遥控器时焦点还没渲染出来,界面卡死。后来我做了两件事:

  • 背景图用低分辨率占位图先顶上
  • onBindDescription里异步加载高清图

另外,动作按钮的焦点样式一定要明显。电视上不像手机可以触摸,用户全靠焦点反馈来判断「我选中了哪个」。我一般会在按钮的focused状态加一个缩放动画:

<scale
    android:fromXScale="1.0"
    android:toXScale="1.1"
    android:fromYScale="1.0"
    android:toYScale="1.1"
    android:pivotX="50%"
    android:pivotY="50%"
    android:duration="150" />

知识体系总览

下面这张图帮你理清DetailsFragment的核心组件关系:

DetailsFragment 核心组件关系图 DetailsFragment DetailsPresenter ArrayObjectAdapter FullWidthDetailsOverviewRowPresenter AbstractDetailsDescriptionPresenter DetailsOverviewRow Action 列表 Presenter 负责渲染视图,Adapter 负责数据管理,两者通过 DetailsFragment 桥接

完整示例:一个可用的详情页

把上面这些拼起来,就是一个完整的详情页Fragment:

class MovieDetailsFragment : DetailsFragment() {

    private lateinit var movie: Movie

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        movie = arguments?.getParcelable("movie") ?: return

        val presenter = FullWidthDetailsOverviewRowPresenter(
            MovieDetailsDescriptionPresenter()
        ).apply {
            // 设置动作按钮对齐方式
            actionsRowStyle = DetailsOverviewRow.STYLE_ACTIONS_ROW_END
        }

        val adapter = ArrayObjectAdapter(presenter)
        val row = DetailsOverviewRow(movie).apply {
            actionsAdapter = ArrayObjectAdapter().apply {
                add(Action(1, "播放"))
                add(Action(2, "收藏"))
            }
        }
        adapter.add(row)
        setAdapter(adapter)

        // 监听动作点击
        onItemViewClickedListener = ItemViewClickedListener()
        onItemViewSelectedListener = ItemViewSelectedListener()
    }

    private inner class ItemViewClickedListener : OnItemViewClickedListener {
        override fun onItemClicked(
            itemViewHolder: Presenter.ViewHolder?,
            item: Any?,
            rowViewHolder: RowPresenter.ViewHolder?,
            row: Row?
        ) {
            if (item is Action) {
                when (item.id) {
                    1L -> startPlayback(movie)
                    2L -> toggleFavorite(movie)
                }
            }
        }
    }
}
小技巧:如果你想让详情页的背景图铺满全屏,可以在Presenter的onCreateRowView中拿到根布局,然后设置背景。记得用GlideCoil异步加载,别在主线程干这事。
注意:DetailsFragment默认会监听遥控器的DPAD_CENTERENTER键来触发点击事件。如果你自定义了按键逻辑,记得调用super方法,否则动作按钮可能点不动。

元数据展示的细节

元数据不只是标题加简介。电视屏幕大,信息密度可以更高。我一般会展示:

字段 展示方式 说明
标题 大号加粗字体 主标题,建议28sp以上
副标题 年份 · 时长 · 分级 用圆点分隔,灰色字体
评分 星级或数字 我习惯用数字+星标组合
简介 多行文本 最多显示4行,超出用省略号
标签 圆角标签行 动作、喜剧、科幻等

这里有个经验——电视上文字不要太小。我见过很多应用把手机端的14sp直接搬过来,结果在55寸电视上根本看不清。详情页的正文至少20sp起步。

好了,DetailsFragment的核心用法就这些。说白了它就是帮你搭好了详情页的骨架,你只需要往里面填肉——元数据、动作按钮、背景图。下次做TV端详情页,别自己从头写了,直接用这个方案,省心不少。


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