Jake Maschoff
Jake Maschoff

Reputation: 25

Nested Struct models not causing view to re-render SwiftUI

I have a view that listens to a Model via and ObservableObject:

class Feed : ObservableObject {
    
    // Posts to be displayed
    @Published var posts = [Posts]()
    ...
    ...
}

And the Posts model looks like:

struct Posts: Hashable, Identifiable {
    let bar: Bars
    let time: String
    var description: String
    let id: String
    let createdAt : String
    let tags : [Friends]
    let groups : [String]
    var intializer : Friends    // Creator of the post
}

Which contains multiple other Struct models like Friends and Bars. However, when I do change a value within one of these other models, it doesn't trigger the @Published to fire, so the view isn't redrawn. For example, the Friends model looks like:

struct Friends : Hashable {
    
    static func == (lhs: Friends, rhs: Friends) -> Bool {
        return lhs.id == rhs.id
    }
    
    let name: String
    let username: String
    let id : String
    var thumbnail : UIImage?
    var profileImgURL : String?
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

but when I change the thumbnail, the views are not redrawn. But when I change something directly apart of the Posts model, like the description attribute, the view is redrawn. How am I able to have the view redraw when the underlying model values are changed?

I change the thumbnail as shown:

        // Grab the thumbnail of user (if exists)
        if post.intializer.profileImgURL != nil {
            AF.request(post.intializer.profileImgURL!, method: .get, encoding: URLEncoding.default)
                .validate()
                .responseData { (response) in
                    if let data = response.value {
                        // Find the index of where this post is in the array and set the profile img
                        if let indexOfPost = self.posts.firstIndex(of: post) {
                            self.posts[indexOfPost].intializer.thumbnail = UIImage(data: data)
                        }
                    }
            }
        }

But if I were to change the description doing the same thing:

        // Grab the thumbnail of user (if exists)
        if post.intializer.profileImgURL != nil {
            AF.request(post.intializer.profileImgURL!, method: .get, encoding: URLEncoding.default)
                .validate()
                .responseData { (response) in
                    if let data = response.value {
                        // Find the index of where this post is in the array and set the profile img
                        if let indexOfPost = self.posts.firstIndex(of: post) {
                            self.posts[indexOfPost].description = "Loaded!!!!"
                        }
                    }
            }
        }

And when I do this, the view does update and change. I can see that the thumbnails are being loaded correctly, too, because I can print out the data sent, and sometimes the thumbnails are redrawn for the view correctly.

EDIT

As suggested I tried adding a mutating func to the struct:

struct Posts: Hashable, Identifiable {
    let bar: Bars
    let time: String
    var description: String
    let id: String
    let createdAt : String
    let tags : [Friends]
    let groups : [String]
    var intializer : Friends    // Creator of the post
    
    mutating func addInitThumbnail(img : UIImage) {
        self.intializer.thumbnail = img
    }
}

and then using it:

    func grabInitThumbnail(post : Posts) {
        // Grab the thumbnail of user (if exists)
        if post.intializer.profileImgURL != nil {
            AF.request(post.intializer.profileImgURL!, method: .get, encoding: URLEncoding.default)
                .validate()
                .responseData { (response) in
                    if let data = response.value {
                        // Find the index of where this post is in the array and set the profile img
                        if let indexOfPost = self.posts.firstIndex(of: post) {
                            if let thumbnailImg = UIImage(data: data) {
                                self.posts[indexOfPost].addInitThumbnail(img: thumbnailImg)
                            }
                        }
                    }
            }
        }
    }

but it did not work either.

However, when I do:

    func grabInitThumbnail(post : Posts) {
        // Grab the thumbnail of user (if exists)
        if post.intializer.profileImgURL != nil {
            AF.request(post.intializer.profileImgURL!, method: .get, encoding: URLEncoding.default)
                .validate()
                .responseData { (response) in
                    if let data = response.value {
                        // Find the index of where this post is in the array and set the profile img
                        if let indexOfPost = self.posts.firstIndex(of: post) {
                            self.posts[indexOfPost].intializer.thumbnail = UIImage(data: data)
                            self.posts[indexOfPost].description = "Loaded!!!!"
                        }
                    }
            }
        }
    }

the images are loaded and set correctly...? So I think it might have something to do with UIImages directly?

Upvotes: 1

Views: 689

Answers (2)

Ruben Mim
Ruben Mim

Reputation: 181

it's been a long time but still :

1 - mutating func is not necessary.

2 - The re-rendering won't happen if you only change the nested object and not the "observed" object it self.

3 - You can play with the getters and setters as well, to pull the wanted value to change and update it back.

Considering we have a complex object such as :

struct Content{

    var listOfStuff : [Any] = ["List", 2, "Of", "Stuff"]
    var isTheSkyGrey : Bool = false
    var doYouLikeMyMom : Bool = false
    var status : UIImage? = UIImage(systemName: "paperplane")
}

Now let's wrap/nest this object into a ContentModel for the View. If, while using the @State var contentModel : ContentModel in the View, we change change one of the properties directly by accessing the nested object(like so : model.content.status = "Tchak"), it will not trigger a re-rendering because the ContentModel itself didn't change. Understanding this, we need to trigger a tiny useless change in the ContentModel :

struct ContentModel {

    private var change : Bool = false

    private var content : Content = Content() {
        didSet{
            // this will trigger the view to re-render
            change.toggle()
        }
    }
    
    //the value you want to change
    var status : UIImage?{
        get{
            contentModel.status
        }
    
        set{
            contentModel.status = newValue
        }
    }
}

Now what's left to do is to observe the change of the content in the view.

struct ContentPouf: View {

    @State var contentModel = ContentModel()

    var body: some View {
        Image(uiImage: contentModel.status)
            .onTapGesture {
                contentModel.status = UIImage(systemName: "pencil")
            }
    }
}

and using an ObservableObject it would be :

class ContentObservable : ObservableObject {
    @Published var content : ContentModel = ContentModel()
    func handleTap(){
        content.status = UIImage(systemName: "pencil")
    }
}

and

@StateObject var viewModel : ContentObservable = ContentObservable()

var body: some View {
    Image(uiImage :viewModel.content.status)
        .onTapGesture {
            viewModel.handleTap()
        }
}

Upvotes: 0

Zeona
Zeona

Reputation: 454

I tried using mutating function and also updating value directly, both cases it worked.

UPDATED CODE (Added UIImage in new struct)

import SwiftUI
import Foundation

//Employee
struct Employee : Identifiable{

    var id: String = ""
    var name: String = ""
    var address: Address
    var userImage: UserIcon
    
    init(name: String, id: String, address: Address, userImage: UserIcon) {
        self.id = id
        self.name = name
        self.address = address
        self.userImage = userImage
    }
    
    mutating func updateAddress(with value: Address){
        address = value
    }
}

//User profile image
struct UserIcon {
    var profile: UIImage?
    
    init(profile: UIImage) {
        self.profile = profile
    }
    
    mutating func updateProfile(image: UIImage) {
        self.profile = image
    }
}

//Address
struct Address {

    var houseName: String = ""
    var houseNumber: String = ""
    var place: String = ""
    
    init(houseName: String, houseNumber: String, place: String) {
        self.houseName = houseName
        self.houseNumber = houseNumber
        self.place = place
    }
    
    func getCompleteAddress() -> String{
        let addressArray = [self.houseName, self.houseNumber, self.place]
        return addressArray.joined(separator: ",")
    }
}

//EmployeeViewModel
class EmployeeViewModel: ObservableObject {
    @Published var users : [Employee] = []
    
    func initialize() {
        self.users = [Employee(name: "ABC", id: "100", address: Address(houseName: "Beautiful Villa1", houseNumber: "17ABC", place: "USA"), userImage: UserIcon(profile: UIImage(named: "discover")!)),
        Employee(name: "XYZ", id: "101", address: Address(houseName: "Beautiful Villa2", houseNumber: "18ABC", place: "UAE"), userImage: UserIcon(profile: UIImage(named: "discover")!)),
        Employee(name: "QWE", id: "102", address: Address(houseName: "Beautiful Villa3", houseNumber: "19ABC", place: "UK"), userImage: UserIcon(profile: UIImage(named: "discover")!))]
    }
    
    
    func update() { //both below cases worked
        self.users[0].address.houseName = "My Villa"
        //self.users[0].updateAddress(with: Address(houseName: "My Villa", houseNumber: "123", place: "London"))
        self.updateImage()
    }
    
    func updateImage() {
        self.users[0].userImage.updateProfile(image: UIImage(named: "home")!)
    }
}
    
//EmployeeView
struct EmployeeView: View {
    @ObservedObject var vm = EmployeeViewModel()
    
    var body: some View {
       NavigationView {
            List {
                ForEach(self.vm.users) { user in
                    VStack {
                        Image(uiImage: user.userImage.profile!)
                        Text("\(user.name) - \(user.address.getCompleteAddress())")
                    }
                }.listRowBackground(Color.white)
            }.onAppear(perform: fetch)
            .navigationBarItems(trailing:
                Button("Update") {
                    self.vm.update()
                }.foregroundColor(Color.blue)
            )
             .navigationBarTitle("Users", displayMode: .inline)
        
       }.accentColor(Color.init("blackTextColor"))
    }
    
    func fetch() {
        self.vm.initialize()
    }
}

Upvotes: 1

Related Questions