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