iosswiftswiftuiphotosui

How to properly update a State var from a separate view in swiftUI


I have a WineInfoView that displays an array of images of a particular wine. The last "image" in the horizontal scroll is a blank image that when tapped on shows the users camera roll and allows them to add a new image.

When I test it out in HorizontalImageScrollView() i correctly places the new image before the last "image" but the changes are visible when tested in ``WineInfoView``` or running the app on my device.

Here is the code for the two views:

WineInfoView

struct WineInfoView: View {
    //ADD MODEL HERE
    @State var wine: Wine
    
    var body: some View {
        HorizontalImageScrollView(wine: $wine)         
    }
}

Horizontal Image Scroll:

struct HorizontalImageScrollView: View {
    @Binding var wine: Wine
    
    //For selecting / adding phtotos
    @State var photoPickerPresented: Bool = false
    @State var cameraPresente: Bool = false
    @State private var selectedItem: PhotosPickerItem?
    @State var image: UIImage?
    
    var body: some View {
        ScrollView(.horizontal, content: {
            //parse through images that exist
            HStack {
                ForEach(wine.images, id: \.self) { image in
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 175, height: 250)
                        .clipShape(RoundedRectangle(cornerRadius: 25.0))
                        .padding()
                }
                ////////////////////////////////////////////////////////////////////////////
                ///// PHOTO PICKER
                //empty "Image" to add more pictures
                PhotosPicker(selection: $selectedItem, label: {
                    ZStack {
                        Color(uiColor: UIColor(rgb: 0xEDEDE9))
                            .frame(width: 175, height: 250)
                            .clipShape(RoundedRectangle(cornerRadius: 25.0))
                            .padding([.leading,.trailing])
                        Image(systemName: "plus.circle.fill")
                    }
                })
                .onChange(of: selectedItem) {
                    Task {
                        if let data = try? await selectedItem?.loadTransferable(type: Data.self) {
                            image = UIImage(data: data)
                            //add image to local object
                            wine.images.append(image!)
                            //update record in DB
                            updateWineRecordPhotos(wine.images)
                        } else {
                            print("Failed to load the image")
                        }
                    }
                }
                ////////////////////////////////////////////////////////////////////////////
            }
            
        })
    }
}

Can someone explain why the changes are not being displayed in the WineInfoView

Edit

I have two use cases for this Image scroll:

  1. Displaying already existing images in a WineInfoView which is display by selecting a wine from a list.
  2. Allowing users to add images to an empty wine object while adding a new wine when they press the button with the plus sign.

Here is the prevalent code:

MyListView

struct MyListView: View {
    @ObservedObject var model : Model
    @State private var newWineViewVisible: Bool = false
    
    init(model: Model) {
        self.model = model
    }
    
    var body: some View {
        //Header
        NavigationStack {
            VStack {
                HStack {
                    Spacer()
                    Text ("vino")
                        .font(.custom("DMSerifDisplay-Regular", size: 24))
                        .padding(.leading, 50)
                    
                    Spacer()
                    //user pfp : on tap -> account managment
                    Button(action: {
                        newWineViewVisible = true
                    }, label: {
                        Image(systemName: "plus.circle.fill")
                            .resizable()
                    })
                    .frame(width: 40, height: 40)
                    .padding()
                }
                List {
                    ForEach($model.activeUser.wines, id: \.id) { wine in
                        NavigationLink(destination: WineInfoView(wine: wine), label: {
                            WineListView(wine: wine.wrappedValue)
                                .frame(height: 100)
                        })
                    }
                }
            }
            .fullScreenCover(isPresented: $newWineViewVisible, onDismiss: {
                newWineViewVisible = false
            }, content: {
                NewWineView(model: model)
            })
        }
    }
}

NewWineView

struct NewWineView: View {
    @State private var wine: Wine = Wine()
    
    //MARK: body
    var body: some View {
        HorizontalImageScrollView(wine: $wine)
    }
}

Wine class Note: This needs to stay a class in order to run the async init from a CKRecord

class Wine: Identifiable {
    
    var type: String
    var vintage: String
    var vineyard: Vineyard
    var images: [UIImage]
    var cloudID: String
    var recordID: CKRecord.ID
    let id: UUID = UUID()//to make wine conform to identifiable
    
    init(_ type: String,_ vintage: String,_ vineyard: Vineyard,_ images: [UIImage] = [],_ cloudID: String = "",_ recordID: CKRecord.ID = CKRecord(recordType: "Wine").recordID) {
        self.type = type
        self.vintage = vintage
        self.vineyard = vineyard
        self.images = images
        self.cloudID = cloudID
        self.recordID = recordID
    }
    
    init() {
        self.type = ""
        self.vintage = ""
        self.vineyard = Vineyard()
        self.images = []
        let record = CKRecord(recordType: "Wine")
        self.cloudID = record.recordID.recordName
        self.recordID = record.recordID
    }
    
    init(_ ckrecord: CKRecord) async throws {
        self.type = ckrecord["Type"] as! String
        self.vintage = ckrecord["Vintage"] as! String
        self.cloudID = ckrecord.recordID.recordName
        self.recordID = ckrecord.recordID
        
        //Process Photos
        let imageAssets = ckrecord["Pictures"] as! [CKAsset]
        var uiImages: [UIImage] = []
        var imageData = Data()
        for asset in imageAssets {
            do {
                imageData = try Data(contentsOf: asset.fileURL!)
                uiImages.append(UIImage(data: imageData)!)
            }
            catch {
                print(error)
            }
        }
        self.images = uiImages
        
        //create vineyard from record
        //set as empty in order to use self in async
        self.vineyard = Vineyard()
        let vineyardRef = ckrecord["Vineyard"] as! CKRecord.Reference
        let cloudDB = CKContainer.default().publicCloudDatabase
        Task {
            do {
                let fetchedRecord = try await cloudDB.record(for: vineyardRef.recordID)
                // Handle the fetched record
                self.vineyard = Vineyard(fetchedRecord)
            } catch {
                print("Error fetching record: \(error.localizedDescription)")
            }
        }
    }
}

With the current code for HorizontalImageScrollView shown the use case for wine info view is satisfied but when adding images to an empty wine object, Wine(), Images only appear after a second image is selected.

This should provide a comprehensive overview of the problem if extra code is needed please let me know.


Solution

  • You should instantiate a wine object in your WineInfoView, such as: @State private var wine: Wine = Wine(.... ,images: []) so that images can be added to and displayed in WineInfoView.

    Also avoid using forced unwrapping !, eg: wine.images.append(image!), see the code update.

    Here is my test code that works well for me when I select a photo, not a video etc...

    On MacOS 14.3, using Xcode 15.2, tested on real ios 17 devices (not Previews) and macCatalyst. It could be different on older systems.

    // for testing
    struct Wine: Identifiable {
        let id = UUID()
        var name: String
        var images: [UIImage]
        // ....
    }
    
    struct WineInfoView: View {
        @State private var wine: Wine = Wine(name: "Chateau Cardboard", images: []) // <--- here
        
        var body: some View {
            HorizontalImageScrollView(wine: $wine)
        }
    }
    
    struct HorizontalImageScrollView: View {
        @Binding var wine: Wine
        
        //For selecting / adding phtotos
        @State var photoPickerPresented: Bool = false
        @State var cameraPresente: Bool = false
        @State private var selectedItem: PhotosPickerItem?
        @State var image: UIImage?  // <--- not used
        
        var body: some View {
            ScrollView(.horizontal, content: {
                //parse through images that exist
                HStack {
                    ForEach(wine.images, id: \.self) { image in
                        Image(uiImage: image)
                            .resizable()
                            .scaledToFill()
                            .frame(width: 175, height: 250)
                            .clipShape(RoundedRectangle(cornerRadius: 25.0))
                            .padding()
                    }
                    //empty "Image" to add more pictures
                    PhotosPicker(selection: $selectedItem, label: {
                        ZStack {
                            Color(uiColor: .lightGray)
                                .frame(width: 175, height: 250)
                                .clipShape(RoundedRectangle(cornerRadius: 25.0))
                                .padding([.leading,.trailing])
                            Image(systemName: "plus.circle.fill")
                        }
                    })
                    .onChange(of: selectedItem) {
                        Task {
                            if let data = try? await selectedItem?.loadTransferable(type: Data.self),
                                let img = UIImage(data: data) {
                                image = img
                                //add image to local object
                                wine.images.append(img)
                                //update record in DB
                                // updateWineRecordPhotos(wine.images) // for testing
                            } else {
                                print("----> Failed to load the image")
                            }
                        }
                    }
                }
            })
        }
    }
    

    EDIT-1:

    if you are passing the wine object from a parent view into WineInfoView, then use a Binding, such as:

    struct ContentView: View {
        @State private var wine: Wine = Wine(name: "Chateau Cardboard", images: []) // <--- here
        
        var body: some View {
            WineInfoView(wine: $wine) // <--- here
        }
    }
    
    struct WineInfoView: View {
        @Binding var wine: Wine // <--- here
        
        var body: some View {
            HorizontalImageScrollView(wine: $wine)
        }
    }
    
    struct HorizontalImageScrollView: View {
        @Binding var wine: Wine  // <--- here
        // .....
    

    EDIT-2: removed

    EDIT-3:

    In response to your new code, here is a fully working example code that works well for me.

    The important parts are to use struct Wine, struct Vineyard etc... for the contituents of the model. Since you don't want to show the code for model, I created a class Model: ObservableObject that works well in my tests. This model is passed to other views using the @EnvironmentObject var model: Model.

    The example code uses @Binding var wine: Wine to allow particular wine object to be changed in the views, eg adding images.

    See also: monitoring data

    struct ContentView: View {
        @StateObject var model = Model()  // <--- here, must have only one
        
        var body: some View {
            MyListView()
                .environmentObject(model) // <--- here
        }
    }
    
    struct HorizontalImageScrollView: View {
        @Binding var wine: Wine  // <--- here
        
        //For selecting / adding phtotos
        @State var photoPickerPresented: Bool = false
        @State var cameraPresente: Bool = false
        @State private var selectedItem: PhotosPickerItem?
        @State var image: UIImage?
        
        var body: some View {
            ScrollView(.horizontal, content: {
                //parse through images that exist
                HStack {
                    ForEach(wine.images, id: \.self) { image in
                        Image(uiImage: image)
                            .resizable()
                            .scaledToFill()
                            .frame(width: 175, height: 250)
                            .clipShape(RoundedRectangle(cornerRadius: 25.0))
                            .padding()
                    }
    
                    PhotosPicker(selection: $selectedItem, label: {
                        ZStack {
                            Color.pink
                                .frame(width: 175, height: 250)
                                .clipShape(RoundedRectangle(cornerRadius: 25.0))
                                .padding([.leading,.trailing])
                            Image(systemName: "plus.circle.fill")
                        }
                    })
                    .onChange(of: selectedItem) {
                        Task {
                            if let data = try? await selectedItem?.loadTransferable(type: Data.self) {
                                image = UIImage(data: data)
                                //add image to local object
                                wine.images.append(image!)
                                //update record in DB
                              //  updateWineRecordPhotos(wine.images)
                            } else {
                                print("Failed to load the image")
                            }
                        }
                    }
                }
            })
        }
    }
    
    struct MyListView: View {
        @EnvironmentObject var model: Model  // <--- here
        
        @State private var newWineViewVisible: Bool = false
        
        var body: some View {
            //Header
            NavigationStack {
                VStack {
                    HStack {
                        Spacer()
                        Text ("vino")
                            .font(.custom("DMSerifDisplay-Regular", size: 24))
                            .padding(.leading, 50)
                        
                        Spacer()
                        //user pfp : on tap -> account managment
                        Button(action: {
                            newWineViewVisible = true
                        }, label: {
                            Image(systemName: "plus.circle.fill")
                                .resizable()
                        })
                        .frame(width: 40, height: 40)
                        .padding()
                    }
                    
                    // <---
                    List {
                        ForEach($model.activeUser.wines) { $wine in
                            NavigationLink(destination: WineInfoView(wine: $wine)) {
                                WineListView(wine: wine).frame(height: 100)
                            }
                        }
                    }
                    // <---
                    
                }
                .fullScreenCover(isPresented: $newWineViewVisible) {
                    NewWineView()
                        .environmentObject(model) // <--- here
                }
            }
        }
    }
    
    struct NewWineView: View {
        @EnvironmentObject var model: Model  // <--- here
        @Environment(\.dismiss) var dismiss  // <--- here
        
        @State private var newWine = Wine()
        
        var body: some View {
            HorizontalImageScrollView(wine: $newWine)
            Button("Save new wine") {
                model.activeUser.wines.append(newWine)
                dismiss()
            }.buttonStyle(.bordered)
        }
    }
    
    struct WineListView: View {
        var wine: Wine
        
        var body: some View {
            HStack {
                Text(wine.type)
                Text(wine.vintage)
                // ....other
            }
        }
    }
    
    struct WineInfoView: View {
        @Binding var wine: Wine  // <--- here
        
        var body: some View {
            HorizontalImageScrollView(wine: $wine)
        }
    }
    
    struct Wine: Identifiable {  // <--- here
        
        var type: String
        var vintage: String
        var vineyard: Vineyard
        var images: [UIImage]
        var cloudID: String
        var recordID: CKRecord.ID
        let id: UUID = UUID()//to make wine conform to identifiable
        
        init(_ type: String,_ vintage: String,_ vineyard: Vineyard,_ images: [UIImage] = [],_ cloudID: String = "",_ recordID: CKRecord.ID = CKRecord(recordType: "Wine").recordID) {
            self.type = type
            self.vintage = vintage
            self.vineyard = vineyard
            self.images = images
            self.cloudID = cloudID
            self.recordID = recordID
        }
        
        init() {
            self.type = "new wine type"
            self.vintage = String(Int.random(in: 1900...2023)) // for testing
            self.vineyard = Vineyard()
            self.images = []
            let record = CKRecord(recordType: "Wine")
            self.cloudID = record.recordID.recordName
            self.recordID = record.recordID
        }
        
        init(_ ckrecord: CKRecord) async throws {
            self.type = ckrecord["Type"] as! String
            self.vintage = ckrecord["Vintage"] as! String
            self.cloudID = ckrecord.recordID.recordName
            self.recordID = ckrecord.recordID
            
            //Process Photos
            let imageAssets = ckrecord["Pictures"] as! [CKAsset]
            var uiImages: [UIImage] = []
            var imageData = Data()
            for asset in imageAssets {
                do {
                    imageData = try Data(contentsOf: asset.fileURL!)
                    uiImages.append(UIImage(data: imageData)!)
                }
                catch {
                    print(error)
                }
            }
            self.images = uiImages
            
            //create vineyard from record
            //set as empty in order to use self in async
            self.vineyard = Vineyard()
            if let fetchedRecord = await getRecord(ckrecord) {  // <--- here
                self.vineyard.record = fetchedRecord
            }
        }
        
        // --- here, untested
        func getRecord(_ ckrecord: CKRecord) async -> CKRecord? {
            let vineyardRef = ckrecord["Vineyard"] as! CKRecord.Reference
            let cloudDB = CKContainer.default().publicCloudDatabase
            let fetchedRecord = Task { () -> CKRecord in
                do {
                    return try await cloudDB.record(for: vineyardRef.recordID)
                } catch {
                    print("Error fetching record: \(error)")
                }
                throw CKError(.badContainer)
            }
            let result = await fetchedRecord.result
            do {
                 return try result.get()
             } catch {
                 print("Unable to fetch the record \(error)")
             }
            return nil
        }
        
    }
    
    class Model: ObservableObject {  // <--- here
        
        @Published var activeUser: User = User()
        // ...
    
    }
    
    struct Vineyard: Identifiable {  // <--- here
        let id = UUID()
        var name: String
        var record: CKRecord
        // ....
        
        init() { 
            self.name = "Chateau"
            self.record = CKRecord(recordType: "Vineyard")
        }
        
        init(_ ckrecord: CKRecord) {
            self.name = "Chateau"
            self.record = ckrecord
        }
    }
    
    struct User: Identifiable {
        let id = UUID()
        
        var name: String = "no name"
        var wines: [Wine] = []
        // ....
    }