UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Scrolling and Animation Issues in iOS17/SwiftData

Forums > SwiftUI

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:

  1. 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.
  2. 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)
    }

}

1      

I was able to make scrolling work by removing the .id(UUID()) on the view. Animation still crashes with no error messages.

   

BUILD THE ULTIMATE PORTFOLIO APP Most Swift tutorials help you solve one specific problem, but in my Ultimate Portfolio App series I show you how to get all the best practices into a single app: architecture, testing, performance, accessibility, localization, project organization, and so much more, all while building a SwiftUI app that works on iOS, macOS and watchOS.

Get it on Hacking with Swift+

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topicโ€ฆ

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.