Search code examples
swiftxcodeswiftuimvvm

Why is my view not updating using MVVM architecture?


I have two Swift UI views, CategoriesListView and CategoryView. CategoriesListView works sort of like a navigation menu and CategoryView is a view that can be tapped. I'm trying to attempt the following:

  • All the categories start off un-highlighted except the first category.
  • If user selects one of the categories in the list (CategoryView is selected), that category displays a highlighted image
  • Whatever category was selected before gets un-highlighted and displays an un-highlighted image

Unfortunately once something is tapped, the category views don't get changed. Do I have to do something in the view model? I am pretty new to SwiftUI and am still learning a lot

my views:

struct CategoriesListView: View {
    @Environment(CategoriesListViewModel.self) var categoriesListViewModel
    
    var selectedIndex: Int { categoriesListViewModel.categories.firstIndex(where: {
            $0.isSelected == true
        }) ?? 0
    }
    
    var body: some View {
        @Bindable var categoriesListViewModel = categoriesListViewModel
        ScrollView(.horizontal) {
            HStack(alignment: .top, spacing: 12) {
                Spacer()
                // With each category in the categories list view model, create a category view. If the category view gets tapped, change that view to be the selected image. the previously selected view shows an unselected image.
                ForEach(Array(categoriesListViewModel.categories.enumerated()), id: \.offset) { index, element in
                    CategoryView(categoryViewModel: $categoriesListViewModel.categories[index]).onTapGesture {
                        categoriesListViewModel.categories[selectedIndex].isSelected = false
                        categoriesListViewModel.categories[index].isSelected = true
                        CategoryView(categoryViewModel: $categoriesListViewModel.categories[index])
                    }
                }
            }
        }
    }
}

struct CategoryView: View {
    @Binding var categoryViewModel: CategoryViewModel
    
    var body: some View {
        if categoryViewModel.isSelected {
            categoryViewModel.category.highlightedIconImage
        } else {
            categoryViewModel.category.iconImage
        }
    }
}

my view models:


@Observable
class CategoriesListViewModel {
    var categories: [CategoryViewModel] = []
    var currentSelection: Int = 0
    var previousSelection: Int? = nil
    
    init(categories: [CategoryViewModel], currentSelection: Int, previousSelection: Int? = nil) {
        self.categories = setCategoriesToShow()
        self.currentSelection = currentSelection
        self.previousSelection = previousSelection
    }
    
    func setCategoriesToShow() -> [CategoryViewModel] {
        var categoriesToShow = [CategoryViewModel]()
        let resortCategory = Category(
            identifier: "a",
            title: "Resort",
            outfits: [],
            iconImage: Image("ResortUnselected"),
            highlightedIconImage: Image("ResortSelected")
        )
        
        var europeCategory = Category(
            identifier: "b",
            title: "Europe",
            outfits: [],
            iconImage: Image("EuropeUnselected"),
            highlightedIconImage: Image("EuropeSelected")
        )
        
        var brunchCategory = Category(
            identifier: "c",
            title: "Brunch",
            outfits: [],
            iconImage: Image("BrunchUnselected"),
            highlightedIconImage: Image("BrunchSelected")
        )
        
        var athleisureCategory = Category(
            identifier: "d",
            title: "Athleisure",
            outfits: [],
            iconImage: Image("AthleisureUnselected"),
            highlightedIconImage: Image("AthleisureSelected")
        )
        
        var workCategory = Category(
            identifier: "e",
            title: "Work",
            outfits: [],
            iconImage: Image("WorkUnselected"),
            highlightedIconImage: Image("WorkSelected")
        )
        
        categoriesToShow.append(CategoryViewModel(category: resortCategory, isSelected: true))
        categoriesToShow.append(CategoryViewModel(category: europeCategory, isSelected: false))
        categoriesToShow.append(CategoryViewModel(category: brunchCategory, isSelected: false))
        categoriesToShow.append(CategoryViewModel(category: athleisureCategory, isSelected: false))
        categoriesToShow.append(CategoryViewModel(category: workCategory, isSelected: false))
        
        return categoriesToShow
    }
}

class CategoryViewModel: ObservableObject, Identifiable {
    var category: Category
    var isSelected: Bool
    
    init(category: Category, isSelected: Bool) {
        self.category = category
        self.isSelected = isSelected
    }
}

Solution

  • Do not mix ObservableObject and @Observable (eg your CategoryViewModel). Also when you use Identifiable you need to have a let id....

    Try this approach cleaning up the ForEach in CategoriesListView and removing the @Bindable var categoriesListViewModel = categoriesListViewModel

     ForEach(Array(categoriesListViewModel.categories.enumerated()), id: \.offset) { index, element in
         CategoryView(categoryViewModel: categoriesListViewModel.categories[index])
             .onTapGesture {
             categoriesListViewModel.categories[selectedIndex].isSelected = false
             categoriesListViewModel.categories[index].isSelected = true
         }
     }
    

    or without using index, much better and recommended

            ForEach(categoriesListViewModel.categories) { category in
                CategoryView(categoryViewModel: category)
                    .onTapGesture {
                        // turn off all selections
                        categoriesListViewModel.categories.forEach{
                            $0.isSelected = false
                        }
                        // turn on only this one
                        category.isSelected = true
                    }
            }
    

    and

    @Observable
     class CategoryViewModel: Identifiable {
         let id = UUID()
         var category: Category
         var isSelected: Bool
         
         init(category: Category, isSelected: Bool) {
             self.category = category
             self.isSelected = isSelected
         }
     }
    
    struct CategoryView: View {
        var categoryViewModel: CategoryViewModel
        
        var body: some View {
            if categoryViewModel.isSelected {
                categoryViewModel.category.highlightedIconImage
            } else {
                categoryViewModel.category.iconImage
            }
        }
    }
    

    This is assuming you have have declared @State private var model = CategoriesListViewModel(....) in your view hierarchy and pass this model down using .environment(model)

    EDIT-1

    Ok, if you have tens of thousands of categories, then processing may take a few milliseconds in a forEach loop. The following more efficient code uses an extra var to keep track of which category to turn off, instead of using the ugly forEach loop.

    struct CategoriesListView: View {
        @Environment(CategoriesListViewModel.self) var categoriesListViewModel
        @State private var prev: CategoryViewModel? // <--- here
        
        var body: some View {
            ScrollView(.horizontal) {
                HStack(alignment: .top, spacing: 12) {
                    Spacer()
                    ForEach(categoriesListViewModel.categories) { category in
                        CategoryView(categoryViewModel: category)
                            .onTapGesture {
                                // turn on the category
                                category.isSelected = true
                                // turn off prev category
                                prev?.isSelected = false
                                // register the new prev
                                prev = category
                            }
                    }
                }
            }
            .onAppear {
                // since you set the currentSelection=0
                prev = categoriesListViewModel.categories.first  // <--- here
            }
        }
    }