SwiftUI中@Published替代@State存储选中项导致List滚动异常
问题背景
此前咨询过macOS SwiftUI中NavigationLink搭配isActive参数引发列表滚动异常的问题,得到的解决方案是将SidebarRowView封装为独立视图,该方案在activeItem作为@State属性存储于SidebarList视图内时可正常生效。
后续测试发现,若将activeItem改存到ObservableObject类型的视图模型中,声明为@Published属性,滚动异常问题会复现:运行三列布局的macOS SwiftUI应用,将列表滚动到下方位置(如第80项)点击后,List会触发重渲染,偶发跳转到其他位置(如第40项),导致选中项移出可视区域。
该问题根源为@Published和@State对应的视图差分算法行为存在差异,由于activeItem需要响应全局状态变化(如推送通知触发选中指定项),必须通过@Published存储,需要找到方案在使用@Published存储activeItem的前提下,避免触发整个List重渲染,保证滚动位置不受影响。
可复现问题的代码
struct Item : Identifiable, Hashable { let id = UUID() var name : String } class SidebarListViewModel : ObservableObject { @Published var items = Array(0...300).map { Item(name: "Item \($0)") } @Published var activeItem : Item? //#2 } struct SidebarList : View { @StateObject private var viewModel = SidebarListViewModel() @State private var activeItem : Item? //#1 var body: some View { List(viewModel.items) { SidebarRowView(item: $0, activeItem: $viewModel.activeItem) //change this to $activeItem and the scrolling works as expected }.listStyle(SidebarListStyle()) } } struct SidebarRowView: View { let item: Item @Binding var activeItem: Item? func navigationBindingForItem(item: Item) -> Binding<Bool> { .init { activeItem == item } set: { newValue in if newValue { activeItem = item } } } var body: some View { NavigationLink(destination: Text(item.name), isActive: navigationBindingForItem(item: item)) { Text(item.name) } } } struct ContentView : View { var body: some View { NavigationView { SidebarList() Text("No selection") Text("No selection") .frame(minWidth: 300) } } }
注:代码基于Xcode 13.0、macOS 11.3版本构建测试
解决方案
@Published属性更新时会通知所有订阅它的父视图重绘,这里SidebarList订阅了整个ViewModel,只要activeItem更新就会触发全列表重绘,导致List重新计算单元格位置引发滚动偏移,可通过以下方案解决:
- 方案1:加Equatable修饰符控制重绘范围
给SidebarRowView实现Equatable协议,仅在当前行关联的数据发生变化时才重绘,避免全列表刷新:
// 新增Equatable实现 struct SidebarRowView: View, Equatable { static func == (lhs: SidebarRowView, rhs: SidebarRowView) -> Bool { lhs.item == rhs.item && lhs.activeItem == rhs.activeItem } // 原有代码保持不变 } // List调用处加equatable修饰 List(viewModel.items) { item in SidebarRowView(item: item, activeItem: $viewModel.activeItem) .equatable() }.listStyle(SidebarListStyle())
- 方案2:使用系统原生selection绑定(推荐)
macOS 11+支持NavigationLink直接绑定selection参数,不需要自定义每行的isActive绑定,从根本上避免所有行订阅activeItem变更,性能最优:
struct SidebarList : View { @StateObject private var viewModel = SidebarListViewModel() var body: some View { List(viewModel.items, selection: $viewModel.activeItem) { item in NavigationLink(destination: Text(item.name), tag: item, selection: $viewModel.activeItem) { Text(item.name) } }.listStyle(SidebarListStyle()) } }
- 方案3:兜底手动控制滚动位置
如果业务逻辑必须保留原有isActive写法,可以搭配ScrollViewReader在选中项变更后手动滚动到对应位置,抵消异常偏移:
struct SidebarList : View { @StateObject private var viewModel = SidebarListViewModel() var body: some View { ScrollViewReader { proxy in List(viewModel.items) { item in SidebarRowView(item: item, activeItem: $viewModel.activeItem) .id(item.id) } .onChange(of: viewModel.activeItem) { newValue in guard let id = newValue?.id else { return } proxy.scrollTo(id, anchor: .center) } .listStyle(SidebarListStyle()) } } }
内容的提问来源于stack exchange,提问作者jnpdx
相关产品推荐
相关产品推荐

