【问题标题】:@Published in an ObservableObject vs @State on a View leads to unpredictable update behavior in SwiftUI@Published in an ObservableObject vs @State on a View 导致 SwiftUI 中不可预测的更新行为
【发布时间】:2021-11-29 05:09:27
【问题描述】:

这个问题是在我昨天问过(@Asperi 已经回答)的this question 之后提出的,但它引入了一个新的意想不到的元素。

基本设置是一个 3 列的 macOS SwiftUI 应用程序。如果您运行下面的代码并将列表滚动到列表下方的某个项目(例如第 80 项)并单击,List 将重新渲染并偶尔“跳转”到某个位置(例如第 40 项),留下实际的所选项目超出框架。上一题通过将SidebarRowView封装到自己的视图中解决了这个问题。

但是,如果将活动绑定 (activeItem) 存储为 @State 变量在 SidebarList 视图上(请参阅我标记 //#1 的位置),则该解决方案有效。如果活动项存储在ObservableObject 视图模型上(请参阅//#2),则滚动行为会受到影响。

我认为这是因为差异算法在某种程度上与@Published 值和@State 值的工作方式不同。 我想找到一种使用@Published 值的方法,因为活动项目需要通过应用程序的状态进行操作并通过isActive:NavigationLink 中使用(例如,如果推送通知进来影响它)。

有没有办法使用@Published 值而不让它重新渲染整个List 从而不影响滚动位置?

以下是可重现的代码 - 请参阅注释行了解更改内容以查看 @Published@State 的行为

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)
        }
    }
}

(在 macOS 11.3 上使用 Xcode 13.0 构建和测试)

【问题讨论】:

  • 对于最小的、可重现的例子来说,这似乎是一个非常复杂的解决方案。感觉就像是掉进了兔子洞。我想获得更好的视角。使用您的应用程序,您可以从第一列中选​​择一个项目。这是否会导致第二列填充基于第一列选择的选项?然后是第三列的详细信息?还是前两列是独立的,并且组合可以为您提供第三列的详细信息?
  • @Yrb 我恭敬地不同意它“非常复杂”——它不到 50 行代码,而 一行 更改会极大地影响行为。是的,在我的实际应用程序中,第二列基于第一列的选择,详细信息在第三列 - ala 邮件应用程序。当我测试它时,所有这些代码都被删除了,它与第一列中的行为无关。有可能我可以删除示例中的第三列,但这似乎微不足道,因为它只是一个 Text 项目。
  • @Cristik 但在 NavigationView 根目录中没有 3 个子节点,因此无法获得 3 列布局。有没有其他方法可以得到它?如果不是,我认为我“没有正确使用导航视图”——事实上,这似乎是实现这种布局的标准做法。
  • 对此进行了更多测试,如果删除传递给NavigationLinkisActive 参数,问题就会消失。你需要那个吗,顺便说一句?从架构上讲,列表行知道周围的上下文是很奇怪的,比如父列表中的选定项。
  • 我确实需要 isActive - 例如,如果推送通知进来,我会影响导航,这将保证显示特定视图。

标签: swift macos swiftui


【解决方案1】:

更新。我仍然认为原始答案确定了问题,但是似乎有一个更简单的解决方法:将视图模型推向上游一级,到根ContentView,然后将 items 数组注入到 SidebarList 视图中。

因此,以下更改应该可以解决“跳跃”问题:


struct SidebarList : View {
    let items: [Item]
    @Binding var activeItemId: UUID?
    // ...
}

// ...

struct ContentView : View {
    @StateObject private var viewModel = SidebarListViewModel()

    var body: some View {
        NavigationView {
            SidebarList(items: viewModel.items,
                        activeItemId: $viewModel.activeItemId)
        // ...
}

由于某种原因,这行得通,我没有解释为什么。但是,还有一个问题,这是由 SwiftUI 引起的:以编程方式更改选择不会使列表滚动到新选择。 Scroll SwiftUI List to new selection 也可能有助于解决这个问题。

此外,强烈建议将NavigationLinkSidebarRowView 的正文移动到SidebarListList 部分,这将帮助您限制泄露到行视图的详细信息的数量。

我要提出的另一个建议是使用tag:selection: 替代isActive。当您有一个可能的导航链接池时,这会更好地工作,在特定时间只有一个可以处于活动状态。这当然涉及将视图模型从 var activeItem: Item? 更改为 var activeItemId: UUID?,这将避免需要 hacky navigationBindingForItem 函数:

class SidebarListViewModel : ObservableObject {
    @Published var items = // ...
    @Published var activeItemId : UUID?
}

// ...
NavigationLink(destination: ...,
               tag: item.id, 
               selection: $activeItemId) {

原答案

这很可能是导致问题行为的原因:

func navigationBindingForItem(item: Item) -> Binding<Bool> {
        .init {
            activeItem == item
        } set: { newValue in
            if newValue {
                activeItem = item
            }
        }
    }

如果您在绑定设置器上设置断点,您会看到设置器在您每次选择某些内容时都会被调用,如果您还打印项目名称,您会看到当出现问题滚动时,它总是滚动到上一个选定的项目。

似乎这种“手动”绑定会干扰 SwiftUI 更新周期,导致框架出现故障。

这里的解决方案很简单:从activeItem 属性中删除@Binding 声明,并将其保留为“常规”声明。您还可以安全地删除传递给导航链接的 isActive 参数。

只有在需要更新父组件中的值时才需要绑定,大多数时候简单的值就足够了。这也使您的视图更简单,更符合 Swift/SwiftUI 尽可能使用不可变值的原则。

【讨论】:

  • 但是如果没有 isActive,我将如何像 cmets 中讨论的那样以编程方式控制导航?
  • @jnpdx 导航链接不需要isActive 来完成它的工作。元素的存在足以触发适当的推送。
  • 抱歉,我一定遗漏了一些东西——在 gist 版本中,用于模拟推送通知的“导航到 25-0”按钮如何在没有 isActive 的情况下传播状态并选择正确答案?你能提供一个代码示例来说明这一点吗?就像我说的,我一定是错过了什么……
  • @jnpdx 好的,我现在明白你的问题了,不要以为你之前提到过这个。我没有玩过这个按钮,也许更新你的问题以包括这个,否则其他人偶然发现这个问题可能不完全理解答案。
  • 抱歉,不清楚。我在原始问题和这个问题中确实提到了它(“我想找出一种使用 @Published 值的方法,因为活动项目需要由应用程序的状态来操作”)。我没有明确提到要点中发生了什么变化,这绝对是我的错。我也会尝试在原始问题中更清楚地说明这一点。是的,没有它,根本不需要绑定,这会很方便......不过,谢谢你的关注!
猜你喜欢
  • 1970-01-01
  • 2021-10-14
  • 2021-04-21
  • 2021-01-25
  • 2020-07-31
  • 1970-01-01
  • 1970-01-01
  • 2021-12-14
  • 1970-01-01
相关资源
最近更新 更多