Build and style a chart with the new Swift Charts framework

Apple have just announced Swift Charts framework that we can use to create charts in our applications. From my quick look over the API, the framework can provide a lot more than the basic graphics generated by apps like Numbers etc. In this post I would like to share my initial experiments with the APIs. I hope to get into more detail in future articles, so follow me on Twitter and check back here for more articles and tips throughout the WWDC 2022 week and after.

For the examples, I'll be using a data set of popular US baby names over time.

# Build a stacked area chart

Here is how we can create a simple stacked area chart.

struct SimpleBabyNameView: View {
    let data: [BabyNamesDataPoint]
    
    var body: some View {
        Chart(data) { point in
            AreaMark(
                x: .value("Date", point.year),
                y: .value("Count", point.count)
            )
            .foregroundStyle(
                by: .value("Name", point.name)
            )
        }
    }
}
Screenshot of a stacked area chart showing US baby names over time

NOTE: It's very important to sort the data points by name. If I don't, I end up with a very broken chart like the one below!

Screenshot of a stacked area chart with broken area segments

The basic chart is great but it just does not fit in the aesthetics of my app. Stacked areas are all built up from the base of the chart and create a very heavy feeling. I would like to instead play around with creating a streamgraph to better show the organic flow over time and centre the data visually. Luckily, Swift Charts make this really easy!

By changing the constructor of AreaMark to the one that takes a MarkStackingMethod option, I can build a stacked area graph around the centre.

struct SimpleBabyNameView: View {
    let data: [BabyNamesDataPoint]
    
    var body: some View {
        Chart(data) { point in
            AreaMark(
                x: .value("Date", point.year),
                y: .value("Count", point.count),
                stacking: .center
            )
            .foregroundStyle(
                by: .value("Name", point.name)
            )
        }
    }
}
Screenshot of a stream area chart showing US baby names over time

# Customise the chart style

I would now like to look into adjusting the chart colours. The bright colours that have been automatically chosen are really good for quickly reading the chart, but they clash with the rest of my app so I need to come up with something else.

I can adjust the colour ramp that is being used with the chartForegroundStyleScale(range:) modifier. It can be placed at multiple levels within the chart structure. I will place it directly on the Chart view, to apply the customisation to all data ranges within the chart.

struct SimpleBabyNameViews: View {
    let data: [BabyNamesDataPoint]
    
    var body: some View {
        Chart(data) { point in
            AreaMark(
                x: .value("Date", point.year),
                y: .value("Count", point.count),
                stacking: .center
            )
            .foregroundStyle(
                by: .value("Name", point.name)
            )
        }
        .chartForegroundStyleScale(
            range: Gradient (
                colors: [
                    .purple,
                    .blue.opacity(0.3)
                ]
            )
        )
    }
}
Screenshot of a stream area chart showing US baby names over time coloured with purple to light blue shades for each name

# Add labels to the chart

This chart is starting to look like it will fit nicely into my app, however, it's now quite difficult to match the name to the corresponding region in the graph. It would be much nicer if I could place the labels directly on the graph.

I need to position these labels in such a way that it's clear what section they are labelling and they should not overlap. It would be perfect if I could label the point on the graph where the baby name was at its highest proportion of usage.

# Compute values for annotations

First thing to do is to find these dates. For this kind of work I like to use SwiftUI task(id:) modifier. It lets me easily run code asynchronously without blocking the main thread.

struct SimpleBabyNameViews: View {
    let data: [BabyNamesDataPoint]
    
    // We need a spot to save computed data
    @State var datesOfMaximumProportion: [
        (date: Date, name: String)
    ] = []
    
    var body: some View {
        
        //.... existing Chart code goes here ....//
        
        .task(id: data.count) {
            // reset the state
            self.datesOfMaximumProportion = []
            
            var namesToMaxProportion: [
                String: (proportion: Float, date: Date)
            ] = [:]
            
            // Find the date
            for point in self.data {
                if (
                    namesToMaxProportion[point.name]?
                        .proportion ?? 0
                ) < point.proportion {
                    namesToMaxProportion[point.name] =
                        (point.proportion, point.year)
                }
            }
            
            // Re-shape this into a flat list
            self.datesOfMaximumProportion = namesToMaxProportion
                .map { (key: String, value) in
                    (value.date, key)
                }
        }
    }
}

With these values computed I now need to use them to place text on the graph. Looking through the Chart APIs I can't find a way to place text directly on the chart, but I can annotate a RulerMark by using annotation() modifier.

# Add vertical rulers

I need to start by drawing vertical rulers at the maximum proportion dates.

In the chart code we have so far it's not clear how I can loop over my second datesOfMaximumProportion array. But by making use of multiple ForEach views instead of passing the data to the Chart constructor, I can create a single chart with multiple data sets.

struct SimpleBabyNameViews: View {
    let data: [BabyNamesDataPoint]
    
    @State var datesOfMaximumProportion: [
        (date: Date, name: String)
    ] = []
    
    var body: some View {
        Chart {
            ForEach(data) { point in
                AreaMark(
                    x: .value("Date", point.year),
                    y: .value("Count", point.count),
                    stacking: .center
                )
                .foregroundStyle(by: .value("Name", point.name))
            }
            ForEach(
                datesOfMaximumProportion, id: \.name
            ) { point in
                // We can now plot something here... 
            }
        }
        .chartForegroundStyleScale(...)
        .task(id: data.count) { ... }
    }
}

Now I can add my vertical rulers.

struct SimpleBabyNameViews: View {
    
    // ...
    
    var body: some View {
        Chart {
            ForEach(data) { point in
                // ... draw stacked area marks here
            }
            ForEach(
                datesOfMaximumProportion, id: \.name
            ) { point in
                RuleMark(
                    x: .value(
                        "Date of highest popularity for \(point.name)",
                        point.date
                    )
                )
            }
        }
        .chartForegroundStyleScale(...)
        .task(id: data.count) { ... }
    }
}
Screenshot of a stream area chart showing US baby names over time colored with purple to light blue shades for each name and vertical ruler lines.

Before I look into adding annotations, I would like to make the ruler lines a little nicer. I feel that these lines by themselves add some value to the graph and help highlight the dates corresponding to the peak popularity points. I found using a LinearGradient from top to bottom creates a gradient fill based from the top to the bottom of the charts frame. Adding a blendMode() modifier with the value darken reduces the interference that these lines have on the main charts content.

struct SimpleBabyNameViews: View {
    
    // ...
    
    var body: some View {
        Chart {
            ForEach(data) { ... }
            ForEach(
                datesOfMaximumProportion, id: \.name
            ) { point in
                RuleMark(
                    x: .value(
                        "Date of highest popularity for \(point.name)",
                        point.date
                    )
                )
                .foregroundStyle(
                    LinearGradient(
                        gradient: Gradient (
                            colors: [
                                .indigo.opacity(0.05),
                                .purple.opacity(0.5)
                            ]
                        ),
                        startPoint: .top,
                        endPoint: .bottom
                    )
                ).blendMode(.darken)
            }
        }
        .chartForegroundStyleScale(...)
        .task(id: data.count) { ... }
    }
}
Screenshot of a stream area chart showing US baby names over time coloured with a purple to light blue shades for each name and vertical ruler lines fading out to the top

# Annotate the rulers on the chart

The chart is now looking beautiful but I did get sidetracked, I need to figure out how to position the text labels in the correct vertical spot in the middle of each of their corresponding sections.

I have already sorted my dataset by name to ensure the stacked area graph renders correctly. Now I can compute the top and bottom positions of the area for a given name on its most popular date.

I will extend the task block to get the positions within which I need to centre the labels.

struct SimpleBabyNameViews: View {
    let data: [BabyNamesDataPoint]
    
    @State var datesOfMaximumProportion: [
        (date: Date, name: String, yStart: Float, yEnd: Float)
    ] = []
    
    var body: some View {
        Chart { ... }
        .chartForegroundStyleScale( ... )

        .task(id: data.count) {
            // ... compute namesToMaxProportion as above ...
            
            self.datesOfMaximumProportion = namesToMaxProportion
                .map { (key: String, value) in
                    let name = key
                    var count = 0
                    var before = 0
                    var after = 0
                
                    // Loop over all the datapoints
                    for point in self.data {
                        // Only consider points of the same year
                        
                        if point.year != value.date { continue }
                        if point.name == name {
                            count = point.count
                            continue
                        }
                        
                        if count != 0 {
                            // These sections come after
                            after += point.count
                        } else {
                            // These sections come before
                            before += point.count
                        }
                    }
                
                    let total = count + after + before
                    // The height is centred about the x-axis
                    let lowestValue = -1 * Float(total) / 2.0
                    let yEnd = lowestValue + Float(before)
                    let yStart = yEnd  + Float(count)
                    
                    return (value.date, key, yStart, yEnd)
                }
        }
    }
}

With this y-range I can now draw a much smaller (invisible) ruler at the date position from yStart to yEnd and place an annotation in its centre. I also rotated the text and placed a background behind it to make it nice and easy to read. I used the ultraThinMaterial, so that the background adopts the colour of the section it's labelling.

struct SimpleBabyNameViews: View {
 
   // ...
  
    var body: some View {
        Chart {
            ForEach(data) { point in ... }
            ForEach(
                datesOfMaximumProportion,
                id: \.name
            ) { point in ... }
            
            // Loop again to ensure labels are on top
            ForEach(
                datesOfMaximumProportion,
                id: \.name
            ) { point in
                
                // Create a ruler
                RuleMark(
                    x: .value(
                        "Date of highest popularity for \(point.name)",
                        point.date
                    ),
                    yStart: .value("", point.yStart),
                    yEnd: .value("", point.yEnd)
                )
                
                // Set the line width to 0 so as to make it invisible
                .lineStyle(StrokeStyle(lineWidth: 0))
                
                // Place an annotation in the centre of the ruler
                .annotation(
                    position: .overlay,
                    alignment: .center,
                    spacing: 4
                ){
                    // create the annotation
                    Text(point.name)
                        .font(.subheadline)
                        .padding(2)
                        .fixedSize()
                    
                        // Provide a background pill
                        .background(
                            RoundedRectangle(cornerRadius: 2)
                                .fill(.ultraThinMaterial)
                        )
                        .foregroundColor(.secondary)
                    
                        // Rotate the pill 90 degrees
                        .rotationEffect(
                            .degrees(-90),
                            anchor: .center
                        )
                        .fixedSize()
                }
            }
        }
        .chartForegroundStyleScale(...)
        .task(id: data.count) {...}
    }
}
Screenshot of a stream area chart showing US baby names over time with baby name labels on top of the graph

You can find the code for this chart in our GitHub project, this includes the code to download and parse the CSV file.