首页 / 资讯中心 / 文章详情

手写自定义LayoutManager实现直播EPG节目单布局

手写自定义LayoutManager实现直播EPG节目单布局 ★ FEATURED ARTICLE
做TV端应用或者直播类产品久了大概率会撞上EPG布局这个需求。EPG全称Electronic Program Guide说白了就是直播页面那张频道节目单左边竖排频道右边按时间横铺节目块节目多长块就多宽单凭RecyclerView默认的StaggeredGridLayoutManager根本拉不出这种效果。我第二次接类似需求时一开始也走的是继承StaggeredGridLayoutManager魔改的老路改到一半就放弃了因为父类把测量和布局逻辑都封死在黑盒里你没法在“频道行”和“时间列”之间插入自己的规则。这篇文章就完整走一遍从继承StaggeredGridLayoutManager失败、到手写自定义LayoutManager实现EPG布局的全过程。适合做直播、码流播放、排期看板、多列不等宽任务网格这类功能且想保持RecyclerView原生复用和滚动体验的同学参考。1. 方案选型为什么继承StaggeredGridLayoutManager走不通1.1 EPG布局的本质坐标模型EPG的数据天然是二维的先有频道集合每个频道下还有一个节目集合节目带开始时间和结束时间。描述出来的视图是纵向一排频道行每个频道行横向上从当天某个基准时刻比如00:00或早上6点开始铺节目块节目时长的分钟数决定块的像素宽度。也就是说横向坐标轴本质是“时间轴”而不是GridLayoutManager里那种等分的纵向或横向网格。如果你做过排期看板、甘特图、或者服务工单时间线会发现它们的数学模型完全一样左边一个固定标签列右边一个按时间连续展开的内容条。核心在于两点把时间差转换为像素位置以及让每个内容条宽度与其占据的时长成正比。EPG就是甘特图在直播场景里的一个特殊变体。所以EPG的坐标换算公式可以写成下面几个横向坐标programLeft (program.startTime - baseTime) × pxPerMs节目宽度programWidth (program.endTime - program.startTime) × pxPerMs纵向坐标channelTop channelIndex × channelHeightbaseTime一般是当天0点也可以按“最早节目开始时间向下取整到小时”来定。pxPerMs是单位时间对应的像素数通常取pxPerMinute 2.5dp/min也就是一个小时150dp。但实际渲染时dp要转px否则不同密度的设备上时间宽度差很多。1.2 StaggeredGridLayoutManager的局限与候选方案对比StaggeredGridLayoutManager能提供的核心能力是多个span列每列高度自动错落item宽度等于列宽不支持某个item跨多列也不支持固定行高、行内时间连续布局。EPG需要的是“一个节目占据1到N个时间单位宽度”这种动态跨列行为在StaggeredGridLayoutManager里没法表达——节目宽度不是列宽的整数倍而是由时长连续决定的。就算你强行把时间轴切分成很多列再用占位View去拼宽度也逃不掉两个灾难一是列数多时item缓存和布局开销指数上升二是半小时节目和25分钟节目边界对齐误差很难看。常见替换方案有几种第一种是横向ScrollView套纵向RecyclerView缺点是频道内的节目纵向滚动时和频道名区域要手动同步滑动平滑度和View复用都会打折。第二种是外层纵向RecyclerView每个频道行是一个横向RecyclerView适合“每个频道独立滚动”但不符合EPG“整块时间轴统一横滑”的交互要求而且ViewHolder池按频道拆分内存容易失控。第三种就是我最后用的自研LayoutManager所有item仍然放在同一个RecyclerView里由LayoutManager按频道行和时间窗口统一测量和布置子View的回收和复用继续走Recycler滚动时用offsetChildrenHorizontal做整体平移性能差距很小。表面对比几个维度的选型差异方案实现复杂度横向滑动同步View复用内存与性能扩展性嵌套RecyclerView低需手动同步多池复用一般频道多会膨胀差StaggeredGridLayoutManager中天然横向单池复用中等但无法表达连续宽度差自研LayoutManager较高天然横向单池复用好可精确控制缓存粒度好1.3 自研LayoutManager的边界划分自研LayoutManager看起来工作量最大但实际上是把业务逻辑“时间到坐标”的转换收拢到唯一的数据源上反而是后期扩展最舒服的。你把“频道行高、时间比例、节目间隙、可见窗口”都作为参数控制以后无论产品要加“当前时间红线”还是“频道固定列”改动范围都集中在LayoutManager内部或者外层容器而不用碰Adapter的position逻辑。我在项目里采用了一个接口来解耦数据LayoutManager不关心数据是哪里来的只关心能从数据协议里取到频道数、节目数、节目开始结束时间。Adapter也依赖同一份接口这样两者天然一致。这个边界划分很关键后续接入真实数据时几乎没有改动LayoutManager的代码。2. LayoutManager的原理拆解时间坐标翻译器2.1 LayoutManager在RecyclerView体系里到底负责什么简单说LayoutManager是“摆放view的人”。它决定一个item要多大、放在哪个坐标位置不关心数据内容是什么只接收Recycler拿出来的ViewHolder根据Adapter的position和自身的滚动状态去摆放它。在EPG场景LayoutManager需要的输入多了一个维度时间。所以设计接口时要把channel和program的时间信息暴露给LayoutManager。下面这个接口是我在实际项目里用的关键点在getBaseTimeMs它定义了整个坐标系的零点interface EpgDataProvider { fun getChannelCount(): Int fun getProgramCount(channelIndex: Int): Int fun getProgramStartTimeMs(channelIndex: Int, programIndex: Int): Long fun getProgramEndTimeMs(channelIndex: Int, programIndex: Int): Long fun getBaseTimeMs(): Long }这个接口的意义是让LayoutManager和Adapter解耦。Adapter负责把数据列表展平成positionLayoutManager只按照接口取时间和频道数据。2.2 把时间轴翻译成像素坐标系时间戳本身不是像素LayoutManager布局用的都是像素坐标所以内部要维护一个pxPerMs的换算比例。几个决定性参数如下pxPerMinute决定横向上“一小时有多宽”channelHeightPx频道行的行高channelNameWidthPx左侧频道名占位宽度供外层固定栏使用在Demo里我取这样的配置pxPerMinute为2.5dp/min频道行高96dp频道名列宽120dp节目横向间隙1px。这样首屏能显示约6个频道横向能滚动约8个小时的节目。换算逻辑很简单val pxPerMs pxPerMinute / 60000f val programLeft ((program.startTimeMs - baseTimeMs) * pxPerMs).toInt() val programWidth ((program.endTimeMs - program.startTimeMs) * pxPerMs).toInt()baseTimeMs一般设置为当天零点。如果节目跨到次日凌晨节目块的left会是负值这在绘制上是允许的只要RecyclerView的父布局不设置clipChildren为false导致被裁剪掉一部分。实际处理跨天会在第6章展开。2.3 可见窗口只布局看得到的部分RecyclerView的layout机制和自定义ViewGroup最大的区别在于自定义ViewGroup拿到所有child逐个摆RecyclerView是“按需取用”你只摆可视区域内的view。可视区域由scrollX、scrollY、width、height决定。LayoutManager每次fill时要算出可见的时间区间和可见的频道区间然后只遍历这些区间内的节目。计算公式很简单val visibleStartTime baseTimeMs (scrollX / pxPerMs).toLong() val visibleEndTime baseTimeMs ((scrollX width) / pxPerMs).toLong() val firstChannel (scrollY / channelHeightPx).coerceIn(0, channelCount - 1) val lastChannel ((scrollY height) / channelHeightPx).coerceIn(0, channelCount - 1)这就是EPG引擎的最小闭环。后面的实现代码都绕不开这套可见窗口计算。3. 手写EpgLayoutManager核心代码与实现细节3.1 二维表转一维position的索引映射有一个很多实现里容易忽略的问题RecyclerView的position是一维的。EPG项目的数据是“频道→节目”二维结构频道下节目数还不固定怎么映射到一维position常见的错误做法是position channelIndex × 固定节目数 programIndex一旦某个频道少一个节目就全乱了。正确做法是先构建一个索引表记录每个频道在一维数组中的起始位置class EpgIndexTable(private val channels: ListEpgChannel) { private val channelStartPositions IntArray(channels.size) val itemCount: Int init { var acc 0 for (i in channels.indices) { channelStartPositions[i] acc acc channels[i].programs.size } itemCount acc } fun channelOf(position: Int): Int { var lo 0 var hi channels.size - 1 while (lo hi) { val mid (lo hi 1) ushr 1 if (channelStartPositions[mid] position) lo mid else hi mid - 1 } return lo } fun indexInChannel(position: Int, channelIndex: Int): Int { return position - channelStartPositions[channelIndex] } fun positionOf(channelIndex: Int, programIndex: Int): Int { return channelStartPositions[channelIndex] programIndex } }为什么这个映射表重要因为LayoutManager里和Adapter里如果各维护一套映射逻辑节目数据一更新就漂移。把映射表单独抽出来由同一个实例提供给Adapter和LayoutManager才能保证两边的position指向同一个节目。Adapter的getItemCount直接返回indexTable.itemCountgetItemViewType可以用channelOf判断LayoutManager核对position时也用indexTable来回映射。3.2 onLayoutChildren与fill核心循环自定义LayoutManager最核心的方法是onLayoutChildren。每次RecyclerView需要布局或数据变化时都会走到这里。工作内容很简单清掉旧布局重新调用fill遍历可见范围生成新布局。override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (state.itemCount 0) { detachAndScrapAttachedViews(recycler) return } fill(recycler) }fill内部第一步是detachAndScrapAttachedViews把当前所有child从视图树里挪到Recycler的scrap缓存里。第二步是计算可见时间窗口和频道窗口。第三步是双层遍历外层遍历频道内层遍历该频道从可见起始时间到结束时间之间的节目布局每个节目项。private fun fill(recycler: RecyclerView.Recycler) { detachAndScrapAttachedViews(recycler) if (itemCount 0) return val base provider.getBaseTimeMs() val startMs base (scrollX / pxPerMs).toLong() val endMs base ((scrollX width) / pxPerMs).toLong() val firstChannel (scrollY / channelHeightPx).coerceIn(0, channelCount - 1) val lastChannel ((scrollY height) / channelHeightPx).coerceIn(0, channelCount - 1) for (channelIndex in firstChannel..lastChannel) { layoutChannel(recycler, channelIndex, startMs, endMs) } }注意这里有个坐标陷阱scrollX和scrollY属于内容坐标系而child的left/top是相对RecyclerView本地坐标系的。摆放child时一定要用“内容坐标 - scroll偏移”得到屏幕上真正的位置。很多新手踩坑就是因为把两套坐标搞混滚动几次后节目错位。3.3 单频道内节目块的测量与布局layoutChannel是单个频道内节目循环的核心。逻辑是二分定位到第一个可能在屏幕上可见的节目然后从这个节目开始逐个判断它是否和可见时间窗口相交相交就layout如果它已经超出可见窗口右边界就break。private fun layoutChannel( recycler: RecyclerView.Recycler, channelIndex: Int, startMs: Long, endMs: Long ) { val channelTop channelIndex * channelHeightPx val programIndex findFirstVisibleProgram(channelIndex, startMs) val programs provider.getPrograms(channelIndex) var idx programIndex while (idx programs.size) { val program programs[idx] val left ((program.startTimeMs - baseTimeMs) * pxPerMs).toInt() val right ((program.endTimeMs - baseTimeMs) * pxPerMs).toInt() if (left scrollX width) break if (right scrollX left scrollX width) { val position indexTable.positionOf(channelIndex, idx) val child recycler.getViewForPosition(position) val w right - left - programGapPx val h channelHeightPx - rowVerticalInsetPx * 2 child.measure( View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY) ) layoutDecoratedWithMargins( child, left - scrollX, channelTop rowVerticalInsetPx, right - scrollX, channelTop channelHeightPx - rowVerticalInsetPx ) } idx } }这里的几个常量要注意programGapPx是相邻节目的横向间隙如果节目间隙直接减在宽度里最后一段节目块的右边界就凑不齐时间刻度rowVerticalInsetPx是节目块上下留的视觉padding避免节目块占满整行后看起来很拥挤。取值范围建议集中放到一个配置对象里不要在代码里散落魔法数字。补充一点measure用的是EXACTLY这是必须的。EPG节目块的宽度由时间差计算出来不由内容自行决定。有些业务方会习惯在Adapter里给item写wrap_content那样宽度会被TextView的内容撑开时间轴就对不上了。3.4 横竖滚动处理横向滚动逻辑在scrollHorizontallyBy里override fun scrollHorizontallyBy( dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): Int { if (childCount 0 || dx 0) return 0 val consumed if (scrollX dx 0) -scrollX else if (scrollX dx maxScrollX) maxScrollX - scrollX else dx offsetChildrenHorizontal(-consumed) fill(recycler) return consumed }maxScrollX需要根据总时长计算每次进入fill前根据最新数据重新更新。总时间范围来自所有频道的最大结束时间减去baseTime。offsetChildrenHorizontal(-consumed)一句比重新layout所有child高效得多它直接平移所有attached child的位置不需要重新measure和bind。这就是为什么自定义LayoutManager在滚动时性能可以做得很好——真正需要重新摆放的child只有那些刚进入边界的新item。纵向滚动逻辑同理override fun scrollVerticallyBy( dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): Int { if (childCount 0 || dy 0) return 0 val consumed if (scrollY dy 0) -scrollY else if (scrollY dy maxScrollY) maxScrollY - scrollY else dy offsetChildrenVertical(-consumed) fill(recycler) return consumed }频道数量通常不会太多纵向上一次可见频道变化量有限所以纵向滚动比横向省力很多。canScrollHorizontally返回truecanScrollVertically看数据量决定。滚动处理有个注意事项fill(recycler)执行前如果旧child没有正确回收屏幕上会残留已滑出边界的View。所以fill开头必须统一detachAndScrapAttachedViews把旧的scrap放进Recycler再统一getViewForPosition。这看起来是重复操作但因为scrap缓存是轻量级的Recycler内部直接复用同一批ViewHolder性能开销很小。3.5 smoothScrollToPosition与遥控器定位节目定位在TV端很常用。重写smoothScrollToPosition把目标position转换成目标scrollXoverride fun smoothScrollToPosition( recyclerView: RecyclerView, state: RecyclerView.State, position: Int ) { val channel indexTable.channelOf(position) val index indexTable.indexInChannel(position, channel) val start provider.getProgramStartTimeMs(channel, index) val targetX ((start - baseTimeMs) * pxPerMs).toInt() val scroller object : LinearSmoothScroller(recyclerView.context) { override fun getVerticalSnapPreference(): Int SNAP_TO_NONE override fun getHorizontalSnapPreference(): Int SNAP_TO_START } scroller.targetPosition position startSmoothScroll(scroller) }这段代码解决的问题是用户通过频道列表切频道或通过搜索直达某个节目时节目区能平滑滚到对应时间点。LinearSmoothScroller不直接支持“滚到指定像素”所以我把position里的节目开始时间转换成targetX再由LayoutManager自己滚动。实测下来这个方案在TV端响应稳定。4. 从能用到好用固定频道列、时间轴和当前时间线4.1 频道名列的处理方式频道名列固定不动不能让它跟着时间轴横向滚动。最稳妥的方案是左右分栏、双列表同步整个EPG区域用一个FrameLayout左边放一个只有频道名的RecyclerView宽度固定为channelNameWidth右边放EpgLayoutManager的节目区RecyclerView。两个RecyclerView通过同一个OnScrollListener同步垂直偏移水平偏移只发生在右边epgList.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { channelList.scrollBy(0, dy) } })这样做的好处很多频道名列所有item都在独立小RecyclerView里不需要复用节目itemViewType和缓存逻辑干净右边节目区也不用关心频道名宽度横向滚动范围自然就是整条节目时间线后续如果产品要求频道列支持“常驻置顶”改动也只在左边RecyclerView内部。4.2 顶部时间轴与横向滚动同步顶部一行小时刻度可以用横向RecyclerView实现每个刻度一个item高度约40dp。它和节目区的同步更简单监听右边RecyclerView的横向偏移直接把时间轴RecyclerView执行scrollBy(dx, 0)。时间轴RecyclerView不需要垂直滚动也不需要自己的LayoutManager做复杂逻辑LinearLayoutManager横向摆放即可。唯一要注意的是时间轴的item要精确对齐小时边界不要用wrap_content每个刻度item宽度就是hourWidth 60 * pxPerMinute。4.3 当前时间红线在节目区RecyclerView上叠加一个自定义ViewonDraw里根据当前时间戳算出它在当前屏幕时间窗口中的x坐标画一条半透明红色竖线。为了跟随横向滚动在onScrolled回调里invalidate。画线的代码不复杂主要在于同步刷新时机。我踩过的坑是只在onScrolled里刷新横向连续滚动时没问题但如果停在原地跨分钟红线不会自动前进。后来接了一个外部ticker每分钟触发一次红线View的invalidate才算彻底解决。实现方式可以是在Fragment里postDelayed也可以做一个轻量CountingDownTimer视具体场景而定。5. 性能与复用机制落地5.1 用二分查找跳过不可见节目节目数量大时内层遍历如果用线性扫描从0开始找每次滚动都从头查很伤。由于每个频道的节目列表按开始时间排序可以用二分定位第一个可能和可见窗口相交的节目。这部分代码很少但性能收益非常明显private fun findFirstVisibleProgram(channelIndex: Int, startMs: Long): Int { val programs provider.getPrograms(channelIndex) var lo 0 var hi programs.size - 1 while (lo hi) { val mid (lo hi) ushr 1 if (programs[mid].endTimeMs startMs) { lo mid 1 } else { hi mid - 1 } } return lo }判断条件用的是endTimeMs而不是startTimeMs。因为如果节目A开始时间在startMs之前、结束时间在startMs之后它至少有一部分是可见的只有endTimeMs startMs的节目才彻底不可见。这个边界条件写反了会漏掉正在播放中的节目。5.2 回收策略用Recycler的scrap缓存前面提过fill开头用detachAndScrapAttachedViews把旧child全部放进scrap缓存。但还有一个细节动作fill结束后检查一下detachedView是否还应该保留如果已经彻底离开可见区域就removeAndRecycleViewfor (i in childCount - 1 downTo 0) { val child getChildAt(i) if (isChildOutOfBounds(child)) { removeAndRecycleView(child, recycler) } }detach和remove的区别在于detach只是暂时摘掉视图ViewHolder还绑着数据可以快速复用removeAndRecycleView是彻底回收下次必须重新bind。滚动时大部分child只需要detach因此用detachAndScrapAttachedViews做统一操作性价比最高。如果极端快速滚动边界处的child不断detach又拿回来只要尺寸和位置正确Recycler内部会直接复用不会频繁回调onBindViewHolder性能依然可接受。5.3 缓存配置及其他Item优化扩大RecyclerView缓存epgRecycler.setItemViewCacheSize(10)让刚滑出边界的item多留几个在缓存里。关闭ItemAnimator自定义LayoutManager下默认Animator在数据刷新时会尝试预测动画容易错乱建议itemAnimator null。节目增删动画以后单独做先保证布局稳定。LayoutParams里不要给节目item加marginEPG的间隔应该由LayoutManager统一控制加margin会干扰left/right的对齐尤其是最后一个节目块右边界对不上时间刻度。相同position的节目宽度由时长确定是固定的所以ViewHolder复用不存在宽度漂移问题。如果频道行高做成了可变的那情况完全不同我建议EPG把行高固定为常量。5.4 大数据量频道列表的滚动体验频道数几十个、每个频道上百个节目时item总数可能上千。由于fill只布局可见窗口实际屏幕上同时存在的child一般不超过“可见频道数 × 可见时间窗内节目数”这个数量通常稳定在二三十个。所以RecyclerView总item数大并不可怕真正决定性能的是fill期间每个可见节目是否走了一遍measure和layout。控制好二分查找和回收策略之后整体滚动帧率在真机上能保持稳定。如果把时间跨度拉到24小时、pxPerMinute比较小一个频道的可见节目数会增多此时要留意节目块是否存在大量空白间隙。空白间隙建议用一个空白占位ViewHolder处理不要在数据里为每个空档生成一个实际节目对象。6. 踩坑实录常见问题与处理方案6.1 节目块错位或白块闪烁错位多半是同一个position在不同数据下对应了不同节目而LayoutManager还在用旧的缓存坐标。数据刷新后要执行recyclerView.recycledViewPool.clear()或者直接调用adapter.notifyDataSetChanged并让LayoutManager清空状态。白块闪烁多半是item完全没有绑定数据就显示出来可以检查是否用了getViewForPosition却没在Adapter的getItemViewType里做区分如果类型返回不一致也会造成错乱。另外一个隐蔽场景是快速左右滑动后某个节目块出现在错误位置。我在排查时发现是fill循环里break条件写错了应该用if (left scrollX width) break如果用if (left endMs)会导致边界计算不一致。这种bug只能通过逐步打印每个child的left和scrollX对比定位没有捷径。6.2 跨天节目和时间基准错位节目跨过当天零点时用“当天0点为base”会算出负的left。实际处理上给LayoutManager传一个时间基准点参数外层负责把所有时间统一到“以最早可见节目所在日0点为基准”的时间线上。跨天节目不要拆成两段直接让它从负坐标开始画即可left为负在屏幕上就是从左侧边界外插入进来的视觉上是连续的。但要注意顶部时间轴如果只显示当天0点到24点左边的负坐标区域会没有刻度对齐。所以当时我的做法是baseTime取“最早节目开始时间向下取整到小时”比如最早节目凌晨2点开始base就是当天0点时间轴从0点开始画跨0点的问题就消失了。6.3 最小宽度导致的节目块挤压时间间隙很小的节目按比例算出的宽度可能只有几个像素显示出来既不好看又难点击。给programWidth设一个最小值如24px能解决视觉问题但如果强制加宽后面的节目就会从当前位置被顶得更远导致时间轴和节目右边界无法对齐。这个取舍要在产品层明确优先对齐时间轴就不设最小宽度节目内容按样式截断优先保证可点击就在节目内容上做适配而不是调整外边距。当时我在项目里选了“小于36px时只显示底色不显示文字”它们之间保留原始间隙。6.4 TV端焦点移动与边缘滚动TV端遥控器左右移动焦点到屏幕边缘时需要自动滚动。核心是重写requestChildRectangleOnScreen焦点View位置超出可见区域时就smoothScrollBy一个偏移量override fun requestChildRectangleOnScreen( parent: RecyclerView, child: View, rect: Rect, immediate: Boolean, focusedChildVisible: Boolean ): Boolean { val left child.left - scrollX val right child.right - scrollX return if (left 0) { smoothScrollBy(left, 0) true } else if (right width) { smoothScrollBy(right - width, 0) true } else { super.requestChildRectangleOnScreen(parent, child, rect, immediate, focusedChildVisible) } }这段代码不好调因为遥控器左右键触发focusSearch在自定义LayoutManager中的默认行为不明确需要配合FocusFinder和nextFocusDirection一起处理。我的建议是先写好基础滚动逻辑再在真机上反复按方向键观察焦点丢失的方向逐个修正。6.5 常见问题速查表故障现象可能原因处理建议白块闪烁快速滚动时ViewHolder未被正确复用检查fill中是否统一detach/scrap扩大itemViewCacheSize节目错位数据更新后未正确重置LayoutManagernotifyDataSetChanged后清空scrap和recycledViewPool横向滚动卡顿每帧重新measure所有child优先offsetChildrenHorizontal只fill边界新增变化item边缘对不齐宽度取整和间隙设置冲突统一用Int像素间隙只做视觉padding不参与宽度计算无节目内容但有空隙频道内节目列表有空档空档区域用空白占位View宽度等于空档时长焦点滚不动requestChildRectangleOnScreen未重写重写后判断left/right边界调用smoothScrollBy6.6 关于数据更新的一个额外建议EPG数据经常是定时刷新比如每30分钟拉一次最新节目单。直接用notifyDataSetChanged会让整个布局闪一下用户体验很糙。我的建议是如果节目表变化不大只对变化的频道条目执行notifyItemChanged或notifyItemRangeChanged。LayoutManager会重新fill但Recycler里大部分child没有变化不会重新measure和layout闪烁感会小很多。配合DiffUtil在后台算差量整体体验能和普通列表一致。如果一定要保留item动画需要支持LayoutManager的predictive animation。我的经验是EPG布局先求稳定再谈动画自定义LayoutManager里做预测动画的复杂度远高于普通列表收益却很低不建议在项目初期投入。6.7 空白区域的点击处理EPG界面上除了节目块还有大量“没有节目”的空白时间区。这个区域需要一个占位View来承接点击事件。我当时用的是同一个空白ViewHolder根据空档时长测量宽度点击空白区域可以跳转到预约编排页面。这里要特别提醒不要在Adapter里为每个空档生成独立的假节目数据而是让LayoutManager在遍历到节目间隙时自行创建一个占位View这样不会污染数据层。实现方式是在layoutChannel循环中维护一个“上一个节目结束时间”当发现下一个节目开始时间大于上一个节目结束时间时就在中间插入一个空档View。注意空档View的position要自己约定一个特殊值比如-1并且getViewForPosition时需要特殊处理或者直接用recycler.getViewForPosition(空档position)占一个共享的ViewHolder。这个设计初期要规划好否则后面加会议室预约、排期编辑这类功能时会很被动。我记得第一次在真机上跑通这套LayoutManager的时候最直观的体会是EPG布局难不在写代码而在建立“时间就是坐标数据就是边界”这套心智模型。你一旦把频道行高、时间比例、节目间隙拆成明确的参数把可见窗口的计算收敛成一两个函数后面加固定频道列、加时间轴、加当前时间红线都是水到渠成的事。再分享一个小技巧开发这个功能时我给LayoutManager写了一个debug模式在每个节目View上叠加显示它的position、startTime和left坐标。这样调试错位、白块、边界对不齐时一眼就能定位问题。这个调试开关成本很低收益却非常大如果你准备做自定义LayoutManager建议一开始就搭进去。
阅读完成 · 觉得有帮助?
咨询建站