Search code examples
swiftuiswiftui-charts

How can we override the labels used in the legends of Swift Charts?


I'm curious, is there a way of overriding the text that's displayed for each series in the legends of Swift Charts?

I have an application where the names of the series are not guaranteed to be unique, and I want the application to display the series as two distinct series even if it gets data with duplicate names. The user will be able to adjust to the situation if they don't like the series names matching.

To illustrate this, consider the following sample code:

private struct PlotPoint {
    let x: Double
    let y: Double
}

private struct PlotSeries: Identifiable {
    let id: Int
    let name: String
    let points: [PlotPoint]
}

private let data: [PlotSeries] = [
    PlotSeries(id: 0, name: "Blue Hulk", points: stride(from: 0.0, through: 1.0, by: 0.05).map { y in PlotPoint(x: y*y, y: y) }),
    PlotSeries(id: 1, name: "Green Hulk", points: stride(from: 0.0, through: 1.0, by: 0.05).map { y in PlotPoint(x: y*y, y: y/2) })
]

struct LegendOverrideLabel: View {
    var body: some View {
        Chart {
            ForEach(data) { series in
                let plot = LinePlot(
                    series.points,
                    x: .value("x", \.x),
                    y: .value("y", \.y)
                )
                
                plot
                    .symbol(by: .value("Series", series.name))
                    .foregroundStyle(by: .value("Series", series.name))
            }
        }
        .padding()
    }
}

Here, we have the series names being given as "Blue Hulk" and "Green Hulk". Everything is working fine, and we get a chart like this:

Blue Hulk and Green Hulk in Legend

But then, suppose that the user decides that because of the legend putting a coloured symbol next to the Series name, the "Blue" and "Green" are redundant. So they edit the data so the series name is just "Hulk" in both cases. But because our series are defined by the value we gave (.symbol(by: .value("Series", series.name)), etc), Swift charts is identifying all the data as being in the same series, and we get this result:

Both Hulks merged into one

So I would like a way of helping Swift Charts accept the longer unique names as the names of the series, but I would like to intercept those values before display in the legend and remove the colour adjectives at the front, to get a result like this:

Both called Hulk, but one green and one blue

I looked at defining my own custom Plottable type, but that was a dead-end. I wish I could just use a UUID to identify the series (I know, UUID is not Plottable, so the uuidString for it), and provide a mapping to get from UUID to the user-friendly label to be displayed. But I don't see anything like that in the API. Is there another nice way of solving this?


Solution

  • I would include some invisible characters to make the legend labels technically different, but visually the same. For example, you can add \0 characters as a prefix.

    To make this more convenient, you can replace the id and name in PlotSeries with a single Plottable property. You can then include the \0 prefixes in the Plottable implementation.

    private struct PlotSeries: Identifiable {
        struct ID: Hashable, Plottable {
            let number: Int
            let name: String
            
            init(number: Int, name: String) {
                self.number = number
                self.name = name
            }
            
            // remove the \0 prefixes
            init?(primitivePlottable: String) {
                let prefixCount = primitivePlottable.prefixMatch(of: /\0*/)?.output.count ?? 0
                self.init(number: prefixCount, name: String(primitivePlottable.dropFirst(prefixCount)))
            }
            
            // add the \0 prefixes
            var primitivePlottable: String {
                String(repeating: "\0", count: number) + name
            }
        }
        
        let id: ID
        let points: [PlotPoint]
        
        init(number: Int, name: String, points: [PlotPoint]) {
            self.id = ID(number: number, name: name)
            self.points = points
        }
    }
    

    Usage:

    private let data: [PlotSeries] = [
        PlotSeries(number: 0, name: "Hulk", points: stride(from: 0.0, through: 1.0, by: 0.05).map { y in PlotPoint(x: y*y, y: y) }),
        PlotSeries(number: 1, name: "Hulk", points: stride(from: 0.0, through: 1.0, by: 0.05).map { y in PlotPoint(x: y*y, y: y/2) })
    ]
    
    struct LegendOverrideLabel: View {
        var body: some View {
            Chart {
                ForEach(data) { series in
                    let plot = LinePlot(
                        series.points,
                        x: .value("x", \.x),
                        y: .value("y", \.y)
                    )
                    
                    plot
                        .symbol(by: .value("Series", series.id))
                        .foregroundStyle(by: .value("Series", series.id))
                }
            }
            .padding()
        }
    }