I am tearing my hair out converting my first App, that runs fine on iOS 16 and 17 (not using CoreData) to run on iOS17 with SwiftData.
It has a NavigationSplitView with the detail showing a 2 column list of skippers in a race. One column has the skipper numbers who did not finish yet, and the second has a list of those who finished with their score. Tapping an unscored skipper moves them to the scored column by changing their "raceLetterScore" property.
In the original code (snippet below) that works fine using animation and a ScrollViewReader to always show the bottom of the list of scored skippers where the latest finisher is placed.
The "SwiftData" version has 2 issues I have been unable to diagnose:
- The Animation around the setScore method call works once or twice and then creates an indefinite hang. No runtime error message. Instruments showed it happening, but I am not familiar enough with the tool to decipher the cause.
- The .onChage... proxy.scrollTo code executes, but the view seems to update immediately back to the default list showing the top section of the list.
I'll include the first section of the original code (the section that was updated for SwiftData) and the full current code and the @Models involved.
Any pointers as to why, or how to diagnose would be welcome - Thanks.
Original:
import SwiftUI
struct RaceScoringView: View {
@EnvironmentObject var regattaList : RegattaList
@StateObject var startTape = StartTape()
@State private var isShowingRaceScore: RaceScore? = nil
@State private var setRestDNC = false
@State var showingHelp : Bool = ShowScoringHelp
@State private var warnMove = false
let thisRace: Race
let thisHeat: Int
let columns = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
if thisRace.heatScoredSkippers[thisHeat].count == 0 {
StartTapeView()
}
Text("Race #\(thisRace.raceNum) \(regattaList.selectedRegatta.heatNames[thisHeat])").font(.title)
GeometryReader { geometry in
HStack {
VStack {
Text("Unscored").font(.title3).padding(5)
ScrollView {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(thisRace.heatUnscoredSkippers[thisHeat], id: \.id) { skip in
etc.
New Code for View:
import SwiftUI
import SwiftData
struct RaceScoringView: View {
@Environment(\.modelContext) private var modelContext
@StateObject var startTape = StartTape()
@State private var isShowingRaceScore: RaceScore? = nil
@State private var setRestDNC = false
@State var showingHelp : Bool = ShowScoringHelp
@State private var warnMove = false
let thisRace: Race
let thisHeat: Int
var unscoredSkips: [RaceScore] {
thisRace.raceScores!.filter({$0.heatNum == thisHeat && $0.raceLetterScore == .NotScored}).sorted(by: {$0.sailNum < $1.sailNum})
}
var scoredSkips: [RaceScore] {
thisRace.raceScores!.filter({ $0.heatNum == thisHeat && $0.raceLetterScore != .NotScored}).sorted(by: {$0.raceFinish < $1.raceFinish})
}
let columns = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
if scoredSkips.count == 0 {
StartTapeView()
}
Text("Race #\(thisRace.raceNum) \(selections.selectedRegatta.heatNames[thisHeat])").font(.title)
GeometryReader { geometry in
HStack {
VStack {
Text("Unscored").font(.title3).padding(5)
ScrollView {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(unscoredSkips) { skip in
Button {
withAnimation(.easeInOut(duration: 0.5)) {
setScore(skipScore: skip)
}
} label: {
Text(String(format: "%02d", skip.sailNum)).font(.headline)
}
.disabled(!skip.scoreEditOK)
.buttonStyle(.borderedProminent)
}
}
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.2)) {
setRestDNC = true
}
} label: {
Text("Score All remaining skippers").font(.headline)
}
.disabled(unscoredSkips.filter({$0.scoreEditOK}).count == 0)
.buttonStyle(.bordered)
Spacer()
if thisRace.isPromo {
Button {
commitFleetForPromo()
} label: {
Text("Commit Promotions and\n Enable Next Heat Scoring").font(.headline)
}
.disabled(unscoredSkips.count != 0 || thisRace.committedHeats[thisHeat] == true || thisHeat < 2 )
.buttonStyle(.bordered)
}
}
}
.frame(width: geometry.size.width * 0.4)
.background(thisRace.notScored != 0 ? .red.opacity(0.2) : .secondary.opacity(0.2) )
VStack {
Text("Scored Skippers").font(.title3).padding(5)
ScrollViewReader { proxy in
List {
ForEach(scoredSkips, id:\.id) { skip in
HStack {
HStack {
if skip.raceLetterScore == .👍 {
Image(systemName: "\(Int(skip.raceRawScore)).circle.fill").font(.title3).foregroundColor(skip.promoteColor)
} else {
VStack {
Image(systemName: "\(Int(skip.raceRawScore)).circle.fill")
Text("\(skip.raceLetterScore.rawValue)").font(.caption2)
}
}
Text(String(format: "%02d", skip.sailNum))
}
.onLongPressGesture {
isShowingRaceScore = skip
}
.disabled(skip.raceRawScore == 0 && !skip.scoreEditOK)
}.id(skip)
.listRowBackground(skip.scoreBG)
}
.onMove(perform: move)
}
// .scrollTargetLayout()
.id(UUID())
.scrollContentBackground(.hidden)
.scrollIndicators(.visible)
.background(.green.opacity(0.05))
.sheet(item: $isShowingRaceScore) { skip in
UpdateScoreView(thisRscore: skip)
}
.onChange(of: scoredSkips.count) {
// withAnimation {
proxy.scrollTo(scoredSkips.last)
// }
}
} //End of ScrollView
}
.frame(width: geometry.size.width * 0.55)
.background(.green.opacity(0.3))
}
}
.toolbar {
EditButton()
}
}
.floatingActionButton(color: .blue,
image: Image(systemName: "questionmark.circle")
.foregroundColor(.white)) {
showingHelp = true
}
.padding(10)
.alert("Please confirm you want to score all \(thisRace.heatUnscoredSkippers[thisHeat].count) Boats", isPresented: $setRestDNC) {
Button("Cancel", role: .cancel) { }
Button("DNC 'em") {setDNC()}
Button("DNS 'em") {setDNS()}
Button("DNF 'em") {setDNF()}
Button("WDN 'em") {setWDN()}
if selections.selectedRegatta.scoreType == .TwoOfThree {
Button("Mark BYE - zero score ") {setBYE()}
}
}
.alert("Scoring Help", isPresented: $showingHelp) {
Button("Don't show again") { ShowScoringHelp = false }
Link("Get more Help", destination: URL(string: "https://ezregatta.freshdesk.com/support/home")!)
Button("OK", role: .cancel) { }
} message: {
Text("**\(UIApplication.appName ?? "ezRegatta") Version: \(UIApplication.appVersion ?? "unknown")** \n\n")
+
Text("After adding and tapping a race (and fleet for multi-fleet regattas) you will see two columns - unscored and scored skippers/boats.")
+
Text("\nAs each boat finishes tap their sail number in the unscored column, and they will be moved to the next finish position in the scored column.\n If there are several boats remaining that all did not finish they can be moved as DNC/DNF/DNS/WDN with one tap on the button provided.")
+
Text("\nTo remove a boat from the scored list, assign a letter score (DNF, DSQ...), or apply redress, first score them normally and then __LONG PRESS__ their __sail number__ in the scored column to bring up a list of options.\n If the penalty scores are not correct, you will need to go back to the regatta tab and tap the Penalties button on the regatta detail page.")
+
Text("\nTo correct the finishing order, click Edit and DRAG a scored boat up or down the list using the 3 bars on the RIGHT of their entry.\nTo SCROLL the scored list DRAG the CENTER of the list. Note: avoid a long press on the center of the list as that enables re-ordering the finishes")
+
Text("\n\n For promotion (HMS) regattas the lowest fleet must be run and scored first. Once the scores have been checked - especially the promoted boats - TAP the button to commit them and enable scoring of the next higher fleet")
}
}
func setScore(skipScore: RaceScore) {
if skipScore.raceLetterScore == .👍 {return} //catch double click
skipScore.raceLetterScore = .👍
skipScore.raceFinish = thisRace.nextPlace
switch skipScore.toPromo == 0 {
case true:
skipScore.raceRawScore = Double(thisRace.nextPlace)
case false: // Promo
if skipScore.raceFinish <= skipScore.toPromo {
skipScore.raceRawScore = 0
} else {
var skipsInLaterHeats = 0
for heat in 1..<skipScore.heatNum {
skipsInLaterHeats += thisRace.raceHeatNums[heat]
}
skipScore.raceRawScore = Double(skipsInLaterHeats + thisRace.nextPlace - (thisRace.committedHeats[skipScore.heatNum] ? skipScore.toPromo : 0) )
}
}
thisRace.nextPlace += 1
}
//Plus a couple more func
}
Models
@Model final class Race: Identifiable, Hashable {
init(id: UUID = UUID(), regatta : Regatta , raceNum: Int = 1, isSeed: Bool = false, racePromo: Int = 0, nextPlace: Int = 1, raceScores: [RaceScore] = [], committedHeats: [Bool] = [Bool] (repeating: false, count: 9)) {
self.id = id
self.regatta = regatta
self.raceNum = raceNum
self.isSeed = isSeed
self.racePromo = racePromo
self.nextPlace = nextPlace
self.raceScores = raceScores
self.committedHeats = committedHeats
}
@Relationship(deleteRule: .cascade, inverse: \RaceScore.race) var raceScores: [RaceScore]?
var id = UUID()
var regatta : Regatta?
var raceNum: Int = 0
var isSeed: Bool = false
var racePromo: Int = 0
var isPromo: Bool {
racePromo > 0
}
var nextPlace: Int = 1
var unscoredSkippers: [RaceScore] {
let unSkip = raceScores?.filter {$0.raceLetterScore == .NotScored}
return unSkip?.sorted(by: {$0.sailNum < $1.sailNum} ) ?? []
}
var notScored: Int {
unscoredSkippers.count
}
var scoredSkippers: [RaceScore] {
return raceScores?.filter {$0.raceLetterScore != .NotScored}.sorted {$0.raceFinish < $1.raceFinish} ?? []
}
var areScored: Int {
scoredSkippers.count
}
var heatUnscoredSkippers: [[RaceScore]] {
var heatU = [[RaceScore]] (repeating: [], count: 9)
for heat in 0...8 {
heatU[heat] = raceScores?.filter {$0.raceLetterScore == .NotScored && $0.heatNum == heat}.sorted {$0.sailNum < $1.sailNum} ?? []
}
return heatU
}
var heatScoredSkippers: [[RaceScore]] {
var heatS = [[RaceScore]] (repeating: [], count: 9)
for heat in 0...8 {
heatS[heat] = raceScores?.filter {$0.raceLetterScore != .NotScored && $0.heatNum == heat}.sorted {$0.raceFinish < $1.raceFinish} ?? []
}
return heatS
}
var raceHeatNums: [Int] {
var rHN = [Int] (repeating: 0, count: 9)
for heat in 0...8 {
rHN[heat] = raceScores?.filter {$0.heatNum == heat && !($0.raceLetterScore == .👍 && $0.raceRawScore == 0 && committedHeats[heat])}.count ?? 0 //Exclude promoted skips if they have been added to next fleet (committed)
}
return rHN
}
var numberOfHeats: Int {
return raceHeatNums.filter { $0 > 0 }.count
}
var committedHeats = [Bool] (repeating: false, count: 9)
static func ==(lhs: Race, rhs: Race) -> Bool {
return lhs.raceNum == rhs.raceNum
}
func hash(into hasher: inout Hasher) {
hasher.combine(raceNum)
}
}
@Model final class RaceScore: ObservableObject, Hashable, Identifiable {
init(id: UUID = UUID(), race: Race?, /*skipID: UUID = UUID(), sailNum: Int = 0,*/ raceNum: Int = 0, toPromo: Int = 0, heatNum: Int = 0, raceRawScore: Double = 0.0, raceSetScore: Double = 0.0, raceFinish: Int = 0, heatFinish: Int = 0, raceLetterScore: RaceLetterScore = .NotScored, rsIsSeed: Bool = false, scoreEditOK: Bool = true, isThrownOut: Bool = false, manualDemote: Bool = false, manualPromote: Bool = false) {
self.id = id
self.race = race
// self.skipID = skipID
// self.sailNum = sailNum
self.raceNum = raceNum
self.toPromo = toPromo
self.heatNum = heatNum
self.raceRawScore = raceRawScore
self.raceSetScore = raceSetScore
self.raceFinish = raceFinish
self.heatFinish = heatFinish
self.raceLetterScore = raceLetterScore
self.rsIsSeed = rsIsSeed
self.scoreEditOK = scoreEditOK
self.isThrownOut = isThrownOut
self.manualDemote = manualDemote
self.manualPromote = manualPromote
}
var id = UUID()
var race : Race?
var skipper : Skipper?
// var skipID: UUID
var sailNum: Int {
skipper?.sailNum ?? 0
}
var raceID: UUID {
race?.id ?? UUID()
}
var raceNum: Int = 0
var toPromo: Int = 0
var heatNum: Int = 0
var raceRawScore: Double = 0.0
var raceSetScore: Double = 0.0
var raceFinish: Int = 0
var heatFinish: Int = 0
var raceLetterScore: RaceLetterScore
var rsIsSeed: Bool = false
var scoreEditOK: Bool = true
var canThrowOut: Bool {
if raceLetterScore == .DNE || raceLetterScore == .DGM {
return false
} else {
return true
}
}
var isThrownOut: Bool = false
var promoteColor: Color {
if raceRawScore == 0 || manualPromote {
return Color(.systemGreen)
} else if manualDemote {
return Color(.systemRed)
} else { return .primary }
}
var manualDemote = false
var manualPromote = false
var backgroundColor: Color {
switch raceLetterScore {
case .👍:
return .clear
case .NotScored:
return Color(.systemRed)
case .RET, .DNC, .DNF, .DNS, .OCS, .ZFP, .SCP, .NSC,.DPI:
return Color(.systemRed).opacity(0.2)
case .DNE, .DSQ, .BFD, .DGM, .WDN:
return Color(.systemRed).opacity(0.5)
case .RDG:
return Color(.systemGreen).opacity(0.3)
}
}
var scoreBG: Color {
if raceLetterScore == .👍 {
return Color(.systemGray6)
} else {
return backgroundColor
}
}
static func ==(lhs: RaceScore, rhs: RaceScore) -> Bool {
return lhs.raceNum == rhs.raceNum && lhs.sailNum == rhs.sailNum
}
func hash(into hasher: inout Hasher) {
hasher.combine(raceNum)
}
}