Elena Rubilova
Elena Rubilova

Reputation: 469

Modify state variables from onTapGesture in a generic func

I have repetitive code for survey steps that I'm trying to make reusable:

private var workoutTypeSection: some View {
    VStack(spacing: 20) {
        header("What Is Your Preferred Workout?")
        
        ForEach(WorkoutType.allCases) { workoutType in
            Text(workoutType.rawValue)
                .font(.headline)
                .foregroundColor(.purple)
                .frame(height: 55)
                .frame(maxWidth: .infinity)
                .background(selectedWorkoutTypes.contains(workoutType) ?
                            Color.blue : Color.white)
                .cornerRadius(10)
                .onTapGesture {
                    workoutButtonPressed(workout: workoutType)
                }
        }
    }
    .padding(30)
}

private var bodyPartSection: some View {
    VStack(spacing: 20) {
        header("Select a Body Part to Strengthen:")
        
        ForEach(BodyPart.allCases) { bodyPart in
            Text(bodyPart.rawValue)
                .font(.headline)
                .foregroundColor(.purple)
                .frame(height: 55)
                .frame(maxWidth: .infinity)
                .background(selectedBodyParts.contains(bodyPart) ?
                            Color.blue : Color.white)
                .cornerRadius(10)
                .onTapGesture {
                    bodyPartButtonPressed(part: bodyPart)
                }
        }
    }
    .padding(30)
}

OnTapGesture selects or deselects survey options:

func workoutButtonPressed(workout: WorkoutType) {
    if selectedWorkoutTypes.contains(workout) {
        if let index = selectedWorkoutTypes.firstIndex(of: workout) {
            selectedWorkoutTypes.remove(at: index)
        }
    } else {
        selectedWorkoutTypes.append(workout)
    }
}

func bodyPartButtonPressed(part: BodyPart) {
    if selectedBodyParts.contains(part) {
        if let index = selectedBodyParts.firstIndex(of: part) {
            selectedBodyParts.remove(at: index)
        }
    } else {
        selectedBodyParts.append(part)
    }
}

where

@State var selectedWorkoutTypes: [WorkoutType] = []
@State var selectedBodyParts: [BodyPart] = []

and enum types are:

enum WorkoutType: String, CaseIterable, Codable, Identifiable {
case yoga = "Yoga"
case mobility = "Mobility"
case strength = "Strength"
case cardio = "Cardio" 

var id: WorkoutType { self }}

enum BodyPart: String, CaseIterable, Codable, Identifiable {
case legs = "Legs"
case core = "Core/Abs"
case back = "Back"
case chest = "Chest/Arms"
case neck = "Neck/Shoulders"
case body = "Whole Body"

var id: BodyPart { self }}

I created a generic function that makes the code reusable:

func surveyStepOptions<T : Identifiable & Hashable & CaseIterable & RawRepresentable >(_ enumType: T.Type, selected: [T])
-> some View
where T.RawValue == String
{
    ForEach(Array(enumType.allCases)) {option in
        
        Text(option.rawValue).font(.headline)
            .foregroundColor(.purple)
            .frame(height: 55)
            .frame(maxWidth: .infinity)
            .background(selected.contains(option) ?
                        Color.blue : Color.white)
            .cornerRadius(10)
            .onTapGesture {
                if selected.contains(option) {
                    if let index = selected.firstIndex(of: option) {
                        selected.remove(at: index)
                    }
                } else {
                    selected.append(option)
                }
            }
    }
}

I struggle with changing state variables in onTapGesture because it says "selected" param is unmutable, but when I make it mutable it says that I can't use inout in the escaping closure. How to fix it?

Upvotes: 0

Views: 99

Answers (2)

Andrei G.
Andrei G.

Reputation: 1557

@loremipsum provided a perfect answer to your question which should definitely be the solution, but I wanted to show what I imagined as an approach when I initially saw your question.

Assuming this is to be a survey or questionnaire format with questions and multiple choices for an answer, I'd structure it as follows:

  1. A model to define a question and the available answers
  2. A dictionary to keep track of the selections for each question
  3. A main view with a list and sections for each question
  4. A row view for each answer to allow for styling if selected or not.
  5. A method for the selection logic.

Here's the full code:

import SwiftUI

struct Question {
    let question: String
    let answers: [String]
}

struct MultipleChoiceView: View {
    let questions: [Question] = [
        Question(question: "What's your favorite workout?", answers: ["Yoga", "Mobility", "Strength", "Cardio"]),
        Question(question: "What are you focusing on?", answers: ["Legs", "Core/Abs", "Back", "Chest/Arms", "Neck/Shoulds", "Whole Body"]),
        Question(question: "What's your favorite fruit?", answers: ["Apple", "Banana", "Orange", "Mango"]),
        Question(question: "What's your favorite color?", answers: ["Red", "Blue", "Green", "Yellow"]),
    ]
    
    // A dictionary to keep track of the selected answers for each question
    @State private var selections: [String: Set<String>] = [:]
    
    var body: some View {
        List {
            ForEach(questions, id: \.question) { question in
                Section(header: Text(question.question)) {
                    ForEach(question.answers, id: \.self) { answer in
                        MultipleSelectionRow(
                            isSelected: selections[question.question]?.contains(answer) ?? false,
                            text: answer
                        ) {
                            toggleSelection(for: question.question, answer: answer)
                        }
                    }
                }
            }
        }
    }
    
    // Toggle the selection of an answer for a given question
    func toggleSelection(for question: String, answer: String) {
        if selections[question]?.contains(answer) == true {
            selections[question]?.remove(answer)
        } else {
            if selections[question] == nil {
                selections[question] = []
            }
            selections[question]?.insert(answer)
        }
    }
}

struct MultipleSelectionRow: View {
    var isSelected: Bool
    var text: String
    var action: () -> Void
    
    var body: some View {
        HStack {
            Text(text)
            Spacer()
            if isSelected {
                Image(systemName: "checkmark")
                    .foregroundColor(.blue)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture {
            action()
        }
    }
}

#Preview{
    MultipleChoiceView()
    
}

enter image description here

Upvotes: 1

lorem ipsum
lorem ipsum

Reputation: 29614

You are almost there, you just have to change the argument to a Binding so you can alter the selection from within the ForEach function

@ViewBuilder func surveyStepOptions<EnumType>(selected: Binding<[EnumType]>) -> some View where EnumType: CaseIterable & RawRepresentable<String> & Equatable{
    ForEach(Array(EnumType.allCases), id:\.rawValue) {option in
        
        Text(option.rawValue).font(.headline)
            .foregroundColor(.purple)
            .frame(height: 55)
            .frame(maxWidth: .infinity)
            .background(selected.wrappedValue.contains(option) ?
                        Color.blue : Color.white)
            .cornerRadius(10)
            .onTapGesture {
                if let index = selected.wrappedValue.firstIndex(of: option) {
                        selected.wrappedValue.remove(at: index)
                } else {
                    selected.wrappedValue.append(option)
                }
            }
    }
}

Notice I also removed the explicit type argument, it isn't needed in Swift.

Here is the full code.

import SwiftUI

struct ReusableEnumParentView: View {
    @State var selectedWorkoutTypes: [WorkoutType] = []
    @State var selectedBodyParts: [BodyPart] = []
    
    var body: some View {
        ScrollView{
            LazyVStack {
                workoutTypeSection
                bodyPartSection
            }
        }
    }
    
    @ViewBuilder var workoutTypeSection: some View {
        VStack(spacing: 20) {
            header("What Is Your Preferred Workout?")
            
            surveyStepOptions(selected: $selectedWorkoutTypes)
        }
        .padding(30)
    }
    
    @ViewBuilder var bodyPartSection: some View {
        VStack(spacing: 20) {
            header("What Is Your Preferred Workout?")
            
            surveyStepOptions(selected: $selectedBodyParts)
        }
        .padding(30)
    }
    
    @ViewBuilder func header(_ title: String) -> some View {
        Text(title)
    }
    @ViewBuilder func surveyStepOptions<EnumType>(selected: Binding<[EnumType]>) -> some View where EnumType: CaseIterable & RawRepresentable<String> & Equatable{
        ForEach(Array(EnumType.allCases), id:\.rawValue) {option in
            
            Text(option.rawValue).font(.headline)
                .foregroundColor(.purple)
                .frame(height: 55)
                .frame(maxWidth: .infinity)
                .background(selected.wrappedValue.contains(option) ?
                            Color.blue : Color.white)
                .cornerRadius(10)
                .onTapGesture {
                    if let index = selected.wrappedValue.firstIndex(of: option) {
                            selected.wrappedValue.remove(at: index)
                    } else {
                        selected.wrappedValue.append(option)
                    }
                }
        }
    }
}

#Preview {
    ReusableEnumParentView()
}

enum WorkoutType: String, CaseIterable, Codable, Identifiable {
    case yoga = "Yoga"
    case mobility = "Mobility"
    case strength = "Strength"
    case cardio = "Cardio"
    
    var id: WorkoutType { self }}

enum BodyPart: String, CaseIterable, Codable, Identifiable {
    case legs = "Legs"
    case core = "Core/Abs"
    case back = "Back"
    case chest = "Chest/Arms"
    case neck = "Neck/Shoulders"
    case body = "Whole Body"
    
    var id: BodyPart { self }
}

Upvotes: 2

Related Questions