Reputation: 310
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
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:
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.
Upvotes: 4