First, it's worth understanding that in the case of filling a RoundedRectangle
behind a Text
, you don't need to measure the text or send the size up the view hierarchy. You can configure it to choose a height that fits its content exactly. Then add the RoundedRectangle
using the .background
modifier. Example:
import SwiftUI
import PlaygroundSupport
let message = String(NotificationCenter.default.debugDescription.prefix(300))
PlaygroundPage.current.setLiveView(
Text(message)
.fixedSize(horizontal: false, vertical: true)
.padding(12)
.frame(width: 480)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.red.opacity(0.9))
)
.padding(12)
)
Result:

Okay, but sometimes you really do need to measure a view and pass its size up the hierarchy. In SwiftUI, a view can send information up the hierarchy in something called a “preference”. Apple hasn't thoroughly documented the preference system yet, but some people have figured it out. In particular, kontiki has described it starting with this article at swiftui-lab. (Every article at swiftui-lab is great.)
So let's make up an example where we really do need to use a preference. ConversationView
shows a list of messages, each one labeled with its sender:
struct Message {
var sender: String
var body: String
}
struct MessageView: View {
var message: Message
var body: some View {
HStack(alignment: .bottom, spacing: 3) {
Text(message.sender + ":").padding(2)
Text(message.body)
.fixedSize(horizontal: false, vertical: true).padding(6)
.background(Color.blue.opacity(0.2))
}
}
}
struct ConversationView: View {
var messages: [Message]
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(messages.indices) { i in
MessageView(message: self.messages[i])
}
}
}
}
let convo: [Message] = [
.init(sender: "Peanutsmasher", body: "How do I get the size (width/height) of an UI element after its been rendered and pass it back to the parent for re-rendering?"),
.init(sender: "Rob", body: "First, it's worth understanding blah blah blah…"),
]
PlaygroundPage.current.setLiveView(
ConversationView(messages: convo)
.frame(width: 480)
.padding(12)
.border(Color.black)
.padding(12)
)
It looks like this:

We'd really like to have the left edges of those message bubbles aligned. That means we need to make the sender Text
s have the same width. We'll do it by extending View
with a new modifier, .equalWidth()
. We'll apply the modifier to the sender Text
like this:
struct MessageView: View {
var message: Message
var body: some View {
HStack(alignment: .bottom, spacing: 3) {
Text(message.sender + ":").padding(2)
.equalWidth(alignment: .trailing) // <-- THIS IS THE NEW MODIFIER
Text(message.body)
.fixedSize(horizontal: false, vertical: true).padding(6)
.background(Color.blue.opacity(0.2))
}
}
}
And up in ConversationView
, we'll define the “domain” of the equal-width views using another new modifier, .equalWidthHost()
.
struct ConversationView: View {
var messages: [Message]
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(messages.indices) { i in
MessageView(message: self.messages[i])
}
} //
.equalWidthHost() // <-- THIS IS THE NEW MODIFIER
}
}
Before we can implement these modifiers, we need to define a PreferenceKey
(which we will use to pass the widths up the view hierarchy from the Text
s to the host) and an EnvironmentKey
(which we will use to pass the chosen width down from the host to the Text
s).
A type conforms to PreferenceKey
by defining a defaultValue
for the preference, and a method for combining two values. Here's ours:
struct EqualWidthKey: PreferenceKey {
static var defaultValue: CGFloat? { nil }
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
switch (value, nextValue()) {
case (_, nil): break
case (nil, let next): value = next
case (let a?, let b?): value = max(a, b)
}
}
}
A type conforms to EnvironmentKey
by defining a defaultValue
. Since EqualWidthKey
already does that, we can reuse our PreferenceKey
as an EnvironmentKey
:
extension EqualWidthKey: EnvironmentKey { }
We also need to add an accessor to EnvironmentValues
:
extension EnvironmentValues {
var equalWidth: CGFloat? {
get { self[EqualWidthKey.self] }
set { self[EqualWidthKey.self] = newValue }
}
}
Now we can implement a ViewModifier
that sets the preference to the width of its content, and applies the environment width to its content:
struct EqualWidthModifier: ViewModifier {
var alignment: Alignment
@Environment(\.equalWidth) var equalWidth
func body(content: Content) -> some View {
return content
.background(
GeometryReader { proxy in
Color.clear
.preference(key: EqualWidthKey.self, value: proxy.size.width)
}
)
.frame(width: equalWidth, alignment: alignment)
}
}
By default, GeometryReader
fills as much space as its parent gives it. That's not what we want to measure, so we put the GeometryReader
in a background
modifier, because a background view is always the size of its foreground content.
We can implement the equalWidth
modifier on View
using this EqualWidthModifier
type:
extension View {
func equalWidth(alignment: Alignment) -> some View {
return self.modifier(EqualWidthModifier(alignment: alignment))
}
}
Next, we implement another ViewModifier
for the host. This modifier puts the known width (if any) in the environment, and updates the known width when SwiftUI computes the final preference value:
struct EqualWidthHost: ViewModifier {
@State var width: CGFloat? = nil
func body(content: Content) -> some View {
return content
.environment(\.equalWidth, width)
.onPreferenceChange(EqualWidthKey.self) { self.width = $0 }
}
}
Now we can implement the equalWidthHost
modifier:
extension View {
func equalWidthHost() -> some View {
return self.modifier(EqualWidthHost())
}
}
And at last we can see the result:

You can find the final playground code in this gist.