Search code examples
listscrollswiftuireset

SwiftUI List reset scroll on any view change


I have a very simple List with some Sections, in the same view I have also one button which will become enabled when any of list items are selected, this is controlled with a State variable, when this is happening, if list is scrolled down, State variable will change (to enable the button) and all view will refresh, causing my list to scroll on top. How can I avoid this scroll reset, I should mention that the same is happening if elements are removed or added to the list, however, I tried to simplify the problem as much as possible, here is the simplified code snippet.

import SwiftUI

enum FavoritesListActiveSheet: Identifiable {
    case moveToSheet
    
    var id: Int { hashValue }
}

struct Category: Identifiable {
    var id: UUID = UUID()
    var name: String
}

extension Category {
    static var categories: [Category] {
        [
            Category(name: "category 1"),
            Category(name: "category 2")
        ]
    }
}

struct FavoriteItem: Identifiable {
    var id: UUID = UUID()
    var name: String
    var category: String
    var selected: Bool
}

extension FavoriteItem {
    static var favoriteItems: [FavoriteItem] {
        [
            FavoriteItem(name: "Item 1", category: "category 1", selected: false),
            FavoriteItem(name: "Item 2", category: "category 1", selected: false),
            FavoriteItem(name: "Item 3", category: "category 1", selected: false),
            FavoriteItem(name: "Item 4", category: "category 2", selected: false),
            FavoriteItem(name: "Item 5", category: "category 2", selected: false),
            FavoriteItem(name: "Item 6", category: "category 2", selected: false),
            FavoriteItem(name: "Item 7", category: "category 2", selected: false),
            FavoriteItem(name: "Item 8", category: "category 2", selected: false),
            FavoriteItem(name: "Item 9", category: "category 2", selected: false),
            FavoriteItem(name: "Item 10", category: "category 2", selected: false),
            FavoriteItem(name: "Item 11", category: "category 2", selected: false),
            FavoriteItem(name: "Item 12", category: "category 2", selected: false),
            FavoriteItem(name: "Item 13", category: "category 2", selected: false),
            FavoriteItem(name: "Item 14", category: "category 2", selected: false),
            FavoriteItem(name: "Item 15", category: "category 2", selected: false),
            FavoriteItem(name: "Item 16", category: "category 2", selected: false),
            FavoriteItem(name: "Item 17", category: "category 2", selected: false),
            FavoriteItem(name: "Item 18", category: "category 2", selected: false),
        ]
    }
}

struct FavoritesRaw: View {

    @Binding var item: FavoriteItem
    @State var refreshView: Bool = false

    let onItemToggle: () -> ()

    var body: some View {
        HStack {
            if (item.selected) {
                Image(systemName: "checkmark.circle")
            } else {
                Image(systemName: "circle")
            }

            Text (item.name)
        }
        .simultaneousGesture(TapGesture().onEnded {

            self.item.selected.toggle()

            refreshView.toggle()
            onItemToggle()
        })
        .contentShape(Rectangle())
    }
}

class FavoritesViewModel: ObservableObject {
    var favorite_items: [FavoriteItem] = FavoriteItem.favoriteItems
}

struct FavoritesListView: View {
    @StateObject var viewModel: FavoritesViewModel = FavoritesViewModel()

    @State var addtoButtonDisabled: Bool = true
    @State var sheetDisplayed: FavoritesListActiveSheet?

    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach (Category.categories) { category in
                        Section (header: Text(category.name))
                        {
                            ForEach (self.viewModel.favorite_items.filter({$0.category == category.name})) { item in
                                FavoritesRaw(item: binding(for: item), onItemToggle: {
                                    addtoButtonDisabled = (self.viewModel.favorite_items.filter({$0.selected == true}).count == 0)
                                })
                            }
                        }
                        .textCase(nil)
                        
                    }
                }
                .listStyle(PlainListStyle())
                .id(UUID()) // no animation
            }
            .navigationBarTitle("Favorites", displayMode: .inline)
            .toolbar {
                ToolbarItemGroup(placement: .navigationBarTrailing) {

                    Button(action: {
                        sheetDisplayed = .moveToSheet
                    }) {
                        Text("Add to...")
                    }.disabled(addtoButtonDisabled)
                }
            }
            .sheet(item: $sheetDisplayed) { item in
                // [Show a sheet then disable back the button]
            }
        }
        .onAppear
        {
            addtoButtonDisabled = (self.viewModel.favorite_items.filter({$0.selected == true}).count == 0)
        }
    }

    private func binding(for item: FavoriteItem) -> Binding<FavoriteItem> {
        guard let item_index = self.viewModel.favorite_items.firstIndex(where: { $0.id == item.id }) else {
             fatalError("Can't find item in array")
         }
        return $viewModel.favorite_items[item_index]
     }
}

Solution

  • It seems that removing

    .id(UUID()) // no animation

    is fixing my problem. However, I've added this to get rid of the ugly animation, SwiftUI is providing on element delete.