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:
CategoryView
is selected), that category displays a highlighted imageUnfortunately 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
}
}
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
}
}
}