RecyclerView 的布局能力

95 阅读3分钟

1) GridLayoutManager:跨列(SpanSizeLookup)

场景:普通卡片占 1 列,Header/广告/底部加载占整行。

val spanCount = 2
val lm = GridLayoutManager(context, spanCount)

lm.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
  override fun getSpanSize(position: Int): Int {
    return when (adapter.getItemViewType(position)) {
      VT_HEADER, VT_AD, VT_FOOTER -> spanCount       // 整行
      else -> 1                                       // 普通卡
    }
  }
}
// 可选:提升滚动稳定性
lm.isItemPrefetchEnabled = true
recyclerView.layoutManager = lm

要点

  • getItemViewType(position) 要稳定,不要用 position 当类型。
  • 多类型 + DiffUtil 组合:areItemsTheSame 同时比较 id 与类型。
  • 网格间距建议用 ItemDecoration 计算(避免“双倍间距”),见 §5。

2) StaggeredGridLayoutManager:瀑布流(不规则高度)

基础用法

val sglm = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL).apply {
  gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
recyclerView.layoutManager = sglm

Header/全宽模块:设置 fullSpan

override fun onBindViewHolder(holder: RVH, pos: Int) {
  if (adapter.getItemViewType(pos) == VT_HEADER) {
    (holder.itemView.layoutParams as? StaggeredGridLayoutManager.LayoutParams)
      ?.isFullSpan = true
  }
  holder.bind(...)
}

实战细节(避免抖动/错位)****

  • 高度不稳定引发“跳动” :图片先占位(固定宽高比)再加载;或使用 ConstraintLayout + dimensionRatio。
  • 间隙修复:默认 GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS 会在滚动中调换列以填空;若你需要“保持列稳定”,可设 GAP_HANDLING_NONE,但要自己处理空隙。
  • 定位:findFirstVisibleItemPositions(IntArray) 返回多个列的第一个可见,取最小值即可。
  • Header/FullSpan 不要在 onCreateViewHolder 强写,放在 onBindonViewAttachedToWindow,以免复用导致普通卡也被全宽。

3) 吸附(SnapHelper)

3.1 PagerSnapHelper:翻页(一次吸附一个 item,效果类似 ViewPager)

recyclerView.layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(recyclerView)

适用:横向 Banner/视频流/卡片翻页。

提示:需要页指示器时,监听 onScrollStateChanged == SCROLL_STATE_IDLE 后用 findSnapView() 定位当前页。

3.2 LinearSnapHelper:居中对齐(允许一次滑过多项)

recyclerView.layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
LinearSnapHelper().attachToRecyclerView(recyclerView)

注意

  • RecyclerView.setOnFlingListener 与 SnapHelper 互斥;attachToRecyclerView 会自动设置。
  • 想限制 fling 速度可重写 RecyclerView.fling 或给 LayoutManager 做速度上限。

4) 拖拽排序 & 滑动删除(ItemTouchHelper)

4.1 最小可用版(长按拖拽 + 右滑删除)

class DragSwipeCallback(
  private val onMoveItem: (from: Int, to: Int) -> Unit,
  private val onSwipedItem: (pos: Int) -> Unit
) : ItemTouchHelper.SimpleCallback(
  ItemTouchHelper.UP or ItemTouchHelper.DOWN,           // 允许上下拖拽
  ItemTouchHelper.START or ItemTouchHelper.END          // 允许左右滑动删除
) {
  override fun onMove(rv: RecyclerView, vh: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
    onMoveItem(vh.bindingAdapterPosition, target.bindingAdapterPosition)
    return true
  }
  override fun onSwiped(vh: RecyclerView.ViewHolder, direction: Int) {
    onSwipedItem(vh.bindingAdapterPosition)
  }
  override fun isLongPressDragEnabled() = true          // 或者在 handle 上手动 startDrag
  override fun isItemViewSwipeEnabled() = true
}

val helper = ItemTouchHelper(DragSwipeCallback(
  onMoveItem = { from, to -> adapter.moveItem(from, to) },
  onSwipedItem = { pos -> adapter.removeAt(pos) }
))
helper.attachToRecyclerView(recyclerView)

配合 DiffUtil / ListAdapter(推荐)

fun ArticleAdapter.moveItem(from: Int, to: Int) {
  val cur = currentList.toMutableList()
  val item = cur.removeAt(from)
  cur.add(if (to > from) to - 1 else to, item)
  submitList(cur)  // 用差分派发移动动画
}

fun ArticleAdapter.removeAt(pos: Int) {
  val cur = currentList.toMutableList().apply { removeAt(pos) }
  submitList(cur)  // 用差分派发删除动画
}

可定制

  • 只允许对某些 viewType 拖拽/滑动:重写 getMovementFlags,不同类型返回 0 禁止。
  • 自定义删除背景/图标:重写 onChildDraw() 绘制红色背景与 delete 图标。
  • 手柄拖拽:isLongPressDragEnabled=false,在 onBind 的“拖拽手柄” onTouch 里 helper.startDrag(holder)。

5) 网格/瀑布的“均匀间距”ItemDecoration(可直接用)

class GridSpacingDecoration(
  private val spanCount: Int,
  private val spacingPx: Int,
  private val includeEdge: Boolean = true
) : RecyclerView.ItemDecoration() {
  override fun getItemOffsets(outRect: Rect, v: View, rv: RecyclerView, state: RecyclerView.State) {
    val pos = rv.getChildAdapterPosition(v)
    if (pos == RecyclerView.NO_POSITION) return
    val lm = rv.layoutManager
    val column = when (lm) {
      is GridLayoutManager -> (v.layoutParams as GridLayoutManager.LayoutParams).spanIndex
      is StaggeredGridLayoutManager -> (v.layoutParams as StaggeredGridLayoutManager.LayoutParams).spanIndex
      else -> 0
    }
    if (includeEdge) {
      outRect.left  = spacingPx - column * spacingPx / spanCount
      outRect.right = (column + 1) * spacingPx / spanCount
      if (pos < spanCount) outRect.top = spacingPx
      outRect.bottom = spacingPx
    } else {
      outRect.left  = column * spacingPx / spanCount
      outRect.right = spacingPx - (column + 1) * spacingPx / spanCount
      if (pos >= spanCount) outRect.top = spacingPx
    }
  }
}

使用

recyclerView.addItemDecoration(GridSpacingDecoration(spanCount = 2, spacingPx = dp(8)))

6) 示例汇总

// 1) 网格 + Header/广告跨列
val grid = GridLayoutManager(ctx, 2).apply {
  spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(pos: Int) = when (adapter.getItemViewType(pos)) {
      VT_HEADER, VT_AD -> 2 else -> 1
    }
  }
}
recyclerView.layoutManager = grid
recyclerView.addItemDecoration(GridSpacingDecoration(2, dp(8)))

// 2) 瀑布流 + 全宽 Header + 占位防抖
val sglm = StaggeredGridLayoutManager(2, VERTICAL).apply {
  gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
recyclerView.layoutManager = sglm
// 在 onBind:header 设置 isFullSpan = true;图片使用固定比例占位

// 3) 横向翻页吸附
recyclerView.layoutManager = LinearLayoutManager(ctx, HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(recyclerView)

// 4) 拖拽 / 滑删(配合 ListAdapter.submitList 差分)
ItemTouchHelper(DragSwipeCallback(
  onMoveItem = { from, to -> adapter.moveItem(from, to) },
  onSwipedItem = { pos -> adapter.removeAt(pos) }
)).attachToRecyclerView(recyclerView)

7) 性能与避坑清单(与本章强相关)

  • Grid 跨列频繁变化会触发布局抖动:尽量让跨列类型稳定,不要随滚动临时改变。
  • Staggered 抖动:图片必须有固定高宽比占位;需要“列稳定”时用 GAP_HANDLING_NONE + 自控。
  • SnapHelper 加在只有一个 LayoutManager 的 RV 上;切换 LM 前先 attach(null)。
  • ItemTouchHelperDiffUtil先改数据再 submitList,不要手工 notify*。
  • 多适配器(ConcatAdapter) :跨 Adapter 的拖拽/滑删需要把位置转换为 absoluteAdapterPosition。
  • 稳定 ID:开启 setHasStableIds(true) + getItemId,动画更准、复用更稳。

一句话收束

Grid 用 SpanSizeLookup 精准控制跨列;Staggered 通过 fullSpan 与 gapStrategy 应对不规则高度;SnapHelper 让列表“对齐”或“翻页”;ItemTouchHelper 负责拖拽/滑删——与 DiffUtil/ListAdapter 配套,做到“数据驱动 + 高质量动画 + 流畅滚动”。