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) {
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.