Search code examples
swiftswiftuiios16

Custom legends in Swift Charts for iOS 16


How do I obtain colour imagery, or more generally, styling information for each legend entry to construct a custom legend in a chart in Swift Charts?

I have a chart here, with a legend positioned on the right, but using the content: argument of chartLegend does not pass any info into the closure to use. I would like to wrap the legend into a scroll view, so when there are too many entries, the chart will appear correctly on the screen, and the user can scroll through the legend below the chart.

Chart(points, id: \.self) { point in
     LineMark(
          x: .value("time/s", point.timestamp),
          y: .value("potential/mV", point.potential)
    )
    .foregroundStyle(by: .value("Electrode", point.electrode.symbol))
}
.chartLegend(position: .bottom)
// ...

Here is the chart with too many legend entries interfering with the chart sizing, resulting in cropping:

Cropped chart.

And here is the chart with only a few entries so that the chart is sized correctly, with no cropping, and the legend has text to discern between the electrodes they represent:

Same chart with fewer legend entries.

Any help is much appreciated.


Solution

  • I'm not sure what you originally tried, but this works in Swift 5.7. Overriding the legend with .chartLegend() removes the legend colors, so I added func colorFor(symbol:) to cycle through the default legend colors. Note: "symbols" is an array of the electrode names, used in the legend.

    import SwiftUI
    import Charts
    
    struct ContentView: View {
        let (symbols, points) = Point.sampleData()
        let colors = [Color.blue, .green, .orange, .purple, .red, .cyan, .yellow]
    
        var body: some View {
            Chart(points, id: \.self) { point in
                LineMark(
                    x: .value("time/s", point.timestamp),
                    y: .value("potential/mV", point.potential)
                )
                .foregroundStyle(by: .value("Electrode", point.electrode.symbol))
            }
            .chartLegend(position: .bottom) {
                ScrollView(.horizontal) {
                    HStack {
                        ForEach(symbols, id: \.self) { symbol in
                            HStack {
                                BasicChartSymbolShape.circle
                                    .foregroundColor(colorFor(symbol: symbol))
                                    .frame(width: 8, height: 8)
                                Text(symbol)
                                    .foregroundColor(.gray)
                                    .font(.caption)
                            }
                       }
                    }
                    .padding()
                }
            }
            .chartXScale(domain: 0...0.5)
            .chartYAxis {
                AxisMarks(position: .leading)
            }
            .padding()
        }
        
        func colorFor(symbol: String) -> Color {
            let symbolIndex = symbols.firstIndex(of: symbol) ?? 0
            return colors[symbolIndex % colors.count]  // wrap-around colors
        }
    }
    

    SwiftUI Chart with scrolling legend

    Here's the code I used to generate sample data, in case you were wondering.

    import Foundation
    
    struct Point: Hashable {
        var timestamp: Double
        var potential: Double
        var electrode: Electrode
        
        struct Electrode: Hashable {
            var symbol: String
        }
        
        static func sampleData() -> ([String], [Point]) {
            let numElectrodes = 20
            let numTimes = 100
            let maxTime = 0.5
            let amplitude = 80.0
            let frequency = 2.0  // Hz
            let bias = 30.0
            
            var symbols = [String]()
            var points = [Point]()
            
            for e in 0..<numElectrodes {
                let phase = Double.random(in: 0...10)
                let symbol = "C\(e + 1)"
                symbols.append(symbol)
                for t in 0..<numTimes {
                    let time = maxTime * Double(t) / Double(numTimes)
                    let potential = amplitude * sin(2 * Double.pi * frequency * time - phase) + bias
                    points.append(Point(timestamp: time, potential: potential, electrode: .init(symbol: symbol)))
                }
            }
            return (symbols, points)
        }
        
        static func == (lhs: Point, rhs: Point) -> Bool {
            lhs.electrode == rhs.electrode && lhs.timestamp == rhs.timestamp
        }
    }