In SwiftUI List View refresh triggered whenever underlying datasource of list is updated from a view far away in hierarchy

霸气de小男生 提交于 2021-01-27 17:28:54

问题


I am trying to write a "Single View App" in SwiftUI. The main design is very simple. I have a list of items (say Expense) which I am displaying in main view in NavigationView -> List.

List View Source Code

    import SwiftUI

struct AmountBasedModifier : ViewModifier{
    var amount: Int
    func body(content: Content) -> some View {
        if amount <= 10{
            return content.foregroundColor(Color.green)
        }
        else if amount <= 100{
            return content.foregroundColor(Color.blue)
        }
        else {
            return content.foregroundColor(Color.red)
            
        }
    }
}

extension View {
    
    func amountBasedStyle(amount: Int) -> some View {
        self.modifier(AmountBasedModifier(amount: amount))
    }
}

struct ExpenseItem: Identifiable, Codable {
    var id = UUID()
    var name: String
    var type: String
    var amount: Int
    
    static var Empty: ExpenseItem{
        return ExpenseItem(name: "", type: "", amount: 0)
    }
}

class Expenses: ObservableObject {
    @Published var items = [ExpenseItem](){
        didSet{
            let encoder = JSONEncoder()
            if let data = try? encoder.encode(items){
                UserDefaults.standard.set(data, forKey: "items")
            }
        }
    }
    
    init() {
        let decoder = JSONDecoder()
        
        if let data = UserDefaults.standard.data(forKey: "items"){
            if let items = try? decoder.decode([ExpenseItem].self, from: data){
                self.items = items
                return
            }
        }
        items = []
    }
}

struct ContentView: View {
    @ObservedObject var expenses = Expenses()
    @State private var isShowingAddNewItemView = false
    
    var body: some View {
        NavigationView{
            List{
                ForEach(self.expenses.items) { item in
                    NavigationLink(destination: ExpenseItemHost(item: item, expenses: self.expenses)){
                        HStack{
                            VStack(alignment: .leading){
                                Text(item.name)
                                    .font(.headline)
                                Text(item.type)
                                    .font(.subheadline)
                            }
                            Spacer()
                            Text("$\(item.amount)")
                                .amountBasedStyle(amount: item.amount)
                        }
                    }
                }.onDelete(perform: removeItems)
            }
            .navigationBarTitle("iExpense")
            .navigationBarItems(leading: EditButton(), trailing: Button(action:
                {
                    self.isShowingAddNewItemView.toggle()
            }, label: {
                Image(systemName: "plus")
            }))
                .sheet(isPresented: $isShowingAddNewItemView) {
                    AddNewExpense(expenses: self.expenses)
            }
        }
    }
    
    func removeItems(at offsets: IndexSet){
        self.expenses.items.remove(atOffsets: offsets)
    }
}

Each row item is NavigationLink that opens the Expense in readonly mode showing all the attributes of Expense Item.

There is an Add button at the top right to let user add a new expense item in list. The AddNewExpenseView (shown as sheet) has access to the list data source. So whenever user adds an new expense then data source of list is updated (by appending new item) and the sheet is dismissed.

Add View Source Code

struct AddNewExpense: View {
    @ObservedObject var expenses: Expenses
    @Environment(\.presentationMode) var presentationMode
    
    @State private var name = ""
    @State private var type = "Personal"
    @State private var amount = ""
    @State private var isShowingAlert = false
    
    static private let expenseTypes = ["Personal", "Business"]
    
    var body: some View {
        NavigationView{
            Form{
                TextField("Name", text: $name)
                Picker("Expense Type", selection: $type) {
                    ForEach(Self.expenseTypes, id: \.self) {
                        Text($0)
                    }
                }
                TextField("Amount", text: $amount)
            }.navigationBarTitle("Add New Expense", displayMode: .inline)
                .navigationBarItems(trailing: Button(action: {
                    if let amount = Int(self.amount){
                        let expenseItem = ExpenseItem(name: self.name, type: self.type, amount: amount)
                        self.expenses.items.append(expenseItem)
                        self.presentationMode.wrappedValue.dismiss()
                    }else{
                        self.isShowingAlert.toggle()
                    }
                    
                }, label: {
                    Text("Save")
                }))
                .alert(isPresented: $isShowingAlert) {
                    Alert.init(title: Text("Invalid Amount"), message: Text("The amount should only be numbers and without decimals"), dismissButton: .default(Text("OK")))
            }
        }
    }
}

Expense Detail (Read Only) View Source Code

struct ExpenseItemView: View {
    var item: ExpenseItem
    
    var body: some View {
        List{
            Section{
                Text("Name")
                    .font(.headline)
                Text(item.name)
            }
            
            Section{
                Text("Expense Type")
                    .font(.headline)
                Text(item.type)
            }
            
            Section{
                Text("Amount")
                    .font(.headline)
                Text("$\(item.amount)")
            }
        }.listStyle(GroupedListStyle())
        .navigationBarTitle(Text("Expense Details"), displayMode: .inline)
    }
}

So far everything good. I then thought of adding an Edit button on the ExpenseItem View screen so that user can edit the Expense. I created an edit View which is launched as a sheet from ReadOnly View when Edit button is clicked.

Edit View Code

struct ExpenseItemHost: View {
    @State var isShowingEditSheet = false
    @State var item: ExpenseItem
    @State var itemUnderEdit = ExpenseItem.Empty
    
    var expenses: Expenses
    
    var body: some View {
        VStack{
            ExpenseItemView(item: self.item)
        }
        .navigationBarItems(trailing: Button("Edit")
        {
            self.isShowingEditSheet.toggle()
        })
        .sheet(isPresented: $isShowingEditSheet) {
            EditExpenseItemView(item: self.$itemUnderEdit)
                .onAppear(){
                    self.itemUnderEdit = self.item
            }
            .onDisappear(){
                
//TO DO: Handle the logic where save is done when user has explicitly pressed "Done" button.  `//Presently it is saving even if Cancel button is clicked`
                if let indexAt = self.expenses.items.firstIndex( where: { listItem in
                    return self.item.id == listItem.id
                }){
                    self.expenses.items.remove(at: indexAt)
                }
                
                self.item = self.itemUnderEdit
                self.expenses.items.append(self.item)
            }
        }
    }
}


struct EditExpenseItemView: View {
    @Environment(\.presentationMode) var presentationMode
    
    @Binding var item: ExpenseItem
    static private let expenseTypes = ["Personal", "Business"]
    
    var body: some View {
        NavigationView{

            Form{
                TextField("Name", text: self.$item.name)
                Picker("Expense Type", selection: self.$item.type) {
                    ForEach(Self.expenseTypes, id: \.self) {
                        Text($0)
                    }
                }
                TextField("Amount", value: self.$item.amount, formatter: NumberFormatter())
            }

            .navigationBarTitle(Text(""), displayMode: .inline)
            .navigationBarItems(leading: Button("Cancel"){
                self.presentationMode.wrappedValue.dismiss()
            }, trailing: Button("Done"){
                self.presentationMode.wrappedValue.dismiss()
            })
        }
    }
}

Screenshots

Problem

I expect that when user is done with editing by pressing Done button the Sheet should come back to ReadOnly screen as this is where user clicked Edit button. But since I am modifying the data source of ListView when Done button is clicked so the ListView is getting recreated/refreshed. So instead of EditView sheet returning to ReadOnly view, the ListView is getting displayed when Done button is clicked.

Since my code is changing the data source of a view which is right now not accessible to user so below exception is also getting generated

2020-08-02 19:30:11.561793+0530 iExpense[91373:6737004] [TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window). This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc), and will also cause unnecessary performance overhead due to extra layout passes. Make a symbolic breakpoint at UITableViewAlertForLayoutOutsideViewHierarchy to catch this in the debugger and see what caused this to occur, so you can avoid this action altogether if possible, or defer it until the table view has been added to a window. Table view: <_TtC7SwiftUIP33_BFB370BA5F1BADDC9D83021565761A4925UpdateCoalescingTableView: 0x7f9a8b021800; baseClass = UITableView; frame = (0 0; 414 896); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x6000010a1110>; layer = <CALayer: 0x600001e8c0e0>; contentOffset: {0, -140}; contentSize: {414, 220}; adjustedContentInset: {140, 0, 34, 0}; dataSource: <_TtGC7SwiftUIP13$7fff2c9a5ad419ListCoreCoordinatorGVS_20SystemListDataSourceOs5Never_GOS_19SelectionManagerBoxS2___: 0x7f9a8a5073f0>>

I can understand why ListView refresh is getting triggered but what I could not figure out is the correct pattern to edit the model as well as not cause the ListView refresh to trigger when we have intermediate screen in between i.e. List View -> ReadOnly -> Edit View.

What is the suggestion to handle this case?

来源:https://stackoverflow.com/questions/63216941/in-swiftui-list-view-refresh-triggered-whenever-underlying-datasource-of-list-is

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!