Why/how does a SwiftUI view's identity depend on the value of its `@StateObject` member variable?

57 views Asked by At

In this example a SwiftUI view's identity depends on the value of its @StateObject member variable. I don't think this should be possible because if a view doesn’t have an explicit identity, it has a structural identity based on its type and position in the view hierarchy. In the example below the ItemPopupView's identity depends on the value of its itemsWrapper member. Please help me to understand this and hopefully to change the view that its itemsWrapper member doesn't impact the view's identity.

Please note that in this Minimal Reproducible Example the itemsWrapper StateObject isn't used, but the point of the MRE is to understand and address this identity issue.

To reproduce:

  1. Add a core data model named TestApp with an entity named Item that has 1 Date attribute named updateTime.
  2. Run the app
  3. Tap the row in the list
  4. Tap the Edit button in the sheet that opens
  5. Tap the Save button in the next sheet that opens
  6. Observe that ItemPopupView: @self changed. was printed in the console
  7. Comment out the _itemsWrapper = line in ItemPopupView's init()
  8. Rerun the app, tap the row, tap Edit, tap Save
  9. Observe that ItemPopupView: @self changed. wasn't printed in the console

Apologies this code is so long, I couldn't find a way to minimize it further:

import SwiftUI
import CoreData

@main
struct TestAppApp: App {
    @StateObject private var manager: DataManager = DataManager()

    var body: some Scene {
        WindowGroup {
            ItemListContentView()
                .environmentObject(manager)
                .environment(\.managedObjectContext, manager.container.viewContext)
                .onAppear() {
                    let item = Item(context: manager.container.viewContext)
                    item.updateTime = Date()
                    try? manager.container.viewContext.save()
                }
        }
    }
}

struct ChildContextAndObject<Object: NSManagedObject>: Identifiable {
    let id = UUID()
    let childContext: NSManagedObjectContext
    var childObject: Object?
    
    init(withExistingObject object: Object?, in parentContext: NSManagedObjectContext
    ) {
        self.childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
        childContext.parent = parentContext
        self.childObject = childContext.object(with: object!.objectID) as! Object
    }
}

class DataManager: NSObject, ObservableObject {
    private var containerImpl: NSPersistentContainer? = nil
    
    var container: NSPersistentContainer {
        if containerImpl == nil {
            containerImpl = NSPersistentContainer(name: "TestApp")
            containerImpl!.loadPersistentStores(completionHandler: { (storeDescription, error) in
                if let error = error as NSError? {
                    fatalError("Unresolved error \(error), \(error.userInfo)")
                }
            })
            containerImpl!.viewContext.automaticallyMergesChangesFromParent = true
        }
        return containerImpl!
    }
}

struct ItemPopupItem: Identifiable {
    let id = UUID()
    var item: Item?
}

struct ItemListContentView: View {
    @FetchRequest(sortDescriptors: []) private var items: FetchedResults<Item>
    @State var popupItem : ItemPopupItem?
    
    var body: some View {
        List {
            ForEach(items) { item in
                Text("\(item.updateTime!)")
                    .onTapGesture {
                        popupItem = ItemPopupItem(item: item)
                    }
            }
        }
        .overlay(
            EmptyView()
                .sheet(item: $popupItem) { popupItem in
                    ItemPopupView(popupItem: popupItem)
                }
        )
    }
}

class ItemsWrapper : ObservableObject {
    public var items : [Item] = []
    
    init() {
    }
    
    init(items: [Item]) {
        self.items = items
    }
}

struct ItemPopupView: View {
    @Environment(\.managedObjectContext) private var viewContext
    var popupItem : ItemPopupItem
    @StateObject var itemsWrapper = ItemsWrapper()
    @State public var updateOperation: ChildContextAndObject<Item>?
    
    init(popupItem : ItemPopupItem) {
        self.popupItem = popupItem
        _itemsWrapper = StateObject(wrappedValue: ItemsWrapper(items: [popupItem.item!])) // effects identity
    }

    var body: some View {
        let _ = Self._printChanges()
        VStack {
            Button("Edit") {
                updateOperation = ChildContextAndObject(withExistingObject: popupItem.item!, in: viewContext)
            }
            Text("\(popupItem.item!.updateTime!)")
        }
        .overlay(
            EmptyView()
            .sheet(item: $updateOperation) { updateOperation in
                EditItemView(item: updateOperation.childObject!)
                    .environment(\.managedObjectContext, updateOperation.childContext)
            }
        )
    }
}

struct EditItemView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @Environment(\.dismiss) var dismiss
    @ObservedObject var item : Item
  
    var body: some View {
        Button("Save") {
            print("save")
            item.updateTime = Date()
            try? viewContext.save()
            try? viewContext.parent!.save()
            dismiss()
        }
    }
}
0

There are 0 answers