This post was original posted on mobile.blog.

As you all may already know, one of the cool new things introduced in WWDC 2022 was Swift Charts, a framework to create charts with SwiftUI. To learn more about the Swift Charts’ potential, let’s go on a thrilling adventure to replace the chart in WooCommerce iOS with this ✨✨ shiny new framework ✨✨.

To get started, here’s what the chart currently looks like in WooCommerce iOS. We’d want to build something similar using Swift Charts:

Current state

Preparations

The plan is to keep the placeholder in the current view controller where we can add a hosting controller for the new chart (written in SwiftUI) there. We need a new simple struct to encapsulate the basic data needed for the chart. The struct needs to conform to Identifiable just so we can iterate through a collection of this type in SwiftUI.

/// A struct for data to be displayed on a Swift chart.
///
struct StoreStatsChartData: Identifiable {
    var id: String { UUID().uuidString }
 
    let date: Date
    let revenue: Double
}

Basic chart

It’s time to start the actual work! First, let’s create a new SwiftUI view that contains only a single Chart. It needs a list of chart data as created above to create a line chart out of it.

In Swift Charts, the contents displaying data are called marks. Different chart types need different marks, for example for a line chart we need LineMark, and there are others like AreaMark, PointMark, RectangleMark, RuleMark and BarMark. Since the chart in WooCommerce iOS has both a line and some gradient beneath it, we can use a combination of a LineMark and AreaMark:

/// Chart for store stats build with Swift Charts.
///
@available(iOS 16, *)
struct StoreStatsChart: View {
    let intervals: [StoreStatsChartData]
 
    var body: some View {
        Chart(intervals) { item in
            LineMark(x: .value("Date", item.date),
                     y: .value("Revenue", item.revenue))
            .foregroundStyle(Color(Constants.chartLineColor))
 
            AreaMark(x: .value("Date", item.date),
                     y: .value("Revenue", item.revenue))
            .foregroundStyle(.linearGradient(colors: [Color(Constants.chartGradientTopColor), Color(Constants.chartGradientBottomColor)],
                                             startPoint: .top,
                                             endPoint: .bottom))
        }
    }
}

The result looks good with such minimal configuration:

Basic Chart

Customizations

The new chart displays correct data but doesn’t look quite close to what we want to build. Fortunately, it’s not complicated to customize charts with Swift Charts. The hardest part for me was navigating through the framework API when the majority of the documentation is missing! Good thing the WWDC video got me through most of it.

Annotations

Now let’s display a text over the chart that says “No revenue this period” when there’s no data to show. The text should display on top and at the center of a horizontal line for revenue zero. This can be handled by adding a RuleMark for the horizontal line at position zero for y, and then adding a Text as annotation for the mark:

if !hasRevenue {
    RuleMark(y: .value("Zero revenue", 0))
        .annotation(position: .overlay, alignment: .center) {
            Text("No revenue this period")
                .font(.footnote)
                .padding(Constants.annotationPadding)
                .background(Color(UIColor.systemBackground))
        }
}

And we’re also hiding the y axis when there’s no revenue in a chart. This can be handled with a single line using chartYAxis visibility modifier on the Chart:

.chartYAxis(hasRevenue ? .visible : .hidden)

And the result:

Chart with annotation

Axis customizations

The labels on the axes of the new chart didn’t match the current chart - the labels were too dense, and the formats weren’t right for both the dates and revenues. At first, I came up with a simple solution: since we already had implementations to configure labels for the axes, maybe I can send them to the new chart instead? Here was the result:

Chart with string values

This looks so horribly incorrect! The revenues aren’t displayed in the correct order, which makes the chart wrong. The reason was probably due to the data for the chart being Strings, which is a nominal data type, making Swift infers the chart differently from quantitative and temporal types (numbers and dates). I had to revert this change and look for another solution.

After watching the aforementioned WWDC video again, I learned that the axes can be configured with AxisMarks through chartXAxis and chartYAxis view modifiers on the Chart. The AxisMarks comes with a constructor that lets us define how to stride the data, and then define the content for each mark, which can be any or all of these: [AxisTick](https://developer.apple.com/documentation/charts/axistick), [AxisGridLine](https://developer.apple.com/documentation/charts/axisgridline) and [AxisValueLabel](https://developer.apple.com/documentation/charts/axisvaluelabel).

For the x-axis, I want to display a limited number of dates and format the date to match the correct time range for the current tab. I set the stride for the dates, and then use only a label for each mark to match the current design:

.chartXAxis {
    AxisMarks(values: .stride(by: xAxisStride, count: xAxisStrideCount)) { date in
        AxisValueLabel(format: xAxisLabelFormatStyle(for: date.as(Date.self) ?? Date()))
    }
}
private var xAxisStride: Calendar.Component {
    switch timeRange {
    case .today:
        return .hour
    case .thisWeek, .thisMonth:
        return .day
    case .thisYear:
        return .month
    }
}
 
private var xAxisStrideCount: Int {
    switch timeRange {
    case .today:
        return 5
    case .thisWeek:
        return 1
    case .thisMonth:
        return 5
    case .thisYear:
        return 3
    }
}
 
private func xAxisLabelFormatStyle(for date: Date) -> Date.FormatStyle {
    switch timeRange {
    case .today:
        return .dateTime.hour()
    case .thisWeek, .thisMonth:
        if date == intervals.first?.date {
            return .dateTime.month(.abbreviated).day(.twoDigits)
        }
        return .dateTime.day(.twoDigits)
    case .thisYear:
        return .dateTime.month(.abbreviated)
    }
}

For the y-axis, we need to work with AxisMarks as well, but the solution is a bit different because the values are numeric. In the current design, we want to display only 3 lines for the y-axis, so I had to calculate the stride such that the data is separated into 2 groups. The labels need to use custom format, so I do that in a helper function and send the formatted content to the AxisValueLabel directly. I also want to display the label on the leading edge, so I set it to the position value of AxisMarks:

.chartYAxis {
    AxisMarks(position: .leading, values: .stride(by: yAxisStride)) { value in
        AxisGridLine()
        AxisValueLabel(yAxisLabel(for: value.as(Double.self) ?? 0))
    }
}
private var yAxisStride: Double {
    let minValue = intervals.map { $0.revenue }.min() ?? 0
    let maxValue = intervals.map { $0.revenue }.max() ?? 0
    return (minValue + maxValue) / 2
}
 
private func yAxisLabel(for revenue: Double) -> String {
    if revenue == 0.0 {
        // Do not show the "0" label on the Y axis
        return ""
    } else {
        let currencySymbol = ServiceLocator.currencySettings.symbol(from: ServiceLocator.currencySettings.currencyCode)
        return CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)
            .formatCurrency(using: revenue.humanReadableString(shouldHideDecimalsForIntegerAbbreviatedValue: true),
                            currencyPosition: ServiceLocator.currencySettings.currencyPosition,
                            currencySymbol: currencySymbol,
                            isNegative: revenue.sign == .minus)
    }
}

And the result looks great:

Chart with customized axes

Interactions with gestures

One last touch to the new chart that I want to add is the feature to highlight a selected date and its revenue when touching any part of the chart. I want to support both tap and drag gestures, and display a vertical line on the date closest to the touch location, while also sending a callback about the selected interval index to update the parent controller UI.

This can be handled with the chartOverlay modifier together with a GeometryReader to find the position of the touch location on the screen and determine the value at that position using ChartProxy:

.chartOverlay { proxy in
    GeometryReader { geometry in
        Rectangle().fill(.clear).contentShape(Rectangle())
            .gesture(DragGesture()
                .onChanged { value in
                    updateSelectedDate(at: value.location,
                                       proxy: proxy,
                                       geometry: geometry)
                }
            )
            .onTapGesture { location in
                updateSelectedDate(at: location,
                                   proxy: proxy,
                                   geometry: geometry)
            }
    }
}
private func updateSelectedDate(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) {
    let xPosition = location.x - geometry[proxy.plotAreaFrame].origin.x
    guard let date: Date = proxy.value(atX: xPosition) else {
        return
    }
    selectedDate = intervals
        .sorted(by: {
            abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
        })
        .first?.date
    selectedRevenue = intervals.first(where: { $0.date == selectedDate })?.revenue
    if let index = intervals.firstIndex(where: { $0.date == selectedDate }) {
        onIntervalSelected(index)
    }
}

Here selectedDate and selectedRevenue are two @State variables that are used to add a RuleMark and PointMark to the chart:

if let selectedDate = selectedDate, hasRevenue {
    RuleMark(x: .value("Selected date", selectedDate))
        .foregroundStyle(Color(Constants.chartHighlightLineColor))
 
    if let selectedRevenue = selectedRevenue {
        PointMark(x: .value("Selected date", selectedDate),
                  y: .value("Selected revenue", selectedRevenue))
        .foregroundStyle(Color(Constants.chartHighlightLineColor))
    }
}

And the final result 🎉

Final chart

Further improvements

The experiment was pretty successful, but there are still some points in my checklist that weren’t covered:

  • The PointMark in the highlighted view needs to have a border and shadow, but I don’t know how to handle that yet. I tried adding those in the Color view of the mark’s foreground, but the output was not the expected ForegroundStyle.
  • The PointMark above also doesn’t really align with the label on the x-axis - this is another unsolved mystery to me.
  • It’d be nice to have some animation when the Chart appears. This was not covered in the WWDC video so I will need some more time to figure this out 😅

If you’re interested in playing with this experiment, join me on the branch hackweek/swift-charts-dashboard.

Conclusion

I hope you enjoyed this experiment with Swift Charts! We are looking forward to integrating this framework, it was fun to learn how it works and the promising features that it offers. Let me know if you have any questions!