Search code examples
swiftuiswiftui-charts

How to fully reproduce default styling of Swift Charts with override options


I'm curious, is there a way to fully reproduce the styling choices that Swift Charts makes? If we don't specify symbol and style for our data points, Swift Charts assigns some predictable internal choices for each series in our data. If we want to override the choices it is making internally, first we need to reproduce the result.

AFAIK, Apple has not declared the algorithm for the styling choices it makes. But observing the behaviour of Charts, I infer that it is cycling through a set of 5 symbols and 7 foregroundStyles. This is the current behaviour (as of iOS18.3), and could be changed at Apple's discretion.

Here is some code showing my best attempt at reproducing this styling, using the symbol(_:) and foregroundStyle(_:) modifiers:

private let symbols = ["circle", "square", "triangle", "diamond", "pentagon"]
private let styles: [Color] = [ .blue, .green, .orange, .purple, .red, .cyan, .yellow ]
// Swift Charts just cycles through the above choices, resulting in 35 (7x5) unique combinations

private struct StandardChart: View {
    func chartPoint(x: Int, y: Double, series: Int) -> some ChartContent {
        LineMark(
            x: .value("Day", x),
            y: .value("Total", y),
            series: .value("Series", "Series \(series + 1)")
        )
        .symbol(by: .value("Series", "Series \(series + 1)"))
        .foregroundStyle(by: .value("Series", "Series \(series + 1)"))
    }

    var body: some View {
        Chart {
            ForEach(0..<8, id: \.self) { index in
                chartPoint(x: 0, y: 0, series: index)
                chartPoint(x: 1, y: 0.75 * Double(index), series: index)
                chartPoint(x: 2, y: Double(index), series: index)
            }
        }
    }
}

private struct ReproducedChart: View {
    func chartPoint(x: Int, y: Double, series: Int) -> some ChartContent {
        LineMark(
            x: .value("Day", x),
            y: .value("Total", y),
            series: .value("Series", "Series \(series + 1)")
        )
        .symbol {
            Image(systemName: symbols[series % 5])
                .font(.system(size: 7))
                .fontWeight(.black)
        }
        .foregroundStyle(styles[series % 7])
    }

    var body: some View {
        VStack {
            Chart {
                ForEach(0..<8, id: \.self) { index in
                    chartPoint(x: 0, y: 0, series: index)
                    chartPoint(x: 1, y: 0.75 * Double(index), series: index)
                    chartPoint(x: 2, y: Double(index), series: index)
                }
            }
        }
    }
}

// Best viewed in landscape mode
struct ContentView: View {
    var body: some View {
        HStack {
            VStack {
                Text("Standard")
                StandardChart()
            }
            .padding()

            VStack {
                Text("Reproduced")
                ReproducedChart()
            }
            .padding()
        }
    }
}

Resulting in:

Side by side comparison of differing charts

The code in my ReproducedChart comes close, but doesn't quite get the same look as the default. Here are the differences that I have noticed in the reproduced version of the chart:

  • The symbols have a thinner stroke.
  • The plot lines encroach on the insides of the symbols.
  • The symbols are not picking up the colouring.
  • The legend is not appearing for the chart

Any ideas on how to fix these problems?


Solution

  • Well, I had the above question ready to publish, but following my own investigations, I found the solutions. So I'm answering this Q&A style, so others who start off on the wrong path, like I did, can benefit from my findings.

    The first thing to note is that even though Swift Charts allows you to provide Image(systemName:) to specify SFSymbols to use in place of the default point markers, and there are SFSymbols that seem to correspond exactly to the default point markers, Swift Charts is not actually using SFSymbols for default styling, under the hood. It's instead using BasicChartSymbolShape. At the moment (iOS18.3) we have the following choices: asterisk, circle, cross, diamond, pentagon, plus, square, and triangle.

    Using BasicChartSymbolShape, instead of the SFSymbols will solve the first three problems in the question. ie replace the line private let symbols = ["circle", ...] with private let symbols: [BasicChartSymbolShape] = [.circle, ...] and replace symbol {...} in ReproducedChart with .symbol(symbols[series % 5]).

    The final issue of the missing chart legend is much more subtle. It turns out that even though I have styled everything consistently with regard to the series that I have specified, Swift Charts is, perhaps reasonably, not assuming that I will have used the correct symbol for all instances of marks for "Series 1", etc. The API is not guaranteeing my correct choices, and therefore Swift Charts is not associating any of the fallible styling I've provided with the defined series, and therefore has no reliable information to put into a legend. So, any information in the Legend cannot come from API like symbol(_:) and foregroundStyle(_:), and instead we must use symbol(by:) and foregroundStyle(by:). These API guarantee that a consistent style will be applied to all elements in the same series.

    But then it seems like we're back to losing control of the styling choices! However, we can retake control of the choices that are being made using various modifiers in the Charts API that are named in the form chart*Scale(...). The most straightforward one for this situation seems to be chartSymbolScale(_:), which would be used to explicitly give the mappings of series to symbol, like this:

                Chart {
                    // ...
                }
                .chartSymbolScale([
                    "Series 1": .circle,
                    // ...
                    "Series 8": .triangle
                ])
    

    However, I found that the modifiers of the form chart*Scale(_:), which is to say, all the ones that have _ mapping: KeyValuePairs<DataValue, S> as their argument, are very brittle. I can't even get Xcode to compile code if I have used more than one such modifier in a view.

    The alternative I would suggest is to use one of the modifiers that take a mapping function as an argument. In this example, this means using chartSymbolScale(mapping:) and chartForegroundStyleScale(mapping:) and supplying mapping functions that supply the appropriate styling choice for each series.

    So after making the above suggested changes we end up with this code:

    private let styleMapping: [String: Color] = [
        "Series 1": .blue,
        "Series 2": .green,
        "Series 3": .orange,
        "Series 4": .purple,
        "Series 5": .red,
        "Series 6": .cyan,
        "Series 7": .yellow,
        "Series 8": .blue
    ]
    private func getForegroundStyleForSeries(_ series: String) -> Color {
        return styleMapping[series] ?? .gray
    }
    
    private let symbolMapping: [String: BasicChartSymbolShape] = [
        "Series 1": .circle,
        "Series 2": .square,
        "Series 3": .triangle,
        "Series 4": .diamond,
        "Series 5": .pentagon,
        "Series 6": .circle,
        "Series 7": .square,
        "Series 8": .triangle
    ]
    private func getSymbolForSeries(_ series: String) -> BasicChartSymbolShape {
        return symbolMapping[series] ?? .cross
    }
    
    
    private struct StandardChart: View {
        func chartPoint(x: Int, y: Double, series: Int) -> some ChartContent {
            LineMark(
                x: .value("Day", x),
                y: .value("Total", y)
            )
            .symbol(by: .value("Series", "Series \(series + 1)"))
            .foregroundStyle(by: .value("Series", "Series \(series + 1)"))
        }
        
        var body: some View {
            Chart {
                ForEach(0..<8, id: \.self) { index in
                    chartPoint(x: 0, y: 0, series: index)
                    chartPoint(x: 1, y: 0.75 * Double(index), series: index)
                    chartPoint(x: 2, y: Double(index), series: index)
                }
            }
        }
    }
    
    private struct ReproducedChart: View {
        func chartPoint(x: Int, y: Double, series: Int) -> some ChartContent {
            LineMark(
                x: .value("Day", x),
                y: .value("Total", y),
                series: .value("Series", "Series \(series + 1)")
            )
            .symbol(by: .value("Series", "Series \(series + 1)"))
            .foregroundStyle(by: .value("Series", "Series \(series + 1)"))
        }
        
        var body: some View {
            VStack {
                Chart {
                    ForEach(0..<8, id: \.self) { index in
                        chartPoint(x: 0, y: 0, series: index)
                        chartPoint(x: 1, y: 0.75 * Double(index), series: index)
                        chartPoint(x: 2, y: Double(index), series: index)
                    }
                }
                .chartForegroundStyleScale(mapping: getForegroundStyleForSeries)
                .chartSymbolScale(mapping: getSymbolForSeries)
            }
        }
    }
    
    struct ContentView: View {
        var body: some View {
            HStack {
                VStack {
                    Text("Standard")
                    StandardChart()
                }
                .padding()
    
                VStack {
                    Text("Reproduced")
                    ReproducedChart()
                }
                .padding()
            }
        }
    }
    

    with the following result:

    Side by side comparison of matching charts

    Now I all I have to do is to change the styling returned by my mapping functions getForegroundStyleForSeries and getSymbolForSeries, and I will get a result consistent with the default!