Bind view geometry data to bindable properties
SwiftUI is a great UI framework. However, it’s still young and may lack things you need. In this post, we’ll look at a way to read geometry information from any view.
GeometryReader
GeometryReader
can wrap any view and provide geometric data via its GeometryProxy
:
GeometryReader { proxy in
Text("\(proxy.size.height)")
}
But beware, GeometryReader
is greedy and expands to take up as much space as it can! The code above does not return the text height, but the height of the available space.
Until you understand GeometryReader
, it can mess up your view hierarchy. Instead, let’s create an extension that lets us use it in a safer way.
View Extension
Let’s create a View
extension that binds any geometric value to a CGFloat
-based property.
When we’re done, we should be able to do this:
@State private var bodyHeight: CGFloat = 0
@State private var bodyWidth: CGFloat = 0
@State private var textHeight: CGFloat = 0
@State private var textWidth: CGFloat = 0
var body: some View {
ZStack {
Color.clear
.bindGeometry(to: $bodyHeight) { $0.size.height }
.bindGeometry(to: $bodyWidth) { $0.size.width }
Text("Hello!")
.bindGeometry(to: $textHeight) { $0.size.height }
.bindGeometry(to: $textWidth) { $0.size.width }
}
}
Color
is greedy and takes up as much space as it can, which will also affect the ZStack
. We can therefore use bindGeometry
on either the color or the stack, with the same result.
Text
, on the other hand, is not greedy. The binding will therefore get the size of the text.
Implementation
The implementation of this extension it pretty straightforward:
extension View {
func bindGeometry(
to binding: Binding<CGFloat>,
reader: @escaping (GeometryProxy) -> CGFloat
) -> some View {
self.background(GeometryBinding(reader: reader))
.onPreferenceChange(GeometryPreference.self) {
binding.wrappedValue = $0
}
}
}
private struct GeometryBinding: View {
let reader: (GeometryProxy) -> CGFloat
var body: some View {
GeometryReader { geo in
Color.clear.preference(
key: GeometryPreference.self,
value: self.reader(geo)
)
}
}
}
private struct GeometryPreference: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
The bindGeometry
takes a binding
and a reader
function that takes a GeometryProxy
and returns a CGFloat
. It then creates a GeometryBinding
with the reader
, adds it to the calling view, then binds an onPreferenceChanged
to the provided binding
.
GeometryBinding
is a view builder that creates a GeometryReader
and binds a preference
modifier to the provided reader
.
With this in place, we can now bind any CGFloat
property of the GeometryProxy
to any bindable property, e.g. @State
or the properties of an @ObservedObject
.
Cleaning things up
While the extension is convenient, its block-based syntax makes the view hierarchy ugly:
var body: some View {
ZStack {
Color.clear
.bindGeometry(to: $bodyHeight) { $0.size.height }
.bindGeometry(to: $bodyWidth) { $0.size.width }
Text("Hello!")
.bindGeometry(to: $textHeight) { $0.size.height }
.bindGeometry(to: $textWidth) { $0.size.width }
}
}
I prefer to use cleaner, more specific extensions, rather than using it as is. For instance, we can use it to create an extension that reads the safe area inset of any Edge
:
extension View {
func bindSafeAreaInset(
of edge: Edge,
to binding: Binding<CGFloat>) -> some View {
self.bindGeometry(to: binding) {
self.inset(for: $0, edge: edge)
}
}
}
private extension View {
func inset(for geo: GeometryProxy, edge: Edge) -> CGFloat {
let insets = geo.safeAreaInsets
switch edge {
case .top: return insets.top
case .bottom: return insets.bottom
case .leading: return insets.leading
case .trailing: return insets.trailing
}
}
}
This makes it possible to convert this:
.bindGeometry(to: $topInset) { $0.insets.top }
to the much cleaner and easier to read:
.bindSafeAreaInset(of: .top, to: $topInset)
This is however just a matter of taste. I like the cleaner syntax, but the original extension is really all you need.
Code
I have added these extensions to my SwiftUIKit library. You can find the source code here.