SwiftUI - UI 元素的大小

SwiftUI - Size of an UI element

如何在渲染后获取 UI 元素的大小 (width/height) 并将其传递回父级以重新渲染?

示例: 父视图 (ChatMessage) 包含一个 RoundedRectangle,其上放置了来自子视图 (ChatMessageContent) 的文本 - 聊天气泡样式。
问题是在渲染父级时我不知道文本的大小,因为文本可能有 5、6 等行,具体取决于消息文本的长度。

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}
        }
    }
}

在提供的示例中,messageHeight 保持在 28,并且不会在父项上进行调整。我希望 messageHeight 更改为 Text 元素的实际高度,具体取决于它显示的文本行数。
例如。两行 -> messageHeight = 42,三行 -> messageHeight = 56。

如何获得 UI 元素(在本例中为文本)的实际大小,因为 GeometryReader 似乎无法解决问题?它还读取 geometry.size.height = 28(从父视图传递)。

首先,值得理解的是,在 Text 后面填充 RoundedRectangle 的情况下,您不需要测量文本或将大小发送到视图层次结构中。您可以将其配置为选择完全适合其内容的高度。然后使用 .background 修饰符添加 RoundedRectangle。示例:

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)
)

结果:

好的,但有时您确实需要测量视图并将其大小传递到层次结构中。在 SwiftUI 中,视图可以在称为“首选项”的东西中向上发送信息。 Apple 尚未彻底记录偏好系统,但有些人已经弄明白了。特别是 kontiki has described it starting with this article at swiftui-lab。 (swiftui-lab 的每篇文章都很棒。)

那么让我们举一个例子,我们确实需要使用首选项。 ConversationView 显示邮件列表,每封邮件都标有发件人:

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)
)

看起来像这样:

我们真的很想让这些消息气泡的左边缘对齐。这意味着我们需要使发送方 Text 具有相同的宽度。我们将通过使用新修饰符 .equalWidth() 扩展 View 来实现。我们将像这样将修饰符应用于发件人 Text

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))
        }
    }
}

ConversationView 之后,我们将使用另一个新修饰符 .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
    }
}

在我们可以实现这些修饰符之前,我们需要定义一个 PreferenceKey (which we will use to pass the widths up the view hierarchy from the Texts to the host) and an EnvironmentKey(我们将使用它来将选定的宽度从主机向下传递到 Texts)。

通过为首选项定义 defaultValue 以及组合两个值的方法,类型符合 PreferenceKey。这是我们的:

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)
        }
    }
}

一个类型通过定义一个defaultValue来符合EnvironmentKey。由于 EqualWidthKey 已经这样做了,我们可以将 PreferenceKey 重用为 EnvironmentKey:

extension EqualWidthKey: EnvironmentKey { }

我们还需要添加访问器 EnvironmentValues:

extension EnvironmentValues {
    var equalWidth: CGFloat? {
        get { self[EqualWidthKey.self] }
        set { self[EqualWidthKey.self] = newValue }
    }
}

现在我们可以实现 ViewModifier 将首选项设置为其内容的宽度,并将环境宽度应用于其内容:

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)
    }
}

默认情况下,GeometryReader 会填充其父项给它的 space 数量。这不是我们想要衡量的,所以我们将 GeometryReader 放在 background 修饰符中,因为背景视图始终是其前景内容的大小。

我们可以使用 EqualWidthModifier 类型在 View 上实现 equalWidth 修饰符:

extension View {
    func equalWidth(alignment: Alignment) -> some View {
        return self.modifier(EqualWidthModifier(alignment: alignment))
    }
}

接下来,我们为主机实现另一个ViewModifier。此修饰符将已知宽度(如果有)放入环境中,并在 SwiftUI 计算最终偏好值时更新已知宽度:

struct EqualWidthHost: ViewModifier {
    @State var width: CGFloat? = nil

    func body(content: Content) -> some View {
        return content
            .environment(\.equalWidth, width)
            .onPreferenceChange(EqualWidthKey.self) { self.width = [=19=] }
    }
}

现在我们可以实现 equalWidthHost 修饰符:

extension View {
    func equalWidthHost() -> some View {
        return self.modifier(EqualWidthHost())
    }
}

最后我们可以看到结果:

您可以找到最终的 playground 代码 in this gist