You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.30 16:27:04