Peanutsmasher
Peanutsmasher

Reputation: 310

SwiftUI - Size of an UI element

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?

Example: The parent view (ChatMessage) contains a RoundedRectangle on which a Text is placed from the child view (ChatMessageContent) - chat bubble style.
The issue is that I do not know the size of the Text when rendering the parent as the Text might have 5, 6, etc. lines depending on the length of the message text.

struct ChatMessage: View {

    @State var message: Message
    @State var messageHeight: CGFloat = 28

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 8)
                .fill(Color.red).opacity(0.9)
                .frame(width: 480, height: self.messageHeight)
            ChatMessageContent(message: self.$message, messageHeight: self.$messageHeight)
                .frame(width: 480, height: self.messageHeight)
        }
    }
}

struct ChatMessageContent: View {

    @Binding var message: Message
    @Binding var messageHeight: CGFloat

    var body: some View {
        GeometryReader { geometry in 
            Text(self.message.message)
                .lineLimit(nil)
                .multilineTextAlignment(.center)
                .onAppear {self.messageHeight = geometry.size.height; print(geometry.size.height}
        }
    }
}

In the provided example, the messageHeight stays at 28 and does not get adjusted on the parent. I'd want the messageHeight to change to the actual height of the Text element depending on how many lines of text it displays.
E.g. two lines -> messageHeight = 42, three lines -> messageHeight = 56.

How do I get the actual size of an UI Element (in this case a Text), as GeometryReader does not appear to do the trick? It also reads geometry.size.height = 28 (that gets passed from the parent view).

Upvotes: 2

Views: 2311

Answers (1)

rob mayoff
rob mayoff

Reputation: 385980

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:

text with fitted rounded rectangle background

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:

ConversationView showing two messages, but the senders are different lengths so the message bubbles are not aligned

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:

conversation view with message bubbles aligned

You can find the final playground code in this gist.

Upvotes: 4

Related Questions