Building BarChart with Shape API in SwiftUI

This week I want to show you how to use Shape API in SwiftUI. We will take a look at ready to use shapes like Circle, Capsule, Rectangle, etc. We will learn how to draw super custom shapes by using Path and GeometryReader. In the end, we will build BarChart implementation ultimately in SwiftUI.

Build with Xcode, Ship with Helm.
The all-in-one macOS app that enhances App Store Connect, supercharging your app updates, localization, and ASO with AI-powered tools. Save 25% and try now!

Shape protocol

SwiftUI provides Shape protocol which has a single requirement path function. We use path function to describe our shape inside a provided rectangle. Let’s try to use it to draw a progress ring.

struct ProgressShape: Shape {
    let progress: Double

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.addArc(
            center: CGPoint(x: rect.midX, y: rect.midY),
            radius: rect.width / 2,
            startAngle: .radians(1.5 * .pi),
            endAngle: .init(radians: 2 * Double.pi * progress + 1.5 * Double.pi),
            clockwise: false
        )
        return path
    }
}

Here we use a little math to calculate arc which describes our progress. Shape protocol conforms to View protocol that’s why we can easily use it inside Stack or Group. We can also customize default drawing by setting custom StrokeStyle.

struct ProgressView: View {
    let progress: Double

    var body: some View {
        ProgressShape(progress: progress)
            .stroke(Color.blue,
                    style: StrokeStyle(
                        lineWidth: 4,
                        lineCap: .round,
                        lineJoin: .round,
                        miterLimit: 0,
                        dash: [],
                        dashPhase: 0
                    )
        )
    }
}

Ready to use shapes

We discussed how to draw custom shapes, but most of the time, it is enough to apply ready to use shapes provided by SwiftUI. SwiftUI offers shapes like Circle, Capsule, Rectangle, etc. We can easily use them to build BarChart in SwiftUI. Let’s start by describing the Bar data model.

struct Legend: Hashable {
    let color: Color
    let label: String
}

struct Bar: Identifiable {
    let id: UUID
    let value: Double
    let label: String
    let legend: Legend
}

As you can see, our Bar struct conforms to Identifiable protocol. We need Identifiable conformance to use Bar struct inside ForEach. ForEach uses Identifiable to understand data changes during the rendering process. Now we can draw our bars by using shape Capsule.

struct BarsView: View {
    let bars: [Bar]
    let max: Double

    init(bars: [Bar]) {
        self.bars = bars
        self.max = bars.map { $0.value }.max() ?? 0
    }

    var body: some View {
        GeometryReader { geometry in
            HStack(alignment: .bottom, spacing: 0) {
                ForEach(self.bars) { bar in
                    Capsule()
                        .fill(bar.legend.color)
                        .frame(height: CGFloat(bar.value) / CGFloat(self.max) * geometry.size.height)
                        .overlay(Rectangle().stroke(Color.white))
                        .accessibility(label: Text(bar.label))
                        .accessibility(value: Text(bar.legend.label))
                }
            }
        }
    }
}

Here we implement BarsView which uses HStack to display capsules in the horizontal layout. Another interesting fact here is GeometryReader. By wrapping any view inside the GeometryReader, we can access the geometry data like parent size and safe area insets provided by its container. We use container size to calculate the height of bars and draw them properly. We also use accessibility modifier here to make our bars more accessible and navigable.

Every chart should have a legend view. Let’s implement it by using another regular shape provided by SwiftUI. I want to display legends as a view which contains a small colored circle on the left and title on the right.

struct LegendView: View {
    private let legends: [Legend]

    init(bars: [Bar]) {
        legends = Array(Set(bars.map { $0.legend }))
    }

    var body: some View {
        HStack(alignment: .center) {
            ForEach(legends, id: \.self) { legend in
                VStack(alignment: .center) {
                    Circle()
                        .fill(legend.color)
                        .frame(width: 16, height: 16)

                    Text(legend.label)
                        .font(.subheadline)
                        .lineLimit(nil)
                }
            }
        }
    }
}

In the example above, we use Сircle shape to draw color indicators of every Legend. Now we can compose our BarsView and LegendsView to provide BarChart experience.

struct BarChartView: View {
    let bars: [Bar]

    var body: some View {
        Group {
            if bars.isEmpty {
                Text("There is no data to display chart...")
            } else {
                VStack {
                    BarsView(bars: bars)
                    LegendView(bars: bars)
                        .padding()
                        .accessibility(hidden: true)
                }
            }
        }
    }
}

Here we have BarChartView which puts BarsView and LegendsView inside the VStack. We also use accessibility modifier to hide LegendsView in the Accessibility tree.

Real-life example

I use the source code from this post to implement BarChart in my SleepBot app. Besides the implementation which we have in the current post, I’ve added LabelsView. LabelsView presents the BarChart labels below the chart itself. Here is the implementation of that view.

struct LabelsView: View {
    let bars: [Bar]
    let labelsCount: Int

    private var threshold: Int {
        let threshold = bars.count / labelsCount
        return threshold == 0 ? 1 : threshold
    }

    var body: some View {
        HStack {
            ForEach(0..<bars.count, id: \.self) { index in
                Group {
                    if index % self.threshold == 0 {
                        Spacer()
                        Text(self.bars[index].label)
                            .font(.caption)
                        Spacer()
                    }
                }
            }
        }
    }
}

Take a look at the final version of BarChartView in SleepBot app. chart

Conclusion

Today we talked about Shape API which we have in SwiftUI. We also build BarChart experience by using ready to use Capsule and Circle shapes. We made our chart accessible by providing labels and values for our bars. Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next week!