Packed Bubble Chart in SwiftUI (iOS 16+)

DevTechie Inc
May 11, 2023

Packed bubbles chart is used to show relational value. The bubbles are packed in as tightly as possible to make efficient use of space.

Its easy to build packed bubble chart in SwiftUI. Here is what we will have by the end of this article.

Note: we will be using a few modifiers introduced in SwiftUI 4 but it can easily be refactored to support earlier version of SwiftUI

Let’s get started, we will start with the data structure for data points.

struct DataPoint: Identifiable {
    var id = UUID()
    var title: String
    var value: CGFloat
    var color: Color
    var offset = CGSize.zero
    var opacity = 1.0
}

Next, we will start building our PackedBubbleChart view.

struct PackedBubbleChart: View { }

Our chart expects a few parameters to be passed in for configuration.

@Binding var data: [DataPoint]
// space between bubbles
var spacing: CGFloat
// Angle in degrees -360 to 360
var startAngle: Int
var clockwise: Bool

Next we will create an internal struct to for bubble size.

struct BubbleSize {
    var xMin: CGFloat = 0
    var xMax: CGFloat = 0
    var yMin: CGFloat = 0
    var yMax: CGFloat = 0
}

Let’s add State variable for bubble size.

@State private var bubbleSize = BubbleSize()

By this time, our view should look like this

struct PackedBubbleChart: View {
    
    @Binding var data: [DataPoint]
    // space between bubbles
    var spacing: CGFloat
    // Angle in degrees -360 to 360
    var startAngle: Int
    var clockwise: Bool
    
    struct BubbleSize {
        var xMin: CGFloat = 0
        var xMax: CGFloat = 0
        var yMin: CGFloat = 0
        var yMax: CGFloat = 0
    }
    
    @State private var bubbleSize = BubbleSize()
  
}

We will add a body property to eliminate compiler errors as we need to add a few helper functions to help us build the rest of the view.

var body: some View { Text("DevTechie!") }

Let’s add a function to build a single bubble.

func bubble(item: DataPoint, scale: CGFloat) -> some View {
    ZStack {
        Circle()
            .frame(width: CGFloat(item.value) * scale,
                   height: CGFloat(item.value) * scale)
            .foregroundStyle(item.color.gradient)
            .opacity(item.opacity)
        VStack {
            Text(item.title)
                .bold()
            Text(item.value.formatted())
        }
    }
}

We need to offset the main bubble view in the x and y axis based on their size.

// X-Axis offset
func xOffset() -> CGFloat {
    if data.isEmpty { return 0.0 }
    let size = data.max{$0.value < $1.value}?.value ?? data[0].value
    let xOffset = bubbleSize.xMin + size / 2
    return -xOffset
}// Y-Axis offset
func yOffset() -> CGFloat {
    if data.isEmpty { return 0.0 }
    let size = data.max{$0.value < $1.value}?.value ?? data[0].value
    let yOffset = bubbleSize.yMin + size / 2
    return -yOffset
}

We need a function to offset all bubbles based on their relative positions and the frame.

// calculate and set the offsets
func setOffets() {
    if data.isEmpty { return }
    // first circle
    data[0].offset = CGSize.zero
    
    if data.count < 2 { return }
    // second circle
    let b = (data[0].value + data[1].value) / 2 + spacing
    
    // start Angle
    var alpha: CGFloat = CGFloat(startAngle) / 180 * CGFloat.pi
    
    data[1].offset = CGSize(width:  cos(alpha) * b,
                            height: sin(alpha) * b)
    
    // other circles
    for i in 2..<data.count {
        
        // sides of the triangle from circle center points
        let c = (data[0].value + data[i-1].value) / 2 + spacing
        let b = (data[0].value + data[i].value) / 2 + spacing
        let a = (data[i-1].value + data[i].value) / 2 + spacing
        
        alpha += calculateAlpha(a, b, c) * (clockwise ? 1 : -1)
        
        let x = cos(alpha) * b
        let y = sin(alpha) * b
        
        data[i].offset = CGSize(width: x, height: y )
    }
}// Calculate alpha from sides - 1. Cosine theorem
func calculateAlpha(_ a: CGFloat, _ b: CGFloat, _ c: CGFloat) -> CGFloat {
    return acos(
        ( pow(a, 2) - pow(b, 2) - pow(c, 2) )
        /
        ( -2 * b * c ) )
    
}

Function to calculate size of the overall plot area for the chart

// calculate max dimensions of offset view
func absoluteSize() -> BubbleSize {
    let radius = data[0].value / 2
    let initialSize = BubbleSize(xMin: -radius, xMax: radius, yMin: -radius, yMax: radius)
    
    let maxSize = data.reduce(initialSize, { partialResult, item in
        let xMin = min(
            partialResult.xMin,
            item.offset.width - item.value / 2 - spacing
        )
        let xMax = max(
            partialResult.xMax,
            item.offset.width + item.value / 2 + spacing
        )
        let yMin = min(
            partialResult.yMin,
            item.offset.height - item.value / 2 - spacing
        )
        let yMax = max(
            partialResult.yMax,
            item.offset.height + item.value / 2 + spacing
        )
        return BubbleSize(xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax)
    })
    return maxSize
}

Now we are ready to put all this together inside the body property.

var body: some View {
    
    let xSize = (bubbleSize.xMax - bubbleSize.xMin) == 0 ? 1 : (bubbleSize.xMax - bubbleSize.xMin)
    let ySize = (bubbleSize.yMax - bubbleSize.yMin) == 0 ? 1 : (bubbleSize.yMax - bubbleSize.yMin)
    
    GeometryReader { geo in
        
        let xScale = geo.size.width / xSize
        let yScale = geo.size.height / ySize
        let scale = min(xScale, yScale)
        
        ZStack {
            ForEach(data, id: \.id) { item in
                bubble(item: item, scale: scale)
                .offset(x: item.offset.width * scale, y: item.offset.height * scale)
            }
        }
        .offset(x: xOffset() * scale, y: yOffset() * scale)
    }
    .onAppear {
        setOffets()
        bubbleSize = absoluteSize()
    }
}

Our complete chart view will look like this:

struct PackedBubbleChart: View {
    
    @Binding var data: [DataPoint]
    // space between bubbles
    var spacing: CGFloat
    // Angle in degrees -360 to 360
    var startAngle: Int
    var clockwise: Bool
    
    struct BubbleSize {
        var xMin: CGFloat = 0
        var xMax: CGFloat = 0
        var yMin: CGFloat = 0
        var yMax: CGFloat = 0
    }
    
    @State private var bubbleSize = BubbleSize()
    
    var body: some View {
        
        let xSize = (bubbleSize.xMax - bubbleSize.xMin) == 0 ? 1 : (bubbleSize.xMax - bubbleSize.xMin)
        let ySize = (bubbleSize.yMax - bubbleSize.yMin) == 0 ? 1 : (bubbleSize.yMax - bubbleSize.yMin)
        
        GeometryReader { geo in
            
            let xScale = geo.size.width / xSize
            let yScale = geo.size.height / ySize
            let scale = min(xScale, yScale)
            
            ZStack {
                ForEach(data, id: \.id) { item in
                    bubble(item: item, scale: scale)
                    .offset(x: item.offset.width * scale, y: item.offset.height * scale)
                }
            }
            .offset(x: xOffset() * scale, y: yOffset() * scale)
        }
        .onAppear {
            setOffets()
            bubbleSize = absoluteSize()
        }
    }
    
    func bubble(item: DataPoint, scale: CGFloat) -> some View {
        ZStack {
            Circle()
                .frame(width: CGFloat(item.value) * scale,
                       height: CGFloat(item.value) * scale)
                .foregroundStyle(item.color.gradient)
                .opacity(item.opacity)
            VStack {
                Text(item.title)
                    .bold()
                Text(item.value.formatted())
            }
        }
    }
    
    // X-Axis offset
    func xOffset() -> CGFloat {
        if data.isEmpty { return 0.0 }
        let size = data.max{$0.value < $1.value}?.value ?? data[0].value
        let xOffset = bubbleSize.xMin + size / 2
        return -xOffset
    }
    
    // Y-Axis offset
    func yOffset() -> CGFloat {
        if data.isEmpty { return 0.0 }
        let size = data.max{$0.value < $1.value}?.value ?? data[0].value
        let yOffset = bubbleSize.yMin + size / 2
        return -yOffset
    }
    
    
    // calculate and set the offsets
    func setOffets() {
        if data.isEmpty { return }
        // first circle
        data[0].offset = CGSize.zero
        
        if data.count < 2 { return }
        // second circle
        let b = (data[0].value + data[1].value) / 2 + spacing
        
        // start Angle
        var alpha: CGFloat = CGFloat(startAngle) / 180 * CGFloat.pi
        
        data[1].offset = CGSize(width:  cos(alpha) * b,
                                height: sin(alpha) * b)
        
        // other circles
        for i in 2..<data.count {
            
            // sides of the triangle from circle center points
            let c = (data[0].value + data[i-1].value) / 2 + spacing
            let b = (data[0].value + data[i].value) / 2 + spacing
            let a = (data[i-1].value + data[i].value) / 2 + spacing
            
            alpha += calculateAlpha(a, b, c) * (clockwise ? 1 : -1)
            
            let x = cos(alpha) * b
            let y = sin(alpha) * b
            
            data[i].offset = CGSize(width: x, height: y )
        }
    }
    
    // Calculate alpha from sides - 1. Cosine theorem
    func calculateAlpha(_ a: CGFloat, _ b: CGFloat, _ c: CGFloat) -> CGFloat {
        return acos(
            ( pow(a, 2) - pow(b, 2) - pow(c, 2) )
            /
            ( -2 * b * c ) )
        
    }
    
    // calculate max dimensions of offset view
    func absoluteSize() -> BubbleSize {
        let radius = data[0].value / 2
        let initialSize = BubbleSize(xMin: -radius, xMax: radius, yMin: -radius, yMax: radius)
        
        let maxSize = data.reduce(initialSize, { partialResult, item in
            let xMin = min(
                partialResult.xMin,
                item.offset.width - item.value / 2 - spacing
            )
            let xMax = max(
                partialResult.xMax,
                item.offset.width + item.value / 2 + spacing
            )
            let yMin = min(
                partialResult.yMin,
                item.offset.height - item.value / 2 - spacing
            )
            let yMax = max(
                partialResult.yMax,
                item.offset.height + item.value / 2 + spacing
            )
            return BubbleSize(xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax)
        })
        return maxSize
    }
    
}

We will add sample data in our data structure’s extension.

extension DataPoint {
    static var sampleData: [DataPoint] {
        [
            DataPoint(title: "iOS", value: 180, color: .orange),
            DataPoint(title: "SwiftUI", value: 160, color: .indigo),
            DataPoint(title: "Swift", value: 170, color: .cyan),
            DataPoint(title: "ML", value: 90, color: .pink),
            DataPoint(title: "UIKit", value: 50, color: .mint),
            DataPoint(title: "Framework", value: 30, color: .teal),
            DataPoint(title: "Flutter", value: 60, color: .green),
        ]
    }
}

Let’s use this chart inside a view.

struct DevTechieBubbleChart: View {
    @State private var data: [DataPoint] = DataPoint.sampleData
    
    var body: some View {
        NavigationStack {
            VStack {
                PackedBubbleChart(data: $data, spacing: 0, startAngle: 180, clockwise: true)
                    .font(.caption)
                    .frame(height: 300)
                    .padding()
                    
                List(data) { datum in
                    HStack {
                        RoundedRectangle(cornerRadius: 10)
                            .frame(width: 50, height: 50)
                            .foregroundStyle(datum.color.gradient)
                        Text(datum.title)
                        Spacer()
                        Text(datum.value.formatted())
                            .foregroundColor(.secondary)
                    }
                    .padding()
                    .listRowSeparator(.hidden)
                    
                }
                .listStyle(.plain)
                .scrollIndicators(.hidden)
                
            }
            .navigationTitle("DevTechie.com")
        }
    }
}

Build and run