Nguyen  Minh Binh
Nguyen Minh Binh

Reputation: 24443

How to force re-create view in SwiftUI?

I made a view which fetches and shows a list of data. There is a context menu in toolbar where user can change data categories. This context menu lives outside of the list.

What I want to do is when user selects a category, the list should refetch data from backend and redraw entire of the view.

I made a BaseListView which can be reused in various screens in my app, and since the loadData is inside the BaseListView, I don't know how to invoke it to reload data.

Did I do this with good approaching? Is there any way to force SwiftUI recreates entire of view so that the BaseListView loads data & renders subviews as first time it's created?

struct ProductListView: View {
    var body: some View {
        BaseListView(
            rowView: { ProductRowView(product: $0, searchText: $1)},
            destView: { ProductDetailsView(product: $0) },
            dataProvider: {(pageIndex, searchText, complete) in
                return fetchProducts(pageIndex, searchText, complete)
            })
            .hideKeyboardOnDrag()
            .toolbar {
                ProductCategories()
            }
            .onReceive(self.userSettings.$selectedCategory) { category in
               //TODO: Here I need to reload data & recreate entire of view.
            }
            .navigationTitle("Products")
    }
}
extension ProductListView{
    private func fetchProducts(_ pageIndex: Int,_ searchText: String, _ complete: @escaping ([Product], Bool) -> Void) -> AnyCancellable {
        let accountId = Defaults.selectedAccountId ?? ""
        let pageSize = 20
        let query = AllProductsQuery(id: accountId,
                                   pageIndex: pageIndex,
                                   pageSize: pageSize,
                                   search: searchText)
        return Network.shared.client.fetchPublisher(query: query)
            .sink{ completion in
                switch completion {
                case .failure(let error):
                    print(error)
                case .finished:
                    print("Success")
                }
            } receiveValue: { response in
                if let data = response.data?.getAllProducts{
                    let canLoadMore = (data.count ?? 0) > pageSize * pageIndex
                    let rows = data.rows
                    complete(rows, canLoadMore)
                }
            }
    }
}

ProductCategory is a separated view:

struct ProductCategories: View {
    @EnvironmentObject var userSettings: UserSettings
    var categories = ["F&B", "Beauty", "Auto"]
    var body: some View{
        Menu {
            ForEach(categories,id: \.self){ item in
                Button(item, action: {
                    userSettings.selectedCategory = item
                    Defaults.selectedCategory = item
                })
            }
        }
        label: {
            Text(self.userSettings.selectedCategory ?? "All")
                .regularText()
                .autocapitalization(.words)
                .frame(maxWidth: .infinity)
            
        }.onAppear {
            userSettings.selectedCategory = Defaults.selectedCategory
        }
    }
}

Since my app has various list-view with same behaviours (Pagination, search, ...), I make a BaseListView like this:

struct BaseListView<RowData: StringComparable & Identifiable, RowView: View, Target: View>: View {
    enum ListState {
        case loading
        case loadingMore
        case loaded
        case error(Error)
    }
    
    typealias DataCallback = ([RowData],_ canLoadMore: Bool) -> Void
    
    @State var rows: [RowData] = Array()
    @State var state: ListState = .loading
    @State var searchText: String = ""
    @State var pageIndex = 1
    @State var canLoadMore = true
    @State var cancellableSet = Set<AnyCancellable>()
    @ObservedObject var searchBar = SearchBar()
    @State var isLoading = false
    
    let rowView: (RowData, String) -> RowView
    let destView: (RowData) -> Target
    let dataProvider: (_ page: Int,_ search: String, _  complete: @escaping DataCallback) -> AnyCancellable
    var searchable: Bool?

    var body: some View {
        HStack{
            content
        }
        .if(searchable != false){view in
            view.add(searchBar)
        }
        .hideKeyboardOnDrag()
        .onAppear(){
            print("On appear")
            searchBar.$text
                .debounce(for: 0.8, scheduler: RunLoop.main)
                .removeDuplicates()
                .sink { text in
                    print("Search bar updated")
                    self.state = .loading
                    self.pageIndex = 1
                    self.searchText = text
                    self.rows.removeAll()
                    self.loadData()
                }.store(in: &cancellableSet)
        }
    }
    
    private var content: some View{
        switch state {
        case .loading:
            return Spinner(isAnimating: true, style: .large).eraseToAnyView()
        case .error(let error):
            print(error)
            return Text("Unable to load data").eraseToAnyView()
        case .loaded, .loadingMore:
            return
                ScrollView{
                    list(of: rows)
                }
                .eraseToAnyView()
        }
    }
    
    private func list(of data: [RowData])-> some View{
        LazyVStack{
            let filteredData = rows.filter({
                searchText.isEmpty || $0.contains(string: searchText)
            })
            
            ForEach(filteredData){ dataItem in
                VStack{
                    //Row content:
                    if let target = destView(dataItem), !(target is EmptyView){
                        NavigationLink(destination: target){
                            row(dataItem)
                        }
                    }else{
                        row(dataItem)
                    }
                    
                    //LoadingMore indicator
                    if case ListState.loadingMore = self.state{
                        if self.rows.isLastItem(dataItem){
                            Seperator(color: .gray)
                            LoadingView(withText: "Loading...")
                        }
                    }
                }
            }
        }
    }
    
    private func row(_ dataItem: RowData) -> some View{
        rowView(dataItem, searchText).onAppear(){
            //Check if need to load next page of data
            if rows.isLastItem(dataItem) && canLoadMore && !isLoading{
                isLoading = true
                state = .loadingMore
                pageIndex += 1
                print("Load page \(pageIndex)")
                loadData()
            }
        }.padding(.horizontal)
    }
    
    private func loadData(){
        dataProvider(pageIndex, searchText){ newData, canLoadMore in
            self.state = .loaded
            rows.append(contentsOf: newData)
            self.canLoadMore = canLoadMore
            isLoading = false
        }
        .store(in: &cancellableSet)
    }
}

Upvotes: 0

Views: 1553

Answers (1)

hayesk
hayesk

Reputation: 594

In your BaseListView you should have an onChange modifier that catches changes to userSettings.$selectedCategory and calls loadData there.

If you don't have access to userSettings in BaseListView, pass it in as a Binding or @EnvironmentObject.

Upvotes: 1

Related Questions