简体   繁体   中英

@Published in an ObservableObject vs @State on a View leads to unpredictable update behavior in SwiftUI

This question is coming on the heels of this question that I asked (and had answered by @Asperi) yesterday, but it introduces a new unexpected element.

The basic setup is a 3 column macOS SwiftUI app. If you run the code below and scroll the list to an item further down the list (say item 80) and click, the List will re-render and occasionally "jump" to a place (like item 40), leaving the actual selected item out of frame. This issue was solved in the previous question by encapsulating SidebarRowView into its own view.

However , that solution works if the active binding ( activeItem ) is stored as a @State variable on the SidebarList view (see where I've marked //#1 ). If the active item is stored on an ObservableObject view model (see //#2 ), the scrolling behavior is affected.

I assume this is because the diffing algorithm somehow works differently with the @Published value and the @State value. I'd like to figure out a way to use the @Published value since the active item needs to be manipulated by the state of the app and used in the NavigationLink via isActive: (say if a push notification comes in that affects it).

Is there a way to use the @Published value and not have it re-render the whole List and thus not affect the scrolled position?

Reproducible code follows -- see the commented line for what to change to see the behavior with @Published vs @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)
        }
    }
}

(Built and tested with Xcode 13.0 on macOS 11.3)

Update. I still think that the original answer identified the problem, however seems that there's an even easier workaround to this: push the view model one level upstream, to the root ContentView , and inject the items array to the SidebarList view.

Thus, the following changes should fix the "jumping" issue:


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

For some reason, this works, I don't have an explanation why. However, there's one problem left, that's caused by SwiftUI: programatically changing the selection won't make the list scroll to the new selection. Scroll SwiftUI List to new selection might help fixing this too.

Also, warmly recommending to move the NavigationLink from the body of SidebarRowView to the List part of SidebarList , this will help you limit the amount of details that get leaked to the row view.

Another recommendation I would make, would be to use the tag:selection: alternative to isActive . This works better when you have a pool of possible navigation links from which only one can be active at a certain time. This involves of course changing the view model from var activeItem: Item? to var activeItemId: UUID? , this will avoid the need of the hacky navigationBindingForItem function:

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

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

Original Answer

This is most likely what's causing the problematic behaviour:

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

If you put a breakpoint on the binding setter, you'll see that the setter gets called every time you select something, and if you also print the item name, you'll see that when the problematic scrolling happens, it always scroll to the previous selected item.

Seems this "manual" binding interferes with the SwiftUI update cycle, causing the framework to malfunction.

The solution here is simple: remove the @Binding declaration from the activeItem property, and keep it as a "regular" one. You also can safely remove the isActive argument passed to the navigation link.

Bindings are needed only when you need to update values in parent components, most of the time simple values are enough. This also makes your views simpler, and more in line with the Swift/SwiftUI principles of using immutable values as much as possible.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM