swiftgenericsswiftuicode-reuse

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?


Solution

  • 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 }
    }