TabView and Swift Charts giving inconsistent behaviour when swiping between pages

Hi there,

I have a TabView in page style. Inside that TabView I have a number of views, each view is populated with a model object from an array. The array is iterated to provide the chart data.

Here is the code:

            TabView(selection: $displayedChartIndex) {
                ForEach((0..<data.count), id: \.self) { index in
                    ZStack {
                        AccuracyLineView(graphData: tabSelectorModel.lineChartModels[index])
                            .padding(5)
                    }
                    .tag((index))
                }
            }
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))

I am seeing odd behaviour, as I swipe left and right, occasionally the chart area shows the chart from another page in the TabView. I know the correct view is being shown as there are text elements.

See the screenshot below. The screen on the right is running iOS 17.2 and this works correctly. The screen on the left is running iOS 17.4 and the date at the top is correct which tells me that the data object is correct. However the graph is showing a chart from a different page. When I click on the chart on the left (I have interaction enabled) then it immediately draws the correct chart. If I disable the interaction then I still get the behaviour albeit the chart never corrects itself because there is no interaction!

I can reproduce this in the 17.4 simulator and it is happening in my live app on iOS17.4. This has only started happening since iOS 17.4 dropped and works perfectly in iOS 17.2 simulator and I didn't notice it in the live app when I was running 17.3.

Is this a bug and/or is there a workaround?

For info this is the chart view code, it is not doing anything clever:

struct AccuracyLineView: View {
    
    @State private var selectedIndex: Int?
    let graphData: LineChartModel
    
    
    func calcHourMarkers (maxTime: Int) -> [Int] {
        let secondsInDay = 86400 // 60 * 60 * 24
        var marks: [Int] = []
        var counter = 0
        while counter <= maxTime {
            if (counter > 0) {
                marks.append(counter)
            }
            counter += secondsInDay
        }
        return marks
    }
    
    var selectedGraphMark: GraphMark? {

        var returnMark: GraphMark? = nil
        
        var prevPoint = graphData.points.first

        for point in graphData.points {
            if let prevPoint {
                if let selectedIndex, let lastPoint = graphData.points.last, ((point.interval +  prevPoint.interval) / 2 > selectedIndex || point == lastPoint) {
                    if point == graphData.points.last {
                        if selectedIndex > (point.interval +  prevPoint.interval) / 2 {
                            returnMark = point
                        } else {
                            returnMark = prevPoint
                        }
                    } else {
                        returnMark = prevPoint
                        break
                    }
                }
            }
            prevPoint = point
        }
        return returnMark
    }

    
    var body: some View {
        
        let lineColour:Color = Color(AppTheme.globalAccentColour)
        
        VStack {
            HStack {
              Image(systemName: "clock")
                Text(graphData.getStartDate() + " - " + graphData.getEndDate())  // 19-29 Sept
                .font(.caption)
                .fontWeight(.light)
              Spacer()
            }
            Spacer()
            Chart {
               
                // Lines
                ForEach(graphData.points) { item in
                    LineMark(
                        x: .value("Interval", item.interval),
                        y: .value("Offset", item.timeOffset),
                        series: .value("A", "A")
                    )
                    .interpolationMethod(.catmullRom)
                    .foregroundStyle(lineColour)
                    .symbol {
                        Circle()
                            .stroke(Color(Color(UIColor.secondarySystemGroupedBackground)), lineWidth: 4)
                            .fill(AppTheme.globalAccentColour)
                            .frame(width: 10)
                    }
                }
                
                ForEach(graphData.trend) { item in
                    
                     LineMark (
                        x: .value("Interval", item.interval),
                        y: .value("Offset", item.timeOffset)
                     )
                     .foregroundStyle(Color(UIColor.systemGray2))
                }
              
                if let selectedGraphMark {
                    RuleMark(x: .value("Offset", selectedGraphMark.interval))
                        .foregroundStyle(Color(UIColor.systemGray4))
                }
            }
            .chartXSelection(value: $selectedIndex)
            .chartXScale(domain: [0, graphData.getMaxTime()])
        }
    }
}

Accepted Reply

Maybe, it's not a bug and I found the solution: Use .id() with Charts, e.g. Chart {...}.id(someHashableObject)

Pseudocode example:

ForEach (identifiableObjects) { identifiableObject in 
       Chart {
            // some Marks...
       }
       .id(UUID())
}

Seems to work in my case.

  • 🙏 This worked for me, Thank You! (I didn't need interactivity so can't comment on that)

Add a Comment

Replies

Ok. I have paired it all the way back to a single page of code.

You can see the text at the top of the graph is the Y coords and it doesn't match the chart. If I had included interactivity then tapping on the graph would make it show the correct chart.

Code:

import SwiftUI
import Charts

struct Point: Identifiable {
    var x: Int
    var y: Int
    let id = UUID()
}

struct PointData: Identifiable {
    var points: [Point]
    let id = UUID()
}

struct TabSelectorView: View {
       
    var pointDataArray: [PointData] = []
    
    init() {
        self.pointDataArray = initData()
    }
      
    var body: some View {
        let data = pointDataArray
        VStack {
            TabView  {
                ForEach((0..<data.count), id: \.self) { index in
                    VStack {
                        let graphData = pointDataArray[index]
                        Text (graphData.points.map { String($0.y) }.joined(separator: ","))
                        Chart(graphData.points) { item in
                            LineMark(
                                x: .value("X", item.x),
                                y: .value("Y", item.y)
                            )
                            .symbol {
                                Circle()
                                    .fill(Color.orange)
                                    .frame(width: 10)
                            }
                        }
                    }
                    .tag((index))
                }
            }
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))
        }
    }
    
    func initData() -> [PointData] {
        var ret: [PointData] = []
        for chartNum in 0...6 {
            var pointArray: [Point] = []
            for point in 0...5 {
                pointArray.append(Point(x: point, y: point + chartNum))
            }
            ret.append(PointData(points: pointArray))
        }
        return ret
    }
}

#Preview {
    return TabSelectorView()
        .frame(height: 400)
        .padding(50)
}

Am I doing something obviously wrong?!

Note that some tab pages are correct and others are incorrect, and it changes as you swipe through.

This time with the correct screenshot!

This seems to be a serious bug of SwiftUIs Charts API. In case there are several Chart-Views generated inside a ForEach-Loop the Chart-Views sometimes do not match the data of the relevant loop-run anymore. This bug appears especially as soon as the view was updated by the OS (e.g. switching tabs, scrolling, ...), not so often when the view was generated initially.

This bug appeared with one of the latest OS Updates (iOS, iPadOS, macOS). I can't see any misuse here. I also couldn't find a workaround. I recommend reporting this to Apple.

Maybe, it's not a bug and I found the solution: Use .id() with Charts, e.g. Chart {...}.id(someHashableObject)

Pseudocode example:

ForEach (identifiableObjects) { identifiableObject in 
       Chart {
            // some Marks...
       }
       .id(UUID())
}

Seems to work in my case.

  • 🙏 This worked for me, Thank You! (I didn't need interactivity so can't comment on that)

Add a Comment

For some reason I didn't get notified of these posts. Apple's feedback was to raise a bug which I did. Thank you so much for the workaround / correction. I have tested it quickly in the test app and it works for me also. It will take me some time to look at my live app as I am travelling. Thanks again. :-)

Ok.....so I made the change to my live app to add the ID to the chart with a UUID. Good news is that the swiping bug is now cleared. Bad news is now interactivity is broken!

Without the .id(UUID()) modifier to the chart then when I interact with the chart it behaves normally insofar as when I tap the chart then up comes the annotation view that I have coded . When I scroll with my finger then the annotation updates to the closest point in the chart. When I remove my finger then the annotation disappears.

However if I add the .id(UUID()) modifier to the chart then interactions go awry. I cannot keep my finger down and scroll through the points and, worse, the annotation does not remove itself after I have lifted my finger. Therefore there doesn't seem to be a way to remove the annotation.

I have updated my MVP code to illustrate. If you keep the '.id' line in then the swiping works but the interaction doesn't work. If you comment out the '.id' line then the original swiping problem comes back but the interaction works as expected.

I have updated this in the feedback item I raised.

Updated code:

import SwiftUI
import Charts

struct Point: Identifiable {
    var x: Int
    var y: Int
    let id = UUID()
}

struct PointData: Identifiable {
    var points: [Point]
    let id = UUID()
}

struct TabSelectorView: View {
       
    @State private var selectedIndex: Int?     // NEW
    var pointDataArray: [PointData] = []
    
    init() {
        self.pointDataArray = initData()
    }
      
    var body: some View {
        VStack {
            TabView  {
                ForEach((0..<pointDataArray.count), id: \.self) { index in
                    VStack {
                        let graphData = pointDataArray[index]
                        Text ("Selected index: \(selectedIndex == nil ? "None" : String(selectedIndex!) )")        // NEW
                        Text (graphData.points.map { String($0.y) }.joined(separator: ","))
                        Chart(graphData.points) { item in
                            LineMark(
                                x: .value("X", item.x),
                                y: .value("Y", item.y)
                            )
                            .symbol {
                                Circle()
                                    .fill(Color.orange)
                                    .frame(width: 10)
                            }
                        }
                        .id(UUID())       // NEW. Comment out to fix interaction but break swiping
                        .chartXSelection(value: $selectedIndex)          // NEW
                    }
                    .tag((index))
                }
            }
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))
        }
    }
    
    func initData() -> [PointData] {
        var ret: [PointData] = []
        for chartNum in 0...6 {
            var pointArray: [Point] = []
            for point in 0...5 {
                pointArray.append(Point(x: point, y: point + chartNum))
            }
            ret.append(PointData(points: pointArray))
        }
        return ret
    }
}

#Preview {
    return TabSelectorView()
        .frame(height: 400)
        .padding(50)
}