返回

SwiftUI List 数据源更新导致意外滚动问题详解与修复

IOS

SwiftUI List 数据源更新导致意外滚动,咋回事?

在使用 SwiftUI 的 List 时,你可能会遇到一个烦人的问题:当 List 的数据源发生变化,特别是当你使用动画更新数据时,列表会意外地向上滚动。这真是让人摸不着头脑! 别急,咱们一起看看是啥情况,怎么解决。

问题根源在哪?

通常,导致 List 意外滚动的原因有几个:

  1. id 的不稳定性: List 通过 id 来追踪每个列表项。如果 id 在数据更新后发生了变化,List 会认为这是不同的项,并尝试重新渲染和定位,这可能导致滚动位置的变化。特别是当列表项目跨 section 移动的时候.

  2. 动画(animation)的滥用: 错误的动画或不适当的动画参数,在数据变化时候可能会与List 的内部布局计算产生冲突, 进而引发非预期的滚动行为。像示例代码 value: state.sections

  3. Section 的变化: 如果列表数据根据某些条件被分到不同的 Section 中,而数据变化导致项目在 Section 之间移动,这种移动本身就可能触发 List 重新计算布局,包括滚动位置。

  4. 状态管理不当: 在一些复杂情形,比如 ObservableObject 的发布(Publish)过于频繁或者对数据的修改没有批量处理,都可能造成 List 不停地刷新,表现出来像是回滚。

  5. SwiftUI 的 Bug : 在较老的 iOS 版本中,SwiftUI 自身的 List 实现可能存在一些已知的 bug,也可能导致类似问题(尽管在 iOS 18 中,这些旧问题大部分已修复,但仍不排除新的或边缘情况下的 bug)。

解决方案及代码示例

针对上面这些原因,我们一步步排查,看看如何解决。

1. 确保 id 的稳定性

这是最重要的一点。保证每个列表项都有一个唯一的、稳定的 id。即使数据更新,这个 id 也应该保持不变。

  • 原理: List 依靠稳定的 id 来区分不同的行, 判断是否需要进行重新渲染。如果你的数据模型本身就有唯一的 id 字段(比如数据库记录的 ID),那就直接用它。

  • 代码示例:

    struct MyDataItem: Identifiable {
        let id: UUID // 或者你的数据模型里已有的唯一标识符
        var title: String
        var isRead: Bool
        // ... 其他属性
    }
    
    struct ContentView: View {
        @State private var items: [MyDataItem] = [
            // ... 你的初始数据
        ]
    
        var body: some View {
            List(items) { item in // List 默认会使用 Identifiable 协议的 id
                Text(item.title)
            }
        }
    }
    

    如果 id 会因为数据分组改变, 考虑在数据更新前,保存旧数据的id 与 indexpath 的映射关系, 在数据更新完成后手动指定 scrollPosition

2. 精细控制动画

过度或不恰当的动画,很容易打乱List 的布局。 .animation(.default, value: state.sections) 应用在了 List上, 而 state.sections 是所有分组, 这意味着每次分组的微小变化都让整个列表去执行动画, 这是问题的关键!

  • 原理: SwiftUI 的动画系统会在数据变化时,自动为视图的改变添加过渡效果。如果对整个 List 添加动画,可能会导致不必要的重绘和滚动。通常只应该为数据源中具体发生变化的项添加动画,并且选择合适的动画效果与时长。

  • 解决:

    1. 移除 List 上的动画:
      .animation(.default, value: state.sections)List 上移走.

    2. 针对特定更改动画(如果需要):
      只在数据源更新的时候加上, 且操作应该在withAnimation 块里.

  • 代码示例(改进):

        //List部分修改
        List {
            ForEach(state.sections, id: \.id) { section in
                inboxSection(section: section)
            }
        }
    
        ...
    
    //swipe Action 的修改.
    Section {
        MyList()
            .swipeActions(edge: .leading, allowsFullSwipe: true) {
                ReadButton {
                        markNotificationAsRead(id) // 操作现在直接发生,不需要延迟
                }
            }
    }
    
     func markNotificationAsRead(_ id: UUID) {
        withAnimation { // 只在这个数据更新发生时,使用动画
           //data source 的实际变更代码, 现在放在 withAnimation块内部
        }
    }
    
    

3. 使用 ScrollViewReader(进阶)

如果数据更新确实导致项目在 Section 之间移动,且你希望在移动后保持滚动位置,那么 ScrollViewReader 可能是你的好帮手。

  • 原理: ScrollViewReader 允许你以编程方式控制 ScrollViewList 内部就是一个 ScrollView)的滚动位置。你可以在数据更新后,使用 ScrollViewReader 将列表滚动到更新后的项目所在的位置。

  • 代码示例:

    struct ContentView: View {
       @ObservedObject var state: MyState
    
        var body: some View {
            ScrollViewReader { proxy in //关键 1: 使用 ScrollViewReader
                List {
                    ForEach(state.sections, id: \.id) { section in
                       Section(header: Text(section.title)) {
                            ForEach(section.items) { item in
                                Text(item.title)
                                   .id(item.id) //关键 2: 为每行设置一个ID
                            }
                       }
                    }
                }
             .onChange(of: state.sections) { //关键3: 侦听sections 变化
                //简单示例:直接跳转到第一个未读消息。
                    if let firstUnread = state.firstUnreadId() {
                            withAnimation{ //可选择是否给滚动添加动画。
                                proxy.scrollTo(firstUnread, anchor: .top) //滚动到指定的 ID
                         }
                     }
                 }
            }
        }
    }
    
  • 代码中, 首先,我们使用 ScrollViewReader 将整个List 包起来, 然后在需要指定 ID 的列表项目(Text(item.title)) 上使用了 .id(item.id), 在 List外部使用了 onChange 修饰符来检测 state.sections的变化,并在发生改变时调用proxy.scrollTo

4. 优化状态管理

频繁的更新会使 List 卡顿,特别是有动画时。

  • 原理: 避免短时间内发布多个微小的改变, 尽可能批量处理这些更新。
  • 示例 : 不在循环中反复调用 published 的属性的 setter, 把多次修改集中到一次赋值中去.

5. 检查是否有 SwiftUI Bug(不太可能,但还是提一下)

一般较新版本的系统会修补旧BUG, 但如果有复杂交互,仍然存在这种可能.

  • 解决方案: 如果排除了以上所有可能,你的问题依然存在,并且你怀疑是 SwiftUI 的 bug,可以尝试向 Apple 提交反馈。

希望以上分析和解决方案能解决你的 List 滚动问题!