How to create a self-sizing gauge component in SwiftUI
SwiftUI is really great and building custom UI is actually pretty straight forward. Let's use function builders and preference keys to build a component that looks like this:
The gauge features a center view that the user can either provide or the gauge will automatically show a text presenting it's value. So once we have our gauge component ready it can be used like this:
// Simplest use possible, the Gauge will construct a centerView that shows: '\(value) %'.
// To prevent constant resizings when passing in other values the centerView width is calculated
// from the string: '100 %'
Gauge(value: value)
// provide your own centerView.
Gauge(value: value) {
Text("This is a fixed text")
}
// use an image
Gauge(value: value) {
Image(systemName: "headphones")
.resizable()
.frame(width: 55, height: 55)
}
The gauge will automatically adjust its size depending on the size of the center view.
How is this built?
import SwiftUI
public struct Gauge<T>: View where T: View {
let centerView: T
let value: Double
let thickness: CGFloat = 5
let scale: CGFloat = 1.777
let gradient = AngularGradient(
gradient: Gradient(
colors: [
.red,
.green
]
),
center: .center
)
@State private var diameter: CGFloat = 0
public init(value: Double, @GaugeBuilder builder: () -> T) {
self.value = value
self.centerView = builder()
}
public var body: some View {
ZStack {
centerView.background(
GeometryReader { proxy in
Color.clear.preference(key: GaugeWidthPreferenceKey.self, value: proxy.size.width)
}
)
Group {
Circle()
.stroke(Color.primary.opacity(0.2), style: .init(lineWidth: thickness/scale, dash: [5]))
Circle()
.trim(from: 0, to: CGFloat(value/100))
.stroke(gradient, style: .init(lineWidth: thickness))
}
.padding(thickness/2)
.rotationEffect(.degrees(90))
.frame(width: diameter, height: diameter)
}.onPreferenceChange(GaugeWidthPreferenceKey.self) { width in
self.diameter = width * self.scale
}
}
}
extension Gauge where T == ZStack<TupleView<(Text, Text)>> {
public init(value: Double) {
self.value = value
self.centerView = ZStack {
Text("100 %").foregroundColor(.clear)
Text("\(value, specifier: "%.0f") %")
}
}
}
@_functionBuilder
struct GaugeBuilder {
static func buildBlock<T: View>(_ centerView: T) -> T {
centerView
}
}
struct GaugeWidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
So that's the complete thing. There are a few things worth mentioning.
Function Builder
This is the magic behind the nice SwiftUI DSL. The buildBlock
function returns what gets passed as the trailing closure to the Gauge. So instead of this:
Gauge(value: value, centerView: Text("\(value) %")
you can write:
Gauge(value: value) {
Text("\(value) %")
}
Preference Key
In SwiftUI preference keys provide the possibility for a child view to pass values up to it's ancestors. While @Environment
-objects are visible to child views, PreferenceKeys
are visible to parents. What we want to achieve is that the Gauge knows the width of it's centerView
and adjusts the circles accordingly. So we add the .background
-modifier to the centerView
and fill its background with a transparent color. We use the GeometryReader
's proxy to get the size of the invisible Color and save that in the GaugeWidthPreferenceKey
.
Now we can use the .onPreferenceChange
-modifier on the outer ZStack to calculate the diameter of our circles. Since diamater is a @State
-property the body property of the Gauge will be evaluated and our circles are drawn with the desired diameter. Very cool!
Providing a default center view
There's one more thing required to make the Gauge work without the need to provide a centerView. We need an initializer that takes only the value as an argument. This can be done with an extension of the Gauge using conditional conformance.
Why conformance to ZStack<TupleView<(Text, Text)>>
?
In order to prevent the Gauge to change its size with every different value between 0 and 100 % I build a default centerView that has the invisible string "100 %" and centered on top of that the string representing the actual value. So the centerView
will always have the same width.