Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I style a Swift Chart Axis to have ticks above bottom grid line and always have a start and end tick

Requirements

I'm using Swift Charts to create a line chart representing stock values over time, and I'm aiming for a design with inset ticks at every month. I need the start, end, and middle ticks to be larger and include a label below the tick.

enter image description here

Progress

I have been working on a quick POC in Swift Charts and have managed to make the axis look like this:

enter image description here

It's close to the design, but I have two outstanding issues:

1. Spacing Between the Bottom Axis Grid Line and Value Labels

I’ve set the vertical spacing to 0, but I notice there's about 16 points of spacing between the bottom axis grid line and the axis value label. Setting verticalSpacing to -16 gives me the result I want, but it doesn’t feel like the right solution. Where does this extra spacing come from, and how can I fix it?

2. First and Last X-Axis Ticks with Date Labels

I want to ensure that the first and last X-axis ticks are major ticks with the corresponding date labels. How can I make sure these ticks are marked as major and have labels?

Associated code:

import SwiftUI
import Charts

struct PlanValue: Codable, Identifiable {
    let value: Double
    let date: Date

    var id: Date { date }
}

// Date formatter to parse the date strings
let dateFormatter: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withFullDate]
    return formatter
}()

let fundValues: [PlanValue] = [
    PlanValue(value: 100.58, date: dateFormatter.date(from: "2023-01-30")!),
    PlanValue(value: 200.22, date: dateFormatter.date(from: "2023-01-31")!),
    PlanValue(value: 505.66, date: dateFormatter.date(from: "2023-02-28")!),
    PlanValue(value: 399.33, date: dateFormatter.date(from: "2023-03-31")!),
    PlanValue(value: 5652.37, date: dateFormatter.date(from: "2023-04-30")!),
    PlanValue(value: 5755.66, date: dateFormatter.date(from: "2023-05-31")!),
    PlanValue(value: 5850.81, date: dateFormatter.date(from: "2023-06-30")!),
    PlanValue(value: 6002.82, date: dateFormatter.date(from: "2023-07-31")!),
    PlanValue(value: 5750.62, date: dateFormatter.date(from: "2023-08-31")!),
    PlanValue(value: 5699.93, date: dateFormatter.date(from: "2023-09-30")!),
    PlanValue(value: 5820.01, date: dateFormatter.date(from: "2023-10-31")!),
    PlanValue(value: 6105.03, date: dateFormatter.date(from: "2023-11-30")!),
    PlanValue(value: 6291.02, date: dateFormatter.date(from: "2023-12-31")!),
    PlanValue(value: 6399.48, date: dateFormatter.date(from: "2024-01-31")!),
    PlanValue(value: 6544.99, date: dateFormatter.date(from: "2024-02-29")!),
    PlanValue(value: 6700.69, date: dateFormatter.date(from: "2024-03-31")!),
    PlanValue(value: 6850.57, date: dateFormatter.date(from: "2024-04-30")!),
    PlanValue(value: 6998.78, date: dateFormatter.date(from: "2024-05-31")!),
    PlanValue(value: 6400.39, date: dateFormatter.date(from: "2024-06-30")!),
    PlanValue(value: 6450.33, date: dateFormatter.date(from: "2024-07-31")!),
    PlanValue(value: 29555.39, date: dateFormatter.date(from: "2024-08-31")!),
    PlanValue(value: 30300.39, date: dateFormatter.date(from: "2024-09-30")!),
    PlanValue(value: 30836.33, date: dateFormatter.date(from: "2024-10-31")!),
    PlanValue(value: 30750.06, date: dateFormatter.date(from: "2024-11-30")!),
    PlanValue(value: 31011.97, date: dateFormatter.date(from: "2024-12-31")!),
    PlanValue(value: 32500.24, date: dateFormatter.date(from: "2025-01-29")!)
]

struct ContentView: View {
    var body: some View {
        Chart {
            // Fund Values
            ForEach(fundValues) { planValue in
                LineMark(x: .value("Date", planValue.date),
                         y: .value("Value", planValue.value)
                )
            }
        }
        .chartXAxis {
            let numberOfMajorTicks = 3
            // Major Ticks + Labels
            AxisMarks(preset: .aligned, values: .automatic(desiredCount: numberOfMajorTicks) ) { value in
                AxisValueLabel(format: .dateTime.month().year(.twoDigits), verticalSpacing: 0)
            }
            AxisMarks(preset: .inset, values:  .automatic(desiredCount: numberOfMajorTicks)) { value in
                AxisTick(length: 16, stroke: StrokeStyle(lineWidth: 1))
                    .foregroundStyle(Color.black)
            }

            // Minor Ticks
            AxisMarks(preset: .inset, values: .stride (by: .month)) { value in
                AxisTick(centered: true, length: 8, stroke: StrokeStyle(lineWidth: 1))
                    .foregroundStyle(Color.black)
            }
        }
        .chartYAxis {
            AxisMarks(preset: .aligned, values: .automatic(desiredCount: 7) ) { value in
                let isFirstLine = value.index == 0
                AxisGridLine(stroke: StrokeStyle(lineWidth: 1, dash: [isFirstLine ? 0 : 4], dashPhase: 0))
                    .foregroundStyle(isFirstLine ? .black : .gray)
                AxisValueLabel(format: .currency(code: "GBP").notation(.compactName))
            }
        }

        .frame(height: 275)
        .padding()
    }
}

#Preview {
    ContentView()
}
like image 995
bencallis Avatar asked Oct 27 '25 13:10

bencallis


1 Answers

So I fiddled around with your reproducible example, and figured out answers to both questions. Based on the resources I could find (documentation + WWDC videos), there's no straightforward answer to either question. With that in mind, here's what I did find:

1. Spacing Between the Bottom Axis Grid Line and Value Labels

This actually depends on the height of the ticks you use for the x-axis. Without adding any additional padding/offset, here's what the chart looks like:

  • when the maximum tick length is 16

chart when the maximum tick length is 16

  • when the maximum tick length is 1

chart when the maximum tick length is 1

My inference: when you create a tick, Swift automatically applies a padding equal to the tick size on both sides (which makes sense, because the .extended preset applies the tick in the opposite direction). Hence, using a negative value in the spacing (or offset, whichever you prefer) is the only way out.

2. First and Last X-Axis Ticks with Date Labels

There's no way to guarantee that the last data point will have a tick using the .automatic() value. So you'll have to manually check each index and apply the appropriate config (major vs minor tick, label vs no label).

I've added a simple computed variable that calculates the indices of major ticks for a given number of major ticks required. Only adding the relevant ContentView here, rest is same as your implementation.

struct ContentView: View {
    let numberOfMajorTicks = 3

    /// the indices where we should have a major tick
    var majorTickPositions: [Int] {
        let n = fundValues.count + numberOfMajorTicks - 2
        var positions = [0]
        /// k is the number of intervals we're getting, so numberOfMajorTicks should be > 1
        let k = numberOfMajorTicks - 1
        let bucketSize = n / k
        let overFlow = n % k
        for i in 0..<numberOfMajorTicks-1 {
            positions.append((positions.last ?? 0) + bucketSize - 1 + (overFlow > i ? 1: 0))
            print(i, positions)
        }
        return positions
    }

    var body: some View {
        Chart {
            // Fund Values
            ForEach(fundValues) { planValue in
                LineMark(x: .value("Date", planValue.date, unit: .month),
                         y: .value("Value", planValue.value)
                )
            }
        }
        .chartXAxis {
            /// Labels
            AxisMarks(preset: .aligned, values: .stride (by: .month)) { value in
                if majorTickPositions.contains(value.index) {
                    AxisValueLabel(format: .dateTime.month().year(.twoDigits), verticalSpacing: -8)
                }
            }

            AxisMarks(preset: .inset, values: .stride (by: .month)) { value in
                if majorTickPositions.contains(value.index) {
                    /// major ticks
                    AxisTick(length: 16, stroke: StrokeStyle(lineWidth: 1))
                        .foregroundStyle(Color.black)
                } else {
                    /// minor ticks
                    AxisTick(centered: true, length: 8, stroke: StrokeStyle(lineWidth: 1))
                        .foregroundStyle(Color.black)
                }
            }
        }
        .chartYAxis {
            AxisMarks(preset: .aligned, values: .automatic(desiredCount: 7) ) { value in
                let isFirstLine = value.index == 0
                AxisGridLine(stroke: StrokeStyle(lineWidth: 1, dash: [isFirstLine ? 0 : 4], dashPhase: 0))
                    .foregroundStyle(isFirstLine ? .black : .gray)
                AxisValueLabel(format: .currency(code: "GBP").notation(.compactName))
            }
        }
        .frame(height: 275)
        .padding(32)
    }
}
  • Output when numberOfMajorTicks = 3

Output when numberOfMajorTicks = 3

  • Output when numberOfMajorTicks = 4

Output when numberOfMajorTicks = 4

like image 120
Abhinav Mathur Avatar answered Oct 29 '25 05:10

Abhinav Mathur



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!