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.
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))
]))
}