☰
Android仿外卖菜单:双列表左右联动与RecyclerView滚动定位实战
2026/10/11 10:09:33 网站建设 项目流程

简介:Android Studio实战演练项目仿美团外卖菜单,面向有一定Android基础、想通过完整案例提升综合能力的开发者。项目覆盖UI布局(LinearLayout、ConstraintLayout)、RecyclerView列表、SQLite/SharedPreferences数据管理、OkHttp/Retrofit网络请求、Glide图片加载、事件监听与Adapter适配等关键知识点,同时处理Android 6.0+运行时权限、AndroidX兼容及JUnit/Espresso测试调试问题。压缩包共541个文件,以flat、json、xml等布局与配置为主,另含jar、class、dex编译文件及apk安装包,整体约10.31MB,便于导入分析。已有12730人学习下载,通过完整源码与构建目录可快速掌握仿美团外卖菜单的实现思路,强化从界面到数据交互的Android实战能力。

1. 仿外卖菜单到底在仿什么:左右联动交互的价值

如果你做过点餐类 App,大概率接到过这样的需求:“菜单页就按某外卖那套来,左边分类、右边菜品,往下滑右侧左侧自动高亮,点左侧右侧滑过去。”听起来不算难,真正动手后会发现瓶颈不在 UI 还原,而在两个 RecyclerView 之间的状态同步。用 Android Studio 实现仿外卖菜单,核心就是两件事:把分类和菜品数据组织成能定位的列表结构,再让左右两侧的滚动事件互相驱动。这篇实战笔记按实际开发顺序,从数据模型、布局选型、联动算法写到购物车接入和常见踩坑,适合正在做商城或点餐模块、想彻底搞懂联动列表的 Android 开发者。

2. 数据源与界面骨架:先把分类-菜品模型和双列表布局定下来

2.1 数据结构设计:分类、菜品、数量状态放哪里

做联动之前先定数据。常见做法是把菜品放在一个平列表里、分类单独存,等滚动时再根据分类头去判断归属。这种方案在数据简单时很快,但后面一旦有插入、删除、搜索,就很容易出现索引错位。我习惯先用两个 data class 把模型定死:

data class Food( val id: Int, val name: String, val price: Double, val imageUrl: String = "", var count: Int = 0 ) data class Category( val id: Int, val name: String, val foods: MutableList<Food> )

这里把 count 放在 Food 里,而不是单独弄一个购物车 Map。原因是菜单页的加号减号按钮直接改 count,Adapter 绑定数据时能根据 count 决定显示“加入购物车”还是“数量加减”;用单独 Map 存 count 还要同步两份数据,容易漏改。price 用 Double 演示方便展示价格,真正做支付级项目时建议用 String 或分转 long,避免浮点误差。

2.2 为什么用两个 RecyclerView 而不是嵌套

左右两列的交互本质是“两个互不干预的滚动容器”。很多人第一次做会尝试在一个 RecyclerView 的 item 里再塞一个 RecyclerView 用来放右侧菜品,这种嵌套做法会带来一连串问题:滑动事件冲突、高度测量不准、item 复用错乱,而且左侧联动右侧时很难拿到准确的滚动位置。最常见可靠的方案是主界面放两个 RecyclerView:左侧一个横向滚动受限的列表,宽度固定;右侧一个纵向列表,通过多种 item 类型把“分类标题”和“菜品”混排。这样左右两侧各自持有 LinearLayoutManager,位置计算完全可控。

布局文件 activity_menu.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_category" android:layout_width="96dp" android:layout_height="match_parent" android:scrollbars="none" /> <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#EEEEEE" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_foods" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:scrollbars="vertical" /> </LinearLayout> <TextView android:id="@+id/tv_cart" android:layout_width="match_parent" android:layout_height="48dp" android:gravity="center_vertical" android:paddingStart="16dp" android:text="购物车" /> </LinearLayout>

左侧宽度固定 96dp,刚好放下两到三个汉字;中间用一条 1dp 的 View 做分割线;右侧 RecyclerView 用 weight 占满剩余宽度。底部先放一个 TextView 作为购物车栏,后续章节再补总价逻辑。这个骨架里没有嵌套列表,只有一个水平协议上的“左右平级”结构,为后面的滚动联动打底。

2.3 右侧列表的 Item 类型设计:分类头与菜品

右侧 RecyclerView 需要两种 item:分类标题和菜品。分类标题要能一眼看出分组,菜品行要包含名称、价格、数量和加减按钮。分类头布局 item_category.xml:

<TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/tv_category_name" android:layout_width="match_parent" android:layout_height="40dp" android:background="#F7F7F7" android:gravity="center_vertical" android:paddingStart="12dp" android:textColor="#333333" android:textSize="14sp" android:textStyle="bold" />

菜品行布局 item_food.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="12dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical"> <TextView android:id="@+id/tv_food_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#333333" android:textSize="15sp" /> <TextView android:id="@+id/tv_food_price" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textColor="#FF6A00" android:textSize="14sp" /> </LinearLayout> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center_vertical"> <TextView android:id="@+id/btn_minus" android:layout_width="32dp" android:layout_height="32dp" android:gravity="center" android:text="-" /> <TextView android:id="@+id/tv_count" android:layout_width="32dp" android:layout_height="32dp" android:gravity="center" android:text="0" /> <TextView android:id="@+id/btn_plus" android:layout_width="32dp" android:layout_height="32dp" android:gravity="center" android:text="+" /> </LinearLayout> </LinearLayout>

这里用 TextView 模拟按钮,好处是省去 ImageView 资源,代码可读性好。真机项目里换成 AppCompatImageView 加上下两个资源图标就行。加减按钮的宽度固定在 32dp,点击区域偏小,正式上线需要按 48dp 的触控规范加大。

2.4 用一个 Adapter 承载两种类型

右侧列表不是两个列表拼在一起,而是一个 Adapter 里根据 item 类型返回不同 ViewHolder。先用一个最简单的版本把展示和数量按钮跑通:

class MenuAdapter( private val items: MutableList<Any>, private val onCountChange: ((Food) -> Unit)? = null ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { companion object { const val TYPE_CATEGORY = 0 const val TYPE_FOOD = 1 } override fun getItemViewType(position: Int): Int { return if (items[position] is String) TYPE_CATEGORY else TYPE_FOOD } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return if (viewType == TYPE_CATEGORY) { CategoryViewHolder(inflater.inflate(R.layout.item_category, parent, false)) } else { FoodViewHolder(inflater.inflate(R.layout.item_food, parent, false)) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is CategoryViewHolder -> holder.bind(items[position] as String) is FoodViewHolder -> holder.bind(items[position] as Food) } } override fun getItemCount(): Int = items.size inner class CategoryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(name: String) { itemView.findViewById<TextView>(R.id.tv_category_name).text = name } } inner class FoodViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(food: Food) { itemView.findViewById<TextView>(R.id.tv_food_name).text = food.name itemView.findViewById<TextView>(R.id.tv_food_price).text = "¥${food.price}" val tvCount = itemView.findViewById<TextView>(R.id.tv_count) tvCount.text = if (food.count > 0) "${food.count}" else "0" itemView.findViewById<TextView>(R.id.btn_plus).setOnClickListener { food.count++ notifyItemChanged(bindingAdapterPosition) onCountChange?.invoke(food) } itemView.findViewById<TextView>(R.id.btn_minus).setOnClickListener { if (food.count > 0) { food.count-- notifyItemChanged(bindingAdapterPosition) onCountChange?.invoke(food) } } } } }

逻辑说明:items 里 String 代表分类标题,Food 代表菜品;getItemViewType 靠类型判断,简单直接。加号按钮里先改 food.count,再用 notifyItemChanged(bindingAdapterPosition) 刷新当前行,不用像很多人那样调 notifyDataSetChanged。注意 bindingAdapterPosition 和 getAdapterPosition 的区别,前者在 notify 后不会返回失效值。

3. 左右联动核心代码:从点击定位到滚动反馈的完整实现

3.1 先把右侧列表拍平,并建立分类起始位置表

右侧 RecyclerView 的数据不能是List<Category>这种嵌套结构,必须是一维的:每个分类标题变成一条 String 数据,后面跟着这个分类的菜品。这样findFirstVisibleItemPosition返回的位置才能直接对应真实滚动位置。同时再用一个categoryStartPositions数组记录每个分类标题在拍平列表中的下标:

private fun buildFlatList(categories: List<Category>): Pair<MutableList<Any>, MutableList<Int>> { val items = mutableListOf<Any>() val categoryStartPositions = mutableListOf<Int>() categories.forEach { category -> categoryStartPositions.add(items.size) items.add(category.name) // 分类标题 items.addAll(category.foods) // 该分类下的所有菜品 } return Pair(items, categoryStartPositions) }

这段代码是这个联动方案的黑匣子。第一个分类从位置 0 开始,如果它有 5 个菜,第二个分类标题的起始位置就是 6。后面左侧点击定位、右侧滚动归因都靠这张表换算。实际项目里如果分类特别多,可以把顺序查找改成二分查找,但外卖菜单一般十几个分类,没必要提前优化。

3.2 右侧滚动时,左侧高亮跟随

滚动监听是右侧联动左侧的入口。核心逻辑是:拿到右侧第一个可见 item 的 position,再通过起始位置表判断它属于第几个分类。如果和上一个高亮的分类索引不同,就更新左侧列表:

private var lastSelectedCategoryIndex = 0 private fun setupFoodScrollListener() { binding.rvFoods.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as LinearLayoutManager val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() val categoryIndex = findCategoryIndex(firstVisiblePosition) if (categoryIndex != lastSelectedCategoryIndex) { val previousIndex = lastSelectedCategoryIndex lastSelectedCategoryIndex = categoryIndex updateLeftSelection(previousIndex, categoryIndex) } } }) } private fun findCategoryIndex(position: Int): Int { var index = 0 for (i in categoryStartPositions.indices) { if (categoryStartPositions[i] <= position) { index = i } else { break } } return index }

说明:findFirstVisibleItemPosition 返回的是屏幕上第一个可见 item,哪怕只露出 1 像素也算。这是我试过几个方案后选定的,比 findFirstCompletelyVisibleItemPosition 更跟手:新分类的标题刚探出头部,左侧就要立刻切换过去,不然视觉上会慢一拍。findCategoryIndex 用顺序查找,每次滚动都会执行,但 O(分类数) 的代价很小。

3.3 点击左侧分类,右侧滚动定位

左侧分类列表也需要一个 Adapter。关键点有两个:点击时更新左侧选中状态,并让右侧滚到对应分类标题位置。右侧滚动要带动画就用 LinearSmoothScroller,要稳定置顶也可以用 ScrollPositionWithOffset。这里先给出最直观的方式:

class CategoryAdapter( private val categories: List<Category>, private val onCategoryClick: ((Int) -> Unit)? = null ) : RecyclerView.Adapter<CategoryAdapter.CategoryViewHolder>() { var selectedPosition = 0 set(value) { val old = field field = value notifyItemChanged(old) notifyItemChanged(value) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_category, parent, false) return CategoryViewHolder(view) } override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) { holder.bind(categories[position].name, position == selectedPosition) holder.itemView.setOnClickListener { onCategoryClick?.invoke(position) } } override fun getItemCount(): Int = categories.size class CategoryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val tvName = itemView.findViewById<TextView>(R.id.tv_category_name) fun bind(name: String, selected: Boolean) { tvName.text = name tvName.setBackgroundColor( if (selected) Color.WHITE else Color.parseColor("#F5F5F5") ) tvName.setTextColor( if (selected) Color.parseColor("#FF6A00") else Color.parseColor("#666666") ) } } }

Activity 里这样接:

private fun setupCategoryRecyclerView() { categoryAdapter = CategoryAdapter(categories) { index -> val previousIndex = lastSelectedCategoryIndex lastSelectedCategoryIndex = index updateLeftSelection(previousIndex, index) val targetPosition = categoryStartPositions[index] binding.rvFoods.layoutManager ?.scrollToPositionWithOffset(targetPosition, 0) } binding.rvCategory.layoutManager = LinearLayoutManager(this) binding.rvCategory.adapter = categoryAdapter }

updateLeftSelection 里通过 CategoryAdapter.selectedPosition 这个 setter 刷新,比找 ViewHolder 改背景靠谱得多,因为列表滑动时 ViewHolder 可能被回收或者不在屏幕内:

private fun updateLeftSelection(previousIndex: Int, selectedIndex: Int) { if (previousIndex == selectedIndex) return categoryAdapter.selectedPosition = selectedIndex }

这里有个细节:scrollToPositionWithOffset 没有动画,但是能确保目标 item 停到 RecyclerView 顶部。如果你更想要一个平滑滑动,可以用后面第 6 章的 LinearSmoothScroller 方案。

3.4 Activity 初始化顺序与完整骨架

初始化顺序不能乱,否则会拿到空的映射表。先加载数据、拍平列表、建映射表,再创建两个 Adapter 和监听器:

class MenuActivity : AppCompatActivity() { private lateinit var binding: ActivityMenuBinding private lateinit var categories: List<Category> private lateinit var flatItems: MutableList<Any> private lateinit var categoryStartPositions: MutableList<Int> private lateinit var menuAdapter: MenuAdapter private lateinit var categoryAdapter: CategoryAdapter private var lastSelectedCategoryIndex = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMenuBinding.inflate(layoutInflater) setContentView(binding.root) categories = loadMockCategories() val result = buildFlatList(categories) flatItems = result.first categoryStartPositions = result.second setupMenuRecyclerView() setupCategoryRecyclerView() setupFoodScrollListener() } private fun setupMenuRecyclerView() { menuAdapter = MenuAdapter(flatItems) binding.rvFoods.layoutManager = LinearLayoutManager(this) binding.rvFoods.adapter = menuAdapter } // setupCategoryRecyclerView、setupFoodScrollListener、loadMockCategories 见上文 }

到这里,仿外卖菜单的左右联动已经能跑了。后面的坑基本都是在这套骨架上踩出来的。

4. 联动菜单常见坑排查:滚动错位、索引偏移与刷新卡顿

4.1 左侧高亮乱跳:可见项计算和刷新时机冲突

现象:右侧正常下滑,左侧选中项不会平滑递进,而是来回跳来跳去。

原因:滚动回调里每次 categoryIndex 变化都调用 notifyDataSetChanged;或者用了 findFirstCompletelyVisibleItemPosition,分类头刚露出一点时不算可见,要等完全可见才切换,视觉上就会延迟跳到下一个分类。

解决:滚动回调里只在索引真的变化时更新,并且用局部刷新只刷旧、新两项。判断可用项改成 findFirstVisibleItemPosition,除非你有意做延迟切换。代码参考 3.2,先比较 categoryIndex 和 lastSelectedCategoryIndex 再刷新。

4.2 点击最后一个分类,右侧滚不到顶

现象:左侧点最后一个分类,右侧停在中间甚至停留在空白区域,并没有滚到对应分类头。

原因:最后一个分类的菜品不足一屏时,RecyclerView 可滚动范围不够大,scrollToPositionWithOffset 目标位置置顶已经没有余量,所以滚不动。

解决:常见做法是在拍平数据底部追加一个占位 item。比如在 buildFlatList 返回的 items 尾部 add 一个空白字符串并让分类头 Adapter 忽略它,或者用一个空 View 作为 footer。另一种方式是判断目标分类是否是最后一个,如果是就直接 smoothScrollToPosition(itemCount - 1)。我一般用 footer 方式,顺手还能解决“滑到底部时底部内容贴边”的观感问题。

4.3 数据增删后分类头位置全部错位

现象:某个分类下新加一个菜,之后点左侧分类,右侧总是差一项。

原因:categoryStartPositions 只在初始化时算一次,数据变更后没有重建,导致所有后续位置的换算整体偏移。

解决:任何增删改操作完成后,重新调用 buildFlatList 生成新的 items 和 categoryStartPositions,再给 menuAdapter 设置新列表并 notifyDataSetChanged。不要想着手动维护中间某个 index,那个值会在第二次增删后再次失效。

4.4 加减数量后整个菜单闪烁

现象:点加号,右侧列表闪一下,滚动位置跳到顶部或者明显顿卡。

原因:FoodViewHolder 里如果写成(adapter as MenuAdapter).notifyDataSetChanged(),RecyclerView 会认为整个数据集都变了,所有 item 重新绑定,并可能重置滚动状态。

解决:数量变化只刷新当前行,用 notifyItemChanged(bindingAdapterPosition)。如果数据结构需要整体刷新,先记录 rvFoods 的当前可见位置,刷新后再把位置滚回去。后期建议用 DiffUtil,见第 6 章。

4.5 空分类导致左侧和右侧索引对不上

现象:数据里有一个分类没放菜品,左侧显示十个分类,右侧只出现九个分类标题,点最后几个分类总是错位。

原因:buildFlatList 里如果遇到空分类就跳过,categoryStartPositions 和左侧 Adapter 的 position 就不一致了。

解决:约定空分类也必须保留分类标题。菜单页产品上一般不允许出现空分类,但如果数据来自服务端,必须在数据层过滤或者补一条“暂无菜品”的空态 View。联动列表的前提是左侧分类和右侧分类头一一对应,次序不能变。

5. 把购物车和数量状态接进来:从展示到可下单

5.1 加减按钮的回调链路

第二章 MenuAdapter 里已经预留了 onCountChange 回调,现在把它接到 Activity。每次加号或减号被点击,Food 的 count 变化后回调触发,Activity 统一计算购物车总价和总件数:

private fun setupMenuRecyclerView() { menuAdapter = MenuAdapter( items = flatItems, onCountChange = { food -> updateCartBar() } ) binding.rvFoods.layoutManager = LinearLayoutManager(this) binding.rvFoods.adapter = menuAdapter } private fun updateCartBar() { var totalCount = 0 var totalPrice = 0.0 categories.forEach { category -> category.foods.forEach { food -> if (food.count > 0) { totalCount += food.count totalPrice += food.price * food.count } } } binding.tvCart.text = if (totalCount > 0) { "购物车 $totalCount 件 | 合计 ¥${String.format("%.2f", totalPrice)}" } else { "购物车" } }

遍历所有菜品来计算总价,看起来不够“聪明”,但外卖菜单的菜品量级通常是几十到几百,遍历一次成本极低。这样比单独维护一个 totalCount 变量要稳,因为不会出现“Food 的 count 改了但 totalCount 忘了加”的情况。当菜品量级达到上千时再考虑增量计算。

5.2 不要在 Adapter 里直接持有页面引用

很多 Demo 会在 Adapter 里写成activity.tvCart.text = ...,这在单页面能跑,但一旦页面销毁重建就会内存泄漏。更干净的做法是 Adapter 只上报事件,Activity 或者 ViewModel 自己决定怎么响应。

class MenuAdapter( private val items: MutableList<Any>, private val onCountChange: ((Food) -> Unit)? = null ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { // 内部不持有 Activity、Context 之外的引用 }

回调链路是:FoodViewHolder 的加号按钮 → food.count++ → notifyItemChanged → onCountChange(food) → Activity 里 updateCartBar。这样 Adapter 仍然是纯展示组件,后续如果要加“清空购物车”“减少商品动画”,改动都集中在 Activity 或 ViewModel 里。

5.3 用 ViewModel 承载购物车状态更稳

如果这个项目不只是 Demo,而是要扩展成真正的商城首页,建议把 categories、flatItems、count 都放进 ViewModel。旋转屏幕时 Activity 重建,数据不会丢;数据变更也能用 LiveData 或 StateFlow 推送给 UI。

class MenuViewModel : ViewModel() { private val _cartText = MutableLiveData<String>() val cartText: LiveData<String> = _cartText fun changeCount(food: Food, delta: Int) { food.count += delta _cartText.value = formatCart() } private fun formatCart(): String { var count = 0 var price = 0.0 // 遍历 categories 计算,方式同 updateCartBar return "购物车 $count 件 | 合计 ¥$price" } }

注意 ViewModel 里如果直接持有 Food 的可变引用,需要保证数据源只有一个入口,不要在 Fragment 里再改 data class 字段。真机上更推荐用不可变数据加 DiffUtil 配合,每次修改生成新的 Food 对象,避免列表刷新时出现状态不同步。

5.4 购物车栏的样式与角标占位

底部购物车栏在完整外卖页面里通常会包含一个圆角购物车图标、红色角标和“去结算”按钮。这里只用了 TextView 占位,但布局本身要预留三块:左侧图标角标、中间金额文案、右侧结算按钮。最小实现可以先把 tv_cart 拆成一个横向 LinearLayout,后续有空再补。

<LinearLayout android:id="@+id/cart_bar" android:layout_width="match_parent" android:layout_height="56dp" android:orientation="horizontal" android:background="#FFFFFF" android:gravity="center_vertical"> <TextView android:id="@+id/tv_cart_icon" android:layout_width="40dp" android:layout_height="40dp" android:layout_marginStart="12dp" android:gravity="center" android:text="购" /> <TextView android:id="@+id/tv_cart_info" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="购物车是空的" /> </LinearLayout>

价格文案的更新逻辑不变,只是把之前 tv_cart 的 setText 换成 tv_cart_info。这是一个渐进式的落地路径:先用最朴素的文本栏证明联动和数量计算没问题,再换真实 UI。

6. 进阶技巧:用 DiffUtil 把联动菜单做成生产可用

6.1 用 DiffUtil 替代手动刷新

手动维护 count 时用 notifyItemChanged 已经够快,但后面一旦要接入服务端菜单、支持搜索、按价格排序,列表的增删就会变得复杂。DiffUtil 可以自动算出新旧列表差异,把刷新动画和闪烁成本降到最低。

class MenuDiffCallback( private val oldList: List<Any>, private val newList: List<Any> ) : DiffUtil.Callback() { override fun getOldListSize(): Int = oldList.size override fun getNewListSize(): Int = newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] return when { oldItem is String && newItem is String -> oldItem == newItem oldItem is Food && newItem is Food -> oldItem.id == newItem.id else -> false } } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] return oldItem == newItem } }

使用 DiffUtil 时必须注意:重新 buildFlatList 后,categoryStartPositions 也要同步重建,否则点击左侧定位仍会错位。刷新完成后,再根据 diffResult 处理偶发的位置偏移。

6.2 用 LinearSmoothScroller 让点击置顶更跟手

scrollToPositionWithOffset 没有动画,smoothScrollToPosition 又不会把目标 item 稳定置顶。最可控的方式是自定义 LinearSmoothScroller,把每个分类头滚到右侧列表顶部:

private fun smoothScrollToCategory(targetPosition: Int) { val layoutManager = binding.rvFoods.layoutManager as LinearLayoutManager val smoothScroller = object : LinearSmoothScroller(this) { override fun getVerticalSnapPreference(): Int { return SNAP_TO_START } } smoothScroller.targetPosition = targetPosition layoutManager.startSmoothScroll(smoothScroller) }

这个写法比默认的 smoothScrollToPosition 多了一个关键属性:SNAP_TO_START 会让滚动结束时目标 item 严格对齐顶部,正好是外卖菜单点击分类后的预期行为。

6.3 一个收尾习惯

我做了多个联动列表后发现,每次动手前只要先画一遍数据结构和分类起始位置表,后面的坑就会少很多。很多所谓“联动玄学”,最后都归结为 position 换算没做彻底。希望这篇文章能帮你把这条链路一次跑通,少走我当初的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询