Search code examples
swiftuiswiftui-charts

SwiftUI Charts – X axis data points are not spaced equally by default


Trying to implement a chart into my app where it tracks your profit of a game over time. The idea is very simple, a line chart that shows your progress over the course of a year. I'm using dummy data for now while I style the chart, with dates in Jan, Feb, March, May, and December but for some reason the chart is skewing oddly because the data points are not spaced evenly across the X axis.
Does anyone know how I can fix this?
Code with dummy data below, as well as the image:

let dummyData: [dummySession] = [
    .init(date: Date.from(year: 2023, month: 1, day: 1), profit: 0),
    .init(date: Date.from(year: 2023, month: 2, day: 12), profit: 55),
    .init(date: Date.from(year: 2023, month: 3, day: 20), profit: 376),
    .init(date: Date.from(year: 2023, month: 5, day: 2), profit: 120),
    .init(date: Date.from(year: 2023, month: 5, day: 18), profit: 500),
    .init(date: Date.from(year: 2023, month: 12, day: 11), profit: 222)
]

VStack {
    Chart {
        ForEach(dummyData) { session in
            LineMark(x: .value("Date", session.date, unit: .month),
                     y: .value("Profit", session.profit))
            .lineStyle(.init(lineWidth: 3, lineCap: .round, lineJoin: .round))
        }
        .interpolationMethod(.cardinal)
    }
    .chartXAxis {
        AxisMarks(stroke: StrokeStyle(lineWidth: 0))
    }
    .chartYAxis {
        AxisMarks(position: .trailing, values: .automatic) { value in
            AxisGridLine()
            AxisValueLabel() {
                if let intValue = value.as(Int.self) {
                    Text(intValue.asCurrency())
                        .padding(.leading, 25)
                }
            }
        }
    }
}
.frame(maxHeight: 300)

chart result


Solution

  • Instead of using Date, you should create your own Plottable type that acts like a String (i.e. primitivePlottable returns a String). SwiftData treats strings as categorical data, and plots them with equal spacing.

    struct YearMonthDay: Plottable, Hashable {
        init(year: Int, month: Int, day: Int) {
            self.year = year
            self.month = month
            self.day = day
        }
        
        init?(primitivePlottable: String) {
            guard let date = try? Date.ISO8601FormatStyle.iso8601.parse(primitivePlottable) else {
                return nil
            }
            let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
            guard let day = components.day, let month = components.month, let year = components.year else {
                return nil
            }
            self.day = day
            self.month = month
            self.year = year
        }
        
        let year: Int
        let month: Int
        let day: Int
        
        var primitivePlottable: String {
            date.formatted(.iso8601)
        }
        
        // this will be useful later
        var date: Date {
            Calendar.current.date(from: DateComponents(year: year, month: month, day: day))!
        }
    }
    

    The DummySession struct would then be declared as:

    struct Session: Identifiable {
        let date: YearMonthDay
        let profit: Int
        
        var id: YearMonthDay {
            date
        }
    }
    

    When creating the chart, you should format the X axis labels in your desired format:

    Chart {
        ForEach(data) { session in
            LineMark(x: .value("Date", session.date),
                     y: .value("Profit", session.profit))
            .lineStyle(.init(lineWidth: 3, lineCap: .round, lineJoin: .round))
        }
        .interpolationMethod(.cardinal)
    }
    .chartXAxis {
        AxisMarks { value in
            if let yearMonthDay = value.as(YearMonthDay.self) {
                AxisValueLabel {
                    Text(yearMonthDay.date, format: .dateTime.month().day())
                }
            }
        }
    }
    .chartYAxis {
        AxisMarks(position: .trailing, values: .automatic) { value in
            AxisGridLine()
            AxisValueLabel {
                if let intValue = value.as(Int.self) {
                    Text(intValue, format: .currency(code: "USD"))
                        .padding(.leading, 25)
                }
            }
        }
    }
    

    Output:

    enter image description here