SwiftUI实现分类单选:如何确保仅一个Category的isActive为true
Great question! The issue of multiple categories being selectable at once happens because you're managing the isActive state individually on each Category model. Let's adjust your structure to enforce single selection while keeping your MVVM pattern intact—you don't necessarily need to delete the isActive property, but a cleaner approach is to move selection state management to your parent view model.
Recommended Approach
The key idea is to have a single source of truth for the selected category in your CategoriesViewModel, rather than letting each Category track its own active state. Here's how to implement this step by step:
1. Simplify the Category Model
First, remove the isActive property from your Category struct—this keeps your model focused on data, not UI state:
struct Category: Identifiable { var id = UUID().uuidString var category: categories } /// Represent all possible categories cases enum categories: String, CaseIterable { case love = "❤️" case family = "👨👩👦👦" case urgent = "🆘" case sex = "🔥" case funny = "😂" case school = "🤓" case sport = "🏅" } #if DEBUG let categoryTest = [ Category(category: .love), Category(category: .family), Category(category: .urgent), Category(category: .sex), Category(category: .funny), Category(category: .school), Category(category: .sport) ] #endif
2. Update the Parent ViewModel (CategoriesViewModel)
Add a property to track the selected category's ID, and a method to handle selection. This becomes your single source of truth for selection state:
import SwiftUI import Foundation import Combine class CategoriesViewModel: ObservableObject { @Published var categoryViewModels = [CategoryViewModel]() @Published var selectedCategoryId: String? // Tracks the single selected category private var cancellables = Set<AnyCancellable>() init() { self.categoryViewModels = categoryTest.map { category in CategoryViewModel(category: category) } } // Handle category selection: toggle if already selected, or set new selection func selectCategory(_ categoryId: String) { selectedCategoryId = selectedCategoryId == categoryId ? nil : categoryId // If you don't want to allow deselection, use this instead: // selectedCategoryId = categoryId } }
3. Simplify the Child ViewModel (CategoryViewModel)
Since we're no longer tracking isActive on the model, this view model can be stripped down to just wrap the Category data:
import SwiftUI import Foundation import Combine class CategoryViewModel: ObservableObject, Identifiable { @Published var category: Category var id: String { category.id } // Use the category's ID directly init(category: Category) { self.category = category } }
4. Update the Views
Modify CategoriesView to pass the selection state down to each CategoryView, and handle tap events through the parent view model:
import SwiftUI import Foundation import Combine struct CategoriesView: View { @StateObject var categoriesVM = CategoriesViewModel() // Use StateObject for view models initialized here var body: some View { VStack(alignment: .leading) { Text("Catégories") .font(.title).bold() .offset(y: 20) ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(categoriesVM.categoryViewModels) { categoryVM in CategoryView( categoryVM: categoryVM, isActive: categoriesVM.selectedCategoryId == categoryVM.id, onTap: { categoriesVM.selectCategory(categoryVM.id) } ) } } } } .padding(.leading, 15) } } struct CategoriesView_Previews: PreviewProvider { static var previews: some View { CategoriesView() } } struct CategoryView: View { @ObservedObject var categoryVM: CategoryViewModel let isActive: Bool // Receive active state from parent let onTap: () -> Void // Receive tap action from parent var body: some View { Text(categoryVM.category.category.rawValue) .font(.system(size: isActive ? 26 : 22, weight: .bold)) .frame( width: isActive ? 80 : 60, height: isActive ? 80 : 60 ) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .shadow(color: Color.black.opacity(0.1), radius: 10, x: 10, y: 10) .onTapGesture(perform: onTap) .frame(height: 130) } }
Why This Works
- Single Source of Truth: The
selectedCategoryIdinCategoriesViewModelensures only one category can be selected at a time. - Separation of Concerns: Your
Categorymodel now only holds data, while view state is managed in the view model—this aligns with MVVM best practices. - Reusable Views:
CategoryViewis now a pure presentation component that just renders based on the state passed to it, making it easier to reuse elsewhere.
If you really want to keep the isActive property on the Category model (not recommended), you could adjust the selectCategory method to loop through all categories and set isActive to false except for the selected one. But this mixes data and state, which can lead to harder-to-maintain code over time.
内容的提问来源于stack exchange,提问作者theovogg

