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 Texts 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 Texts to the host) and an EnvironmentKey (which we will use to pass the chosen width down from the host to the Texts).
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.