6.2 KiB
SwiftUI
GeometryReader
Convention
Avoid using GeometryReaders
in your main view tree. To measure views, place GeometryReaders
inside of a .background
or .overlay
and communicate the size up through a Preference
or other methods.
Rationale
GeometryReaders
always fill all of their proposed size, essentially overriding the layout behavior of the views they wrap. They also override the children's ideal size.
Usage of .background
or .overlay
is recommended because the content of these modifiers doesn't affect the layout of the parent view, so this limits the GR's impact while still allowing you to measure the parent view final size (note this can be different from the proposed size).
Example
// Good: Measure final size of Text without affecting its layout
Text("Sample text")
.background { // Size proposed to this will be the final size of the Text view
GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
}
}
.onPreferenceChange(SizePreferenceKey.self) { size in
// Do things with the measured size
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
// Bad: We measure size proposed to the Text view, but the GeometryReader won't fit the text.
GeometryReader { proxy in
Text("Sample text")
}
Exceptions
There are cases where you may need to read the size proposed to a view instead of the final size (for example to do custom proportional layout). In these cases, first consider if a combination of stacks, spacers and frames can be used instead.
If you absolutely need the GeometryReader
, expanding to fill the dimension you want to measure is unavoidable.
Example 1
Let's say you want to ensure a view's width is always 0.65x of the proposed width and the height is self sized.
Wrap a GeometryReader
in a frame, constraining the dimension you don't care about.
GeometryReader { proxy in
...
}
.frame(height: 0) // The GeometryReader won't fill all proposed height
Note that if you place your content inside of this GeometryReader
, as far as the GeometryReader
's parent is concerned, the height will always be 0. This can cause layout issues because the inner view will render out of bounds.
GeometryReader { proxy in
Rectangle()
// The Rectangle will take 20pts, but this height will be ignored by the layout system.
.frame(width: proxy.size.width * 0.65, height: 20)
}
.frame(height: 0)
This is caused by the .frame
we used to constrain the GeometryReader
to 0 height. To get the benefits of a height-constrained GeometryReader
and a correctly sized content view, you can place the GeometryReader
in a ZStack
and bubble up its measurement. Combined with the previous method, this means:
- you measure the proposed width
- you avoid taking up all of the proposed height
- the height proposed to the content view is untouched
- the height returned by the content view is untouched
Note that a ZStack
will match the size of its largest child in each dimension, so it will still fill all width due to the contained GeometryReader
.
// The Rectangle will have a width proportional to the proposed width without taking up all the proposed height.
// This is a two pass layout
@State var availableWidth: CGFloat = 0
ZStack {
GeometryReader { proxy in
Color.clear.preference(key: WidthPreferenceKey.self,
value: proxy.size.width)
}
.frame(height: 0)
.onPreferenceChange(WidthPreferenceKey.self) { newWidth in
availableWidth = newWidth
}
Rectangle()
.frame(width: availableWidth * 0.65, height: 20)
}
Example 2
If your inner view is flexible and wants to span the proposed height anyways, the ZStack
trick is unnecessary.
You will find that the GeometryReader
aligns the smaller inner view based on undocumented logic (top-leading as of 5/20/22). To make the alignment explicit and customizable, wrap your view in essentially a passthrough frame with an explicit alignment parameter.
// This view will span the proposed width and height
// The Rectangle will span the proposed height and take 0.65x of the proposed width.
// It will be centered in the proposed width.
GeometryReader { proxy in
Rectangle()
.frame(width: proxy.size.width * 0.65)
// This frame has the same sizing behavior as the GeometryReader, but allows you to change the alignment.
.frame(width: proxy.size.width,
height: proxy.size.height,
alignment: .center)
}
Multiline text
Convention
Use native Text
instead of a UIViewRepresentable
containing a UILabel
whenever possible.
Rationale
SwiftUI-UIKit layout bridging is heavily dependent on intrinsicContentSize
. UILabel
calculates this size in a special way, taking its preferredMaxLayoutWidth
into consideration to determine how many lines the label will need. In an Auto Layout environment this property is set automatically, but it won't be set by SwiftUI. This results in incorrect sizing for multiline UILabels
when used in SwiftUI. The native Text
doesn't suffer from these problems and takes the proposed width into consideration during a layout pass.
Exceptions
There are cases where the use of UILabel
is unavoidable, for example attributed text, which has only been supported in SwiftUI since iOS 15. In these cases you need to ensure preferredMaxLayoutWidth
is set to the correct value. There are multiple methods of doing this:
- in static width cases, you can directly set the property in your representable
- if width is dynamic, you'll need to pass it down to your representable from the SwiftUI context
- if the width is manually computed, you can pass it as a
@Binding
, making sure to update theUILabel
in the representable'supdateUIView
function. - if the width follows from a SwiftUI layout pass, you might need to use a
GeometryReader
to measure the relevant views. See theGeometryReader
sections for details.
- if the width is manually computed, you can pass it as a