In the following code, I am trying to build an exercise plan builder. Some aspects are still missing, but I've gotten to an unfortunate point where my state no longer works, likely down at the ExerciseSetEntryView. When I preview this, only my first action on the preview takes place. Subsequent actions, such as switching between Weight and Duration, do not work.
Models are Objective-C to interface with Core Data.
import SwiftUI
import Foundation
struct ExercisePlanDayView: View {
@State var isEditing: Bool
@Binding var selectedDay: Int
@State var exerciseEntries: [ExerciseEntry] = []
@State var exerciseSetEntries: [ExerciseSetEntry] = []
let weekDays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
var body: some View {
VStack {
HStack {
Text(weekDays[selectedDay])
Spacer()
Button(action: {
withAnimation {
isEditing.toggle()
}
//TODO: save stuff
}, label: {
Text(isEditing ? "Save" : "Edit")
})
.buttonStyle(.plain)
}
ForEach($exerciseEntries) { $exerciseEntry in
ExerciseEntryView(exerciseEntry: $exerciseEntry, exerciseSetEntries: $exerciseSetEntries, selectedDay: $selectedDay, isEditing: $isEditing)
}
Button(action: {
withAnimation {
//TODO: add a new exerciseEntry
}
}, label: {
Text("Add Exercise")
})
}
.padding(.horizontal, 5)
}
}
struct ExerciseEntryView: View {
@Binding var exerciseEntry: ExerciseEntry
@Binding var exerciseSetEntries: [ExerciseSetEntry]
@Binding var selectedDay: Int
@Binding var isEditing: Bool
var body: some View {
VStack (spacing: 5) {
HStack (spacing: 5) {
Text(exerciseEntry.exerciseName)
.padding(.horizontal, 10)
.fontWeight(.semibold)
.frame(width: 300, alignment: .leading)
.foregroundStyle(.base)
.background {
Rectangle()
.foregroundStyle(.greenEnd)
.cornerRadius(12)
.frame(height: 30)
}
if (isEditing) {
Button(action: {
withAnimation {
// TODO: remove the sets for this exercise
// TODO: remove Exercise
}
}, label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.white, .red)
.frame(height: 30)
})
.buttonStyle(.plain)
}
Spacer()
}
ForEach($exerciseSetEntries.filter({ $0.exerciseID.wrappedValue == exerciseEntry.id.uuidString })) { $exerciseSetEntry in
ExerciseSetEntryView(exerciseSetEntry: $exerciseSetEntry, isEditing: $isEditing)
}
}
}
}
struct ExerciseSetEntryView: View {
@AppStorage("useMetric") var useMetric: Bool = false
@Binding var exerciseSetEntry: ExerciseSetEntry
@Binding var isEditing: Bool
var body: some View{
VStack {
if (isEditing) {
HStack (alignment: .top, spacing: 0) {
Button(action: {
}, label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.white, .red)
})
.frame(height: 20)
HStack (alignment: .top, spacing: 0) {
VStack {
Picker("Select Activity Type", selection: $exerciseSetEntry.activityType) {
Text("Weight").tag("weight")
Text("Duration").tag("duration")
}
.pickerStyle(.segmented)
.frame(width: 140)
.padding(.leading, 5)
}
VStack {
if exerciseSetEntry.activityType == "weight" {
// Stepper for repetitions
Stepper(value: $exerciseSetEntry.repetitions, in: 1...500, step: 10) {
Text("\(exerciseSetEntry.repetitions) reps")
.frame(width: 90)
}
// Stepper for weight lifted
Stepper(value: $exerciseSetEntry.weightLifted, in: 1...500, step: 10) {
Text("\(exerciseSetEntry.weightLifted) \(useMetric ? "kg" : "lbs")")
.frame(width: 90)
}
} else if exerciseSetEntry.activityType == "duration" {
// Stepper for duration (in minutes)
Stepper(value: $exerciseSetEntry.duration, in: 1...180, step: 1) {
Text("\(exerciseSetEntry.duration / 60) min")
.frame(width: 90)
}
}
}
.frame(width: 180)
}
.background {
Rectangle()
.foregroundStyle(.clear)
.cornerRadius(12)
.frame(height: 30)
}
}
} else {
HStack (spacing: 0) {
if (isEditing) {
HStack {
if (exerciseSetEntry.repetitions != 0) {
Image(systemName: "repeat")
Text("\(exerciseSetEntry.repetitions) reps")
}
if (exerciseSetEntry.weightLifted != 0) {
Image(systemName: "scalemass.fill")
Text("\(exerciseSetEntry.repetitions) \(useMetric ? "kg" : "lbs")")
}
if (exerciseSetEntry.duration != 0) {
Text("\(exerciseSetEntry.duration / 60) minutes")
}
}
.padding(.horizontal, 10)
.frame(height: 30, alignment: .leading)
.background {
Rectangle()
.foregroundStyle(.orange)
.cornerRadius(12)
.frame(height: 30)
}
}
Button(action: {
}, label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.white, .red)
})
.frame(height: 20)
.padding(.leading, 5)
Spacer()
}
}
}
}
}
struct ExercisePlanDayView_Previews: PreviewProvider {
static var previews: some View {
let uuid1 = UUID()
let uuid2 = UUID()
let uuid3 = UUID()
let exerciseEntries: [ExerciseEntry] = [
ExerciseEntry(id: uuid1, day: 1, exerciseOrder: 1, exerciseName: "Push-ups"),
ExerciseEntry(id: uuid2, day: 1, exerciseOrder: 2, exerciseName: "Sit-ups"),
ExerciseEntry(id: uuid3, day: 1, exerciseOrder: 3, exerciseName: "Squats"),
]
let exerciseSetEntries: [ExerciseSetEntry] = [
ExerciseSetEntry(id: UUID(), exerciseID: uuid1.uuidString, activityType: "weight", setOrder: 1, repetitions: 10, duration: 0, weightLifted: 210),
ExerciseSetEntry(id: UUID(), exerciseID: uuid1.uuidString, activityType: "weight", setOrder: 2, repetitions: 15, duration: 0, weightLifted: 150),
ExerciseSetEntry(id: UUID(), exerciseID: uuid2.uuidString, activityType: "duration", setOrder: 1, repetitions: 0, duration: 360, weightLifted: 0),
ExerciseSetEntry(id: UUID(), exerciseID: uuid3.uuidString, activityType: "weight", setOrder: 1, repetitions: 10, duration: 0, weightLifted: 210),
]
return ExercisePlanDayView(exerciseViewModel: ExerciseViewModel(), isEditing: true, selectedDay: .constant(1), exerciseEntries: exerciseEntries, exerciseSetEntries: exerciseSetEntries)
}
}
import Foundation
import CoreData
@objc(ExerciseEntry)
public class ExerciseEntry: NSManagedObject, Identifiable {
@NSManaged public var id: UUID
@NSManaged public var day: Int16
@NSManaged public var exerciseOrder: Int16
@NSManaged public var exerciseName: String
convenience init(
id: UUID,
day: Int16,
exerciseOrder: Int16,
exerciseName: String
) {
let entityName = "ExerciseEntry" // Set the entity name here
guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: PersistenceController.shared.container.viewContext) else {
fatalError("Failed to initialize ExerciseEntry entity")
}
self.init(entity: entity, insertInto: PersistenceController.shared.container.viewContext)
self.id = id
self.day = day
self.exerciseOrder = exerciseOrder
self.exerciseName = exerciseName
}
}
@objc(ExerciseSetEntry)
public class ExerciseSetEntry: NSManagedObject, Identifiable {
@NSManaged public var id: UUID
@NSManaged public var exerciseID: String
@NSManaged public var activityType: String
@NSManaged public var setOrder: Int16
@NSManaged public var repetitions: Int16
@NSManaged public var duration: Int16
@NSManaged public var weightLifted: Int16
convenience init(
id: UUID,
exerciseID: String,
activityType: String,
setOrder: Int16,
repetitions: Int16,
duration: Int16,
weightLifted: Int16
) {
let entity = PersistenceController.shared.getExerciseSetEntry()
self.init(entity: entity, insertInto: nil)
self.id = id
self.exerciseID = exerciseID
self.activityType = activityType
self.setOrder = setOrder
self.repetitions = repetitions
self.duration = duration
self.weightLifted = weightLifted
}
}
I have attempted to bounce questions off of ChatGPT, done my own research into ForEach, studied statefulness, etc. I have tried some sample views but none really seemed to resolve my overarching issue.
For CoreData in SwiftUI don't use @State
, instead use @FetchRequest
and @ObservedObject
, e.g.
struct ExercisePlanDayView: View {
@FetchRequest(
sortDescriptors: [SortDescriptor(\.timestamp)],
animation: .default)
private var exerciseEntries: FetchedResults<ExerciseEntry>
var body: some View {
...
List(exerciseEntries) { exerciseEntry in
ExerciseEntryView(exerciseEntry: exerciseEntry)
struct ExerciseEntryView: View {
@ObservedObject var exerciseEntry: ExerciseEntry
var body: some View {
...
ExerciseSetEntryView(entry: exerciseEntry)
When you want to fetch related entities you need to use another @FetchRequest
for that entity, e.g.
struct ExerciseSetEntryView: View {
let entry: ExerciseEntry
@FetchRequest(
sortDescriptors: [SortDescriptor(\.timestamp)],
predicate: NSPredicate(value: false),
animation: .default)
private var sets: FetchedResults<ExerciseSetEntry>
var body: some View {
let _ = sets.nsPredicate = NSPredicate(format: "entry = %@", entry)
List(sets) { set in
The time you might use @State
is if you want to hang on to a temporary managed object to use to bind to the UI before deciding if to save or discard it (a child context makes discarding simpler).