Color Mode


    Language

Getting started with Swift Charts - part 1

October 23, 2023

Let's have a look at Swift Charts and see how we can use it to plot weather data.

Apple introduced Swift Charts in iOS 16, and improved it in iOS 17, adding, among others, new chart types. If your app still needs to support iOS 15 or lower versions, you can use a 3rd party library, like Daniel Gindi's Charts. In this demo project, we'll focus on the initial release of Swift Charts and well target iOS 16.

Data

First, let's find some data to plot. We'll be looking at weather data, and, for the sake of simplicity, we'll just load locally some JSON from this file. The JSON looks like this:

{
    "name": "Københavns Lufthavn",
    "latitude": 55.6139,
    "longitude": 12.6455,
    "temperatureSeries": [{
        "dateTime": "2023-09-21T13:10:00Z",
        "measuredValue": 21.9
    }, ...
    ],
    "precipitationSeries": [{
        "dateTime": "2023-09-21T13:00:00Z",
        "measuredValue": 1.4
    }, ...
    ],
    "windSeries": [{
        "dateTime": "2023-09-21T13:10:00Z",
        "measuredValue": 6.2,
        "secondaryMeasuredValue": 8.2
    },
    ]
}

We have the details of a station, with its name, and position, and then we have 3 series of data, one for temperature (measured in Celsius degrees), one for precipitation (measured in millimetres) and one for wind, which contains the value of the average wind and the value of the gust (max wind speed) for that particular time. All entries in the data series have a dateTime. For temperature and wind, we have 144 data points, corresponding to one measurement every 10 minutes, and for precipitation we have 24 data points, corresponding to a measurement every hour.

Let's start a new Xcode project, using SwiftUI, and let's add the JSON file to the project and the target.

We want to parse this data, so we're creating our model. The purpose of this article is to play with Swift Charts; there are other, possibly better-suited ways of modelling the data, but for now, let's create a WeatherDataPoint struct.

struct WeatherDataPoint: Codable, Identifiable {
    let dateTime: Date
    let measuredValue: Double
    let secondaryMeasuredValue: Double?

    var id: Date {
        dateTime
    }
}

And a StationMeasurements struct:

struct StationMeasurements: Codable {
    let name: String
    let latitude: Double
    let longitude: Double
    let temperatureSeries: [WeatherDataPoint]
    let precipitationSeries: [WeatherDataPoint]
    let windSeries: [WeatherDataPoint]
}

And let's parse the data from the JSON file into a mock:

extension StationMeasurements {
    static var mock: StationMeasurements {
        let url = Bundle.main.url(forResource: "StationData", withExtension: "json")!
        let data = try! Data(contentsOf: url)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        let jsonData = try! decoder.decode(StationMeasurements.self, from: data)
        return jsonData
    }
}

Basic Charts

We now have the data in our project, we can go ahead and start playing with it. We want to have three types of charts, a line chart to show the temperature, a bar chart to show precipitations, and another one with two lines on the same chart, so we can see the average and maximum wind on the same graph.

Let's describe that in code, by making an enum with a case for each chart type, and the data as an associated value.

enum ChartType {
    case temperature(temperatureData: [WeatherDataPoint])
    case precipitation(precipitationData: [WeatherDataPoint])
    case wind(windData: [WeatherDataPoint])
}

Let's start by creating the temperature chart. We'll make a new SwiftUI View, called ChartView. It will have a ChartType, which we'll also use to pass the data in through the associated value of the enum cases. The temperature chart will be a line chart, so inside the Chart we'll create a LineMark. We can then configure the line style, color and interpolation method, to make it prettier.

struct ChartView: View {
    let chartType: ChartType

    var body: some View {
        switch chartType {
        case .temperature(let temperatureData):
            Chart {
                ForEach(temperatureData) { element in
                    LineMark(
                        x: .value("dateTime", element.dateTime),
                        y: .value("temperature", element.measuredValue)
                    )
                    .lineStyle(StrokeStyle(lineWidth: 4))
                    .foregroundStyle(Color.blue)
                    .interpolationMethod(.cardinal)
                }
            }
        case .precipitation(let precipitationData):
            EmptyView()
        case .wind(let windData):
            EmptyView()
        }

    }
}

For the other two chart types, we can return EmptyView() for now. Here is the line chart showing the temperature data:

Let's continue and, similarly, create the bar chart. This time, we have to use the BarMark, but mostly everything else remains the same; we can remove the interpolationMethod, which doesn't make sense for a bar chart, and just change the identifiers.

case .precipitation(let precipitationData):
    Chart {
        ForEach(precipitationData) { element in
            BarMark(
                x: .value("dateTime", element.dateTime),
                y: .value("precipitation", element.measuredValue)
             )
              .lineStyle(StrokeStyle(lineWidth: 4))
              .foregroundStyle(Color.blue)
         }
    }

The wind chart will be a bit more interesting, it will consist of two line charts. So on the same Chart, we will have two LineMarks. The main difference from the temperature chart is that, for each LineMark, we will have to specify the series argument as well, so they will be plotted as individual series. We want the max wind speed to be plotted as a dashed line, so we also specify the dash parameter in the lineStyle.

case .wind(let windData):
    Chart {
        ForEach(windData) { element in
            LineMark(
                x: .value("dateTime", element.dateTime),
                y: .value("average wind", element.measuredValue),
                series: .value("average", "average wind")
            )
            .lineStyle(StrokeStyle(lineWidth: 4))
            .foregroundStyle(Color.blue)
            .interpolationMethod(.cardinal)

            if let secondaryMeasuredValue = element.secondaryMeasuredValue {
                LineMark(
                    x: .value("dateTime", element.dateTime),
                    y: .value("max wind", secondaryMeasuredValue),
                    series: .value("gust", "gust")
                )
                .lineStyle(StrokeStyle(lineWidth: 4, dash: [6]))
                .foregroundStyle(Color.red)
                .interpolationMethod(.cardinal)
            }
        }
    }

Now, we have the data plotted, but we would like to configure the axes. We want the vertical axis to be to the left of the chart, and we want to be able to change the font of the label on the vertical axes. Adding this code after the Chart {...} does the trick.

.chartYAxis {
    AxisMarks(position: .leading) {
        AxisValueLabel()
        AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
    }
}

But that only customizes one of the three charts, and we don't like to repeat code.

So what we want now is to extract the Chart content, and use only one Chart, to be able to customize the axes the same way for all types of charts without repeating code.

Let's create the chart contents for our three charts.

struct LineChartViewContent: ChartContent {
    let data: [WeatherDataPoint]

    var body: some ChartContent {
        ForEach(data) { element in
            LineMark(
                x: .value("dateTime", element.dateTime),
                y: .value("temperature", element.measuredValue)
            )
            .lineStyle(StrokeStyle(lineWidth: 4))
            .foregroundStyle(Color.blue)
            .interpolationMethod(.cardinal)
        }
    }
}

struct BarChartViewContent: ChartContent {
    let data: [WeatherDataPoint]

    var body: some ChartContent {
        ForEach(data) { element in
            BarMark(
                x: .value("dateTime", element.dateTime),
                y: .value("precipitation", element.measuredValue)
            )
            .lineStyle(StrokeStyle(lineWidth: 4))
            .foregroundStyle(Color.blue)
        }
    }
}

struct DoubleLineChartViewContent: ChartContent {
    let data: [WeatherDataPoint]

    var body: some ChartContent {
        ForEach(data) { element in
            LineMark(
                x: .value("dateTime", element.dateTime),
                y: .value("average wind", element.measuredValue),
                series: .value("average", "average wind")
            )
            .lineStyle(StrokeStyle(lineWidth: 4))
            .foregroundStyle(Color.blue)
            .interpolationMethod(.cardinal)

            if let secondaryMeasuredValue = element.secondaryMeasuredValue {
                LineMark(
                    x: .value("dateTime", element.dateTime),
                    y: .value("max wind", secondaryMeasuredValue),
                    series: .value("gust", "gust")
                )
                .lineStyle(StrokeStyle(lineWidth: 4, dash: [6]))
                .foregroundStyle(Color.red)
                .interpolationMethod(.cardinal)
            }
        }
    }
}

Customizing the axes

And now we can modify the ChartView to use the newly created ChartContents, and then we can customize the axes once for all charts.

struct ChartView: View {
    let chartType: ChartType

    var body: some View {
        Chart {
            switch chartType {
            case .temperature(let temperatureData):
                LineChartViewContent(data: temperatureData)
            case .precipitation(let precipitationData):
                BarChartViewContent(data: precipitationData)
            case .wind(let windData):
                DoubleLineChartViewContent(data: windData)
            }
        }
        .chartYAxis {
            AxisMarks(position: .leading) {
                AxisValueLabel()
                AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
            }
        }
    }
}

We also want to customize the horizontal axes. On this one, we display dates. Since the measurements are for 24 hours, and the space on the axis is quite limited, we're only interested in the time. We print one label every 4 hours and only print the time. We also show a grid line every 4 hours.

.chartXAxis {
    AxisMarks(values: .stride(by: .hour, count: 4)) { value in
        if let date = value.as(Date.self) {
            let hour = Calendar.current.component(.hour, from: date)
            AxisValueLabel {
                Text(date, style: .time)
            }
            AxisGridLine()
        }
    }
}

So here's how far we've come:

Where can we go from here? It would be nice if we could tap on the chart to see the exact measurement value, right? And maybe we can do something about that wind chart, which looks a bit crowded. We're looking at that in part 2.

Article photo generated with Midjourney

iosswiftuiswift charts

Author

Marius Constantinescu

Marius Constantinescu

iOS Director

iOS director at Monstarlab EMEA. 🍷 WSET Level 3 distinction.

You may also like

November 7, 2024

Introducing Shorebird, code push service for Flutter apps

Update Flutter apps without store review What is Shorebird? Shorebird is a service that allows Flutter apps to be updated directly at runtime. Removing the need to build and submit a new app version to Apple Store Connect or Play Console for review for ev...

Christofer Henriksson

Christofer Henriksson

Flutter

May 27, 2024

Introducing UCL Max AltPlay, a turn-by-turn real-time Football simulation

At this year's MonstarHacks, our goal was to elevate the sports experience to the next level with cutting-edge AI and machine learning technologies. With that in mind, we designed a unique solution for football fans that will open up new dimensions for wa...

Rayhan NabiRokon UddinArman Morshed

Rayhan Nabi, Rokon Uddin, Arman Morshed

MonstarHacks

ServicesCasesAbout Us
CareersThought LeadershipContact
© 2022 Monstarlab
Information Security PolicyPrivacy PolicyTerms of Service