Search code examples
swiftuiswiftui-charts

Swift Chart Color Legend Dynamically


I’m relatively new to SwiftUI, completely new to Swift Charts, and I seem to have run into a wall. I am trying to get my graph to reflect the colors present in entryObservation.subject.color but if I use a simple .foregroundStyle(entryObservation.subject.color) then the legend doesn’t show, even when I explicitly tell it to with .chartLegend(.visible), and if I use .foregroundStyle(by: .value("Subject", entryObservation.subject.name)) then the chart doesn’t reflect the color in my data, but the legend shows.

I have done some digging and it appears that I may need to use chartForegroundStyleScale(mapping:) but I’m pretty lost on how to do that. Keep in mind the data is dynamic and the code needs to support this so .chartForegroundStyleScale(["Men": .blue, "Boys": .red, "Girls": .teal, "Women": .mint]) won’t work. Is this even the right route to go down or should I just build a custom legend? Any help would be greatly appreciated, Josh

import SwiftUI
import Charts

struct EntryObservation {
    var subject: Subject
    var sectionGroup: SectionGroup
    var time: Date
}

struct Subject {
    var name: String
    var color: Color
}

struct SectionGroup {
    var name: String
}

struct ClickerView: View {
    var entryObservations: [EntryObservation]
    @State private var categorization = 1
    var body: some View {
        Chart (entryObservations, id: \.time) {entryObservation in
            Plot {
                switch categorization {
                case 2:
                    BarMark(x: .value("Section", entryObservation.sectionGroup.name), y: .value("Count", 1))
                        .foregroundStyle(by: .value("Subject", entryObservation.subject.name))
                        .cornerRadius(10)
                        .annotation(position: .top) {
                            Text("\(entryObservations.filter { $0.sectionGroup.name == entryObservation.sectionGroup.name }.count)")
                                .foregroundColor(Color.gray)
                                .font(.system(size: 12, weight: .bold))
                        }
                default:
                    BarMark(x: .value("Subject", entryObservation.subject.name), y: .value("Count", 1)
                        .cornerRadius(10)
                        .foregroundStyle(by: .value("Subject", entryObservation.subject.name))
                        .annotation(position: .top) {
                            Text("\(entryObservations.filter { $0.subject.name == entryObservation.subject.name }.count)")
                                .foregroundColor(Color.gray)
                                .font(.system(size: 12, weight: .bold))
                        }
                }
            }
        }.padding()
.chartLegend(.visible)
            .chartYAxisLabel("Count")
            .safeAreaInset(edge: .top) {
                VStack {
                    Picker("Categorisation", selection: $categorization) {
                        Text("Subject").tag(1)
                        Text("Section").tag(2)
                    }
                    .pickerStyle(.segmented)
                }
            }
    }
}

let entryObservations: [EntryObservation] = [
    EntryObservation(subject: Subject(name: "Men", color: .blue), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 1000)),
    EntryObservation(subject: Subject(name: "Men", color: .blue), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 1300)),
    EntryObservation(subject: Subject(name: "Men", color: .blue), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 2000)),
    EntryObservation(subject: Subject(name: "Boys", color: .red), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 1900)),
    EntryObservation(subject: Subject(name: "Boys", color: .red), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 2200)),
    EntryObservation(subject: Subject(name: "Boys", color: .red), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 2500)),
    EntryObservation(subject: Subject(name: "Girls", color: .teal), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 2800)),
    EntryObservation(subject: Subject(name: "Girls", color: .teal), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 3100)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 3400)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 3700)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 4000)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 4300)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 4600)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 4900)),
    EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 5200))

]

#Preview {
    ClickerView(entryObservations: entryObservations)
}

Solution

  • I would first create a [String: Color] like this to map the subject names to their respective colours:

    @State private var colors: [String: Color] = [:]
    

    You can set this in onChange:

    // this assumes that each name only corresponds to one color
    // there must not be two subjects with the same name but different colours
    .onChange(of: entryObservations, initial: true) { _, newValue in
        colors = Dictionary(uniqueKeysWithValues: Set(newValue.map(\.subject)).map { ($0.name, $0.color) })
    }
    

    onChange requires the of: argument to conform to Equatable, so you should do that for all the structs involved - EntryObservation, Subject, and SectionGroup.

    Then you can use the chartForegroundStyleScale that takes a function parameter, to map each name to a color using the colors dictionary.

    .chartForegroundStyleScale { (name: String) in
        colors[name] ?? .clear
    }
    

    I'd recommend doing a similar thing with the two different categorisations. Create two ChartData arrays, where ChartData contains all the data each BarMark needs, and update the arrays in onChange. This way you don't need to filter on each view update.


    Minimal reproducible example:

    @State private var colors: [String: Color] = [:]
    @State private var categorization = 1
    var body: some View {
        Chart (entryObservations, id: \.time) {entryObservation in
            Plot {
                switch categorization {
                case 2:
                    BarMark(x: .value("Section", entryObservation.sectionGroup.name), y: .value("Count", 1))
                        .foregroundStyle(by: .value("Subject", entryObservation.subject.name))
                        .cornerRadius(10)
                        .annotation(position: .top) {
                            Text("\(entryObservations.filter { $0.sectionGroup.name == entryObservation.sectionGroup.name }.count)")
                                .foregroundColor(Color.gray)
                                .font(.system(size: 12, weight: .bold))
                        }
                default:
                    BarMark(x: .value("Subject", entryObservation.subject.name), y: .value("Count", 1))
                        .cornerRadius(10)
                        .foregroundStyle(by: .value("Subject", entryObservation.subject.name))
                        .annotation(position: .top) {
                            Text("\(entryObservations.filter { $0.subject.name == entryObservation.subject.name }.count)")
                                .foregroundColor(Color.gray)
                                .font(.system(size: 12, weight: .bold))
                        }
                }
            }
        }
        .padding()
        .chartLegend(.visible)
        .chartYAxisLabel("Count")
        .safeAreaInset(edge: .top) {
            VStack {
                Picker("Categorisation", selection: $categorization) {
                    Text("Subject").tag(1)
                    Text("Section").tag(2)
                }
                .pickerStyle(.segmented)
            }
        }
        .onChange(of: entryObservations, initial: true) { _, newValue in
            colors = Dictionary(uniqueKeysWithValues: Set(newValue.map(\.subject)).map { ($0.name, $0.color) })
        }
        .chartForegroundStyleScale { (name: String) in
            colors[name] ?? .clear
        }
    }
    
    let entryObservations: [EntryObservation] = [
        EntryObservation(subject: Subject(name: "Men", color: .blue), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 1000)),
        EntryObservation(subject: Subject(name: "Men", color: .blue), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 1300)),
        EntryObservation(subject: Subject(name: "Men", color: .blue), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 2000)),
        EntryObservation(subject: Subject(name: "Boys", color: .red), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 1900)),
        EntryObservation(subject: Subject(name: "Boys", color: .red), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 2200)),
        EntryObservation(subject: Subject(name: "Boys", color: .red), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 2500)),
        EntryObservation(subject: Subject(name: "Girls", color: .teal), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 2800)),
        EntryObservation(subject: Subject(name: "Girls", color: .teal), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 3100)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 3400)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 3700)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 4000)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 4300)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 4600)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "Mall"), time: Date(timeIntervalSince1970: 4900)),
        EntryObservation(subject: Subject(name: "Women", color: .mint), sectionGroup: SectionGroup(name: "School"), time: Date(timeIntervalSince1970: 5200))
        
    ]