swiftswiftuisurvey

Points Doubling Issue in Simple SwiftUI Survey App


I'm building a survey app as a part of my thesis. A part of this application is a 'point system' that gives the user feedback on how the options they select impact factors in the design process with the goal of understanding the publics preferences.

When the user selects the option of their choice the appropriate points are added to the appropriate factors but when the user progresses to the next question the impact of the points is seemingly doubled. I think the points effect is being applied at the selection of the option but also when the user hits the next question button (I think).

I've pasted the parts of the code that I think are relevant here. Thank you for your time.

struct SingleChoiceInput: View {
    @Binding var selectedOptionIndex: Int?
    @Binding var costPoints: Int
    @Binding var sustainabilityPoints: Int
    @Binding var communityImpactPoints: Int
    var options: [AnswerOption]

    var body: some View {
        GeometryReader(content: { geometry in
            
       
        VStack(alignment: .leading) {
            ForEach(options.indices, id: \.self) { index in
                Button(action: {
                    if selectedOptionIndex != index {
                        // Undo the previous selection's impact
                        if let previousIndex = selectedOptionIndex {
                            let previousOption = options[previousIndex]
                            costPoints -= previousOption.costImpact
                            sustainabilityPoints -= previousOption.sustainabilityImpact
                            communityImpactPoints -= previousOption.communityImpact
                        }
                        
                        // Apply the new selection's impact
                        let selectedOption = options[index]
                        costPoints += selectedOption.costImpact
                        sustainabilityPoints += selectedOption.sustainabilityImpact
                        communityImpactPoints += selectedOption.communityImpact
                        
                        selectedOptionIndex = index
                    }
                }) {
                    HStack {
                        Text(options[index].text)
                            .font(.caption)
                            .foregroundColor(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                            
                        Spacer()
                        if selectedOptionIndex == index {
                            Image(systemName: "checkmark.circle.fill")
                                .foregroundStyle( .linearGradient(colors: [Color(red: 0.145098, green: 0.215686, blue: 0.270588),Color(red: 0.8, green: 0.815686, blue: 0.811765)], startPoint: .leading, endPoint: .trailing))
                                            
                        } else {
                            Image(systemName: "circle")
                                .foregroundColor(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                                .opacity(0.5)
                        }
                    }
                    .padding(.vertical, 10)
                    .padding(.horizontal)
                    .background(Color.white)
                    .cornerRadius(50)
                    .overlay(
                        RoundedRectangle(cornerRadius: 50)
                            .stroke(Color(red: 0.290196, green: 0.360784, blue: 0.415686), lineWidth: 1)
                            .opacity(0.5)
                        )
                }
                .buttonStyle(PlainButtonStyle())
            }
        }
        .padding(.horizontal)
        })
    }
}



struct MultipleChoiceInput: View {
    @Binding var selectedOptions: Set<Int>
    @Binding var costPoints: Int
    @Binding var sustainabilityPoints: Int
    @Binding var communityImpactPoints: Int
    var options: [AnswerOption]

    var body: some View {
        VStack(alignment: .leading) {
            ForEach(0..<options.count, id: \.self) { index in
                Button(action: {
                    if selectedOptions.contains(index) {
                        selectedOptions.remove(index)
                        let option = options[index]
                        costPoints -= option.costImpact
                        sustainabilityPoints -= option.sustainabilityImpact
                        communityImpactPoints -= option.communityImpact
                    } else {
                        selectedOptions.insert(index)
                        let option = options[index]
                        costPoints += option.costImpact
                        sustainabilityPoints += option.sustainabilityImpact
                        communityImpactPoints += option.communityImpact
                    }
                }) {
                    HStack {
                        Text(options[index].text)
                            .font(.caption)
                            .foregroundColor(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                        Spacer()
                        if selectedOptions.contains(index) {
                            Image(systemName: "checkmark.circle.fill")
                                .foregroundStyle( .linearGradient(colors: [Color(red: 0.145098, green: 0.215686, blue: 0.270588),Color(red: 0.8, green: 0.815686, blue: 0.811765)], startPoint: .leading, endPoint: .trailing))
                        } else {
                            Image(systemName: "circle")
                                .foregroundColor(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                                .opacity(0.5)
                        }
                    }
                    .padding(.vertical, 10)
                    .padding(.horizontal)
                    .background(Color.white)
                    .cornerRadius(50)
                    .overlay(
                        RoundedRectangle(cornerRadius: 50)
                            .stroke(Color(red: 0.290196, green: 0.360784, blue: 0.415686), lineWidth: 1)
                            .opacity(0.5)
                        )
                    
                }
                .buttonStyle(PlainButtonStyle())
            }
        }
        .padding(.horizontal)
    }
}



struct PointsSystemView: View {
    var costPoints: Int
    var sustainabilityPoints: Int
    var communityImpactPoints: Int
    var impactsPoints: Bool

    private let maxPoints = 5
    private let minPoints = -5

    var body: some View {
        GeometryReader { geometry in
            if impactsPoints {
                VStack(spacing: 20) {
                    VStack {
                        Text("Cost")
                            .font(.custom("SF Pro", size: geometry.size.height * 0.05))
                            .foregroundStyle(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                        BidirectionalProgressBar(value: normalizePoints(costPoints))
                            .frame(height: 20)
                            .padding(.horizontal)
                    }
                    VStack {
                        Text("Sustainability")
                            .font(.custom("SF Pro", size: geometry.size.height * 0.05))
                            .foregroundStyle(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                        BidirectionalProgressBar(value: normalizePoints(sustainabilityPoints))
                            .frame(height: 20)
                            .padding(.horizontal)
                    }
                    VStack {
                        Text("Community Impact")
                            .font(.custom("SF Pro", size: geometry.size.height * 0.05))
                            .foregroundStyle(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                        BidirectionalProgressBar(value: normalizePoints(communityImpactPoints))
                            .frame(height: 20)
                            .padding(.horizontal)
                    }
                }
                .frame(height: geometry.size.height * 0.8)
            } else {
                VStack {
                    Text("This question is for understanding user activities and does not impact the points system.")
                        .font(.custom("DM Serif Text Italic", size: geometry.size.width * 0.06))
                        .multilineTextAlignment(.center)
                        .foregroundStyle(Color(red: 0.8, green: 0.815686, blue: 0.811765))
                        .padding()
                }
                .frame(height: geometry.size.height * 0.8)
            }
        }
    }

    private func normalizePoints(_ points: Int) -> Double {
        return max(min(Double(points) / Double(maxPoints), 1), -1)
    }
}




struct QuestionPage: View {
    var animationNamespace: Namespace.ID
    @State private var currentQuestionIndex = 0
    @State private var selectedOptionIndex: Int? = nil
    @State private var selectedOptions: Set<Int> = []
    @State private var costPoints: Int = 0
    @State private var sustainabilityPoints: Int = 0
    @State private var communityImpactPoints: Int = 0
    @State private var showPointsSystem = false
    @State private var isPressed = false
    @State private var hasAppliedPoints: Bool = false

    @GestureState private var dragOffset = CGSize.zero

    var body: some View {
        GeometryReader { geometry in
            VStack {
                
                HStack {
                    Text("Question \(questions[currentQuestionIndex].id)")
                        .font(.custom("DM Serif Text Regular", size: geometry.size.width * 0.1))
                        .foregroundStyle(.linearGradient(colors: [Color(red: 0.145098, green: 0.215686, blue: 0.270588), Color(red: 0.8, green: 0.815686, blue: 0.811765)], startPoint: .leading, endPoint: .trailing))
                        
                    Spacer()
                    DonutProgress(progress: Double(currentQuestionIndex + 1) / Double(questions.count))
                        .frame(width: geometry.size.width * 0.08, height: geometry.size.width * 0.08)
                        .padding(.trailing, geometry.size.width * 0.05)
                }
                .padding()

               
                ZStack {
                    if showPointsSystem {
                        PointsSystemView(
                            costPoints: costPoints,
                            sustainabilityPoints: sustainabilityPoints,
                            communityImpactPoints: communityImpactPoints,
                            impactsPoints: questions[currentQuestionIndex].impactsPoints
                        )
                        .transition(.move(edge: .trailing))
                        .offset(x: dragOffset.width)
                        .gesture(
                            DragGesture()
                                .updating($dragOffset) { value, state, _ in
                                    state = value.translation
                                }
                                .onEnded { value in
                                    if value.translation.width > 50 {
                                        showPointsSystem = false
                                    }
                                }
                        )
                    } else {
                        VStack {
                            GeometryReader { geometry in
                                VStack {
                                    Text(questions[currentQuestionIndex].title)
                                        .font(.custom("DM Serif Text Italic", size: geometry.size.width * 0.08))
                                        .foregroundStyle(Color(red: 0.290196, green: 0.360784, blue: 0.415686))
                                        .multilineTextAlignment(.center)
                                        .padding()
                                        .frame(maxWidth: .infinity, alignment: .center)
                                }
                                .frame(height: geometry.size.height * 0.8)
                                .frame(maxWidth: .infinity)
                            }
                        }
                        .transition(.move(edge: .leading))
                        .offset(x: dragOffset.width)
                        .gesture(
                            DragGesture()
                                .updating($dragOffset) { value, state, _ in
                                    state = value.translation
                                }
                                .onEnded { value in
                                    if value.translation.width < -50 {
                                        showPointsSystem = true
                                    }
                                }
                        )
                    }
                }
                .frame(maxWidth: .infinity)
                .padding(.horizontal)

                
                ScrollView {
                    VStack {
                        if let options = questions[currentQuestionIndex].options {
                            if questions[currentQuestionIndex].inputType == .singleChoice {
                                SingleChoiceInput(
                                    selectedOptionIndex: $selectedOptionIndex,
                                    costPoints: $costPoints,
                                    sustainabilityPoints: $sustainabilityPoints,
                                    communityImpactPoints: $communityImpactPoints,
                                    options: options
                                )
                            } else if questions[currentQuestionIndex].inputType == .multipleChoice {
                                MultipleChoiceInput(
                                    selectedOptions: $selectedOptions,
                                    costPoints: $costPoints,
                                    sustainabilityPoints: $sustainabilityPoints,
                                    communityImpactPoints: $communityImpactPoints,
                                    options: options
                                )
                            }
                        } else {
                            TextField("Your answer...", text: .constant(""))
                                .padding()
                                .frame(height: 150)
                                .background(Color.white)
                                .cornerRadius(8)
                                .shadow(radius: 5)
                                .padding(.horizontal)
                        }
                    }
                    .padding(.top, geometry.size.height * 0.01)
                }

                
                VStack {
                    Button(action: {
                        isPressed = true
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                            isPressed = false
                            
                            
                            if questions[currentQuestionIndex].impactsPoints && !hasAppliedPoints {
                                applyPointsImpact()
                                hasAppliedPoints = true
                            }
                            
                            
                            selectedOptionIndex = nil
                            selectedOptions.removeAll()
                            
                            
                            if currentQuestionIndex < questions.count - 1 {
                                currentQuestionIndex += 1
                                hasAppliedPoints = false
                            } else {
                                
                            }
                            
                        
                            showPointsSystem = false
                        }
                    }) {
                        Image(isPressed ? "next_pressed" : "next_rest")
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .frame(width: geometry.size.height * 0.2, height: geometry.size.height * 0.12)
                            .padding(.bottom, geometry.size.height * 0.025)
                            .padding(.horizontal)
                    }
                    .buttonStyle(PlainButtonStyle())
                    .frame(maxWidth: .infinity, alignment: .trailing)
                }
                .frame(width: geometry.size.width, height: 60)
                .background(Color.white)
            }
            .animation(.default, value: showPointsSystem)
        }
    }

   
    private func applyPointsImpact() {
        let question = questions[currentQuestionIndex]
        
        if question.inputType == .singleChoice {
            guard let optionIndex = selectedOptionIndex else { return }
            if let option = question.options?[optionIndex] {
                costPoints += option.costImpact
                sustainabilityPoints += option.sustainabilityImpact
                communityImpactPoints += option.communityImpact
            }
        } else if question.inputType == .multipleChoice {
            for index in selectedOptions {
                if let option = question.options?[index] {
                    costPoints += option.costImpact
                    sustainabilityPoints += option.sustainabilityImpact
                    communityImpactPoints += option.communityImpact
                }
            }
        }
    }
}



Im not a software engineer so my knowledge of code is super limited. I've tried working with ChatGPT to help fix the issue but it's not been much help. I've thought of subtracting points impact of the previous question when the question progresses so that the doubling cancels out, but that feels super bootstrap.

again, thank you for any and all.


Solution

  • It might be possible to find the bug in your code, but trying to manage your state in your views is going to be difficult. Adding more state tracking will make that more complex not easier.

    Instead of directly manipulating your numbers in the view, make it a rule that your view only reads data from a model in order to display it in text or decide which buttons to show, and the view sends only single line commands to a model when buttons are pressed. Basically, don't write logic in views, nothing complex.

    I don't really understand your survey requirements, but here's an example of a view that would use a survey object:

    struct FormView: View {
    
        // 1: we have an observed model object
        @ObservedObject
        var survey: Survey
        
        var body: some View {
            Form {
                ForEach(survey.options) { item in
                    Section {
                        HStack {
                            Text(item.text)
    
                            // 2. Button actions are simple, single liners
                            Button {
                                survey.toggleChosen(item)
                            }
                            label: {
                                Text(survey.isChosen(item) ? "Remove" : "Add")
                            }
                        }
                    }
                }
                
                Section {
                    Text("cost: \(survey.totalCost)")
                    Text("impact: \(survey.totalCommunityImpact)")
    
                }
            }.padding()
        }
    }
    

    Now this is much simpler, the calculation will be done in a model. Let's make a model for a particular option, and make it conform to Identifiable so we can prefer not to use Indices, which also simplifies things.

    struct AnswerOption: Identifiable {
        var id: String { text }
        
        var text: String
        var points: Points
    }
    
    struct Points {
        var cost: Int
        var sustainability: Int
        var communityImpact: Int
    }
    

    And then we can use these in the overall model class which would be something like this example:

    @MainActor
    final class Survey: ObservableObject {
        
        @Published
        var options: [AnswerOption]
        
        @Published
        var chosenIds: Set<String> = []
        
        func isChosen(_ option: AnswerOption) -> Bool {
            chosenIds.contains(option.id)
        }
        init(options: [AnswerOption]) {
            self.options = options
        }
        
        var chosenOptions: [AnswerOption] {
            options.filter { chosenIds.contains($0.id)}
        }
        
        func toggleChosen(_ option: AnswerOption) {
            if chosenIds.contains(option.id) {
                chosenIds.remove(option.id)
            }
            else {
                chosenIds.insert(option.id)
            }
        }
        
    

    Here we always just recompute the total cost from the selection, which means managing state is reduced to just managing what is and what is not selected, and there's no way to get the total number out of sync with the selection, we have 'computed variable's for the totals:

        var totalCommunityImpact: Int {
            var result = 0
            for option in chosenOptions {
                result += option.points.communityImpact
            }
            return result
        }
    
        var totalCost: Int { ... similar to above etc .... }
        
    }
    
    
    
    #Preview {
        FormView(survey: Survey(options: [
            AnswerOption(text: "Example", points: Points(cost: 42, sustainability: 99, communityImpact: 23)),
            AnswerOption(text: "Example2", points: Points(cost: 22, sustainability: 31, communityImpact: 90))
        ]))
    }