如何在SwiftUI中查看另一个视图的大小

58

我正在尝试重新创建Twitter iOS应用程序的一部分,以学习SwiftUI,并想知道如何动态更改一个视图的宽度以使其与另一个视图的宽度相同。在我的情况下,是为了让下划线的宽度与Text视图相同。

我附上了屏幕截图以更好地解释我所指的内容。任何帮助都将不胜感激,谢谢!

此外,这是我到目前为止的代码:

import SwiftUI

struct GridViewHeader : View {

    @State var leftPadding: Length = 0.0
    @State var underLineWidth: Length = 100

    var body: some View {
        return VStack {
            HStack {
                Text("Tweets")
                    .tapAction {
                        self.leftPadding = 0

                }
                Spacer()
                Text("Tweets & Replies")
                    .tapAction {
                        self.leftPadding = 100
                    }
                Spacer()
                Text("Media")
                    .tapAction {
                        self.leftPadding = 200
                }
                Spacer()
                Text("Likes")
            }
            .frame(height: 50)
            .padding(.horizontal, 10)
            HStack {
                Rectangle()
                    .frame(width: self.underLineWidth, height: 2, alignment: .bottom)
                    .padding(.leading, leftPadding)
                    .animation(.basic())
                Spacer()
            }
        }
    }
}


4
这是一个重要的问题。我不知道通常如何在SwiftUI中获取所有元素的大小,例如ScrollView的x和y。:( - Sajad Beheshti
1
认为你可能忽略了 SwiftUI(以及声明式编程作为整体)的一个重要部分。不过我也可能错了。你考虑过将每个TextRectangle都作为自己的“自定义视图”——无论是在代码中还是在布局中——并将下划线作为其中的一部分吗?(1) 设计你的View,使其具有带有下划线的单个文本——即使需要使用ZStack和Rectangle。相信它将不会有填充。(2) 现在,如果需要,将此视图放入带有填充的矩形中。这是一个单一视图。不要担心大小,而要担心层次结构。 - user7014451
2
@Sajad_Behesti,如果你需要调整大小,就去做吧。但是-如果不需要,请让SwiftUI根据设备自行处理。至于滚动视图,请使用List。对于你们两个,我建议观看两个(或更多)WWDC会议-介绍SwiftUI(https://developer.apple.com/videos/play/wwdc2019/204/)和SwiftUI Essentials(https://developer.apple.com/videos/play/wwdc2019/216/)。这是一种与`UIKit`不同的思维方式,但值得花时间去学习。 - user7014451
7个回答

59
我已经写了一份详细的关于如何使用GeometryReader、视图偏好和锚点偏好的说明。下面的代码使用了这些概念。如果想要更深入地了解它们的工作原理,请查看我发布的这篇文章: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/ 下面的解决方案将正确地动画显示下划线:

enter image description here

我很费劲地让它工作,并且我同意你的看法。有时候,你只需要能够在层次结构中向上或向下传递一些框架信息。实际上,WWDC2019第237节(使用SwiftUI构建自定义视图)解释了视图如何持续通信其大小。它基本上说父视图向子视图提出建议,子视图决定如何布局并向父视图返回。他们是怎么做到的?我怀疑anchorPreference与此有关。但是它非常晦涩,尚未完全记录。API已公开,但理解这些长函数原型的方式...那是我现在没有时间的地方。
我认为苹果将其未记录下来是为了迫使我们重新思考整个框架,并忘记“旧”的UIKit习惯,开始声明性地思考。然而,仍然有时需要这样做。您是否曾经想过背景修饰符是如何工作的?我很想看到那个实现。它会解释很多!我希望苹果会在不久的将来记录首选项。我一直在尝试使用自定义PreferenceKey,看起来很有趣。
现在回到您的具体需求,我设法解决了它。您需要两个维度(文本的x位置和宽度)。一个我完全明白,另一个似乎有点黑客。尽管如此,它完美地工作。
我通过创建自定义水平对齐方式来解决文本的x位置。更多信息请参见第237节(在19:00处)。虽然我建议您观看整个过程,但它为布局过程提供了很多启示。
然而,宽度方面,我并不感到自豪... ;-) 它需要DispatchQueue才能避免在显示时更新视图。 更新:我在下面的第二个实现中修复了它

首次实现

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
                Spacer()
                Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
                Spacer()
                Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
                Spacer()
                Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    @Binding var widths: [CGFloat]
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    DispatchQueue.main.async { self.widths[self.idx] = d.width }

                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

更新:更好的实现方式,不使用DispatchQueue

我的第一个解决方案可以工作,但我对将宽度传递给下划线视图的方式并不满意。

我找到了更好的方法来实现相同的效果。事实证明,background修饰符非常强大。它不仅仅是一个可以让您装饰视图背景的修饰符。

基本步骤如下:

  1. 使用Text("text").background(TextGeometry())。TextGeometry是一个自定义视图,其父视图具有与文本视图相同的大小。这就是.background()所做的事情。非常强大。
  2. 在我的实现中,我使用GeometryReader来获取父视图的几何形状,这意味着我获得了文本视图的几何形状,也就是说我现在拥有了宽度。
  3. 现在为了将宽度传回,我使用Preferences。关于它们没有任何文档,但经过一些实验后,我认为preferences就像是“视图属性”一样。我创建了自己的自定义PreferenceKey,称为WidthPreferenceKey,并在TextGeometry中使用它来“附加”宽度到视图上,以便可以在更高层次中读取。
  4. 回到祖先,我使用onPreferenceChange来检测宽度的变化,并相应地设置宽度数组。

这可能听起来过于复杂,但代码最能说明问题。以下是新的实现方式:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    static var defaultValue = CGFloat(0)

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }

    typealias Value = CGFloat
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })

                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct TextGeometry: View {
    var body: some View {
        GeometryReader { geometry in
            return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

很棒的解决方案。我仍然在努力理解alignmentGuide是如何工作的。就像在WWDC2019会议第237场演讲中,在22:50时,我还没有弄清楚底层发生了什么。当我们说viewDimension [.bottom]是一个长度(CGFloat)时,这个长度代表什么,视图的高度吗? - Vishal Singh
1
@Gusutafu 我已经尝试过使用双重自定义对齐方法,但是失败得很惨。如果你成功了,请告诉我。顺便说一下,自那以后,我找到了如何使用锚点首选项的方法,我认为这是正确的方法。我正在撰写一篇文章,并将在完成后在此处发布链接以供参考。 - kontiki
@kontiki你在将视图提取到单独的自定义视图时,是否遇到了PreferenceKey的问题(出现意外结果)?我这里有一个示例:https://gist.github.com/kremizask/f6a27ee9ae3c81f3f63ba9443785d3be - Kremk
@kontiki,我可能要求有点多,但您是否愿意提供最新的SwiftUI解决方案?这是一个我非常想实现的天才解决方案,但似乎在最近的更新中,我一直遇到错误。我已经将Length更改为CGFloat,这个问题得到了解决,但又出现了另一个错误。 - bain
1
嗨 @bain,已将代码更新至最新API。用CGFloat替换了Length。.basic动画改为.linear,.onTapAction改为.tagGesture。 - kontiki
显示剩余8条评论

34

首先,回答标题中的问题,如果您想使一个形状(视图)适应另一个视图的大小,您可以使用.overlay().overlay()从它正在修改的视图中获取其大小。

为了在Twitter重制中设置偏移量和宽度,您可以使用GeometryReaderGeometryReader有能力在另一个坐标空间中找到它的.frame(in:)

您可以使用.coordinateSpace(name:)来识别参考坐标空间。

struct ContentView: View {
    @State private var offset: CGFloat = 0
    @State private var width: CGFloat = 0
    var body: some View {
        HStack {
            Text("Tweets")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
            Text("Tweets & Replies")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
            Text("Media")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
            Text("Likes")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
        }
        .coordinateSpace(name: "container")
        .overlay(underline, alignment: .bottomLeading)
        .animation(.spring())
    }
    var underline: some View {
        Rectangle()
            .frame(height: 2)
            .frame(width: width)
            .padding(.leading, offset)
    }
    struct MoveUnderlineButton: View {
        @Binding var offset: CGFloat
        @Binding var width: CGFloat
        var body: some View {
            GeometryReader { geometry in
                Button(action: {
                    self.offset = geometry.frame(in: .named("container")).minX
                    self.width = geometry.size.width
                }) {
                    Rectangle().foregroundColor(.clear)
                }
            }
        }
    }
}
  1. underline 视图是一个高度为2点的 Rectangle,放置在 HStack 的顶部的 .overlay() 中。
  2. underline 视图对齐于 .bottomLeading,这样我们可以使用 @State 值编程地设置其 .padding(.leading, _)
  3. underline 视图的 .frame(width:) 也是使用 @State 值设置的。
  4. HStack 被设置为 .coordinateSpace(name: "container"),以便我们可以相对于它找到我们按钮的框架。
  5. MoveUnderlineButton 使用 GeometryReader 来查找自己的 widthminX,以便设置 underline 视图的相应值。
  6. MoveUnderlineButton 被设置为包含该按钮文本的 Text 视图的 .overlay(),使其 GeometryReader 从该 Text 视图继承其大小。

Segmented with Underbar in action


3
为启用默认选择,MoveUnderlineButton 应该具有 isActive 属性,并且应该在按钮中添加 .onAppear 代码块:.onAppear { if self.isActive { self.width = geometry.size.width } } - HereTrix

3

试试这个:

import SwiftUI

var titles = ["Tweets", "Tweets & Replies", "Media", "Likes"]

struct GridViewHeader : View {

    @State var selectedItem: String = "Tweets"

    var body: some View {
        HStack(spacing: 20) {
            ForEach(titles.identified(by: \.self)) { title in
                HeaderTabButton(title: title, selectedItem: self.$selectedItem)
                }
                .frame(height: 50)
        }.padding(.horizontal, 10)

    }
}

struct HeaderTabButton : View {
    var title: String

    @Binding var selectedItem: String

    var isSelected: Bool {
        selectedItem == title
    }

    var body: some View {
        VStack {
            Button(action: { self.selectedItem = self.title }) {
                Text(title).fixedSize(horizontal: true, vertical: false)

                Rectangle()
                    .frame(height: 2, alignment: .bottom)
                    .relativeWidth(1)
                    .foregroundColor(isSelected ? Color.accentColor : Color.clear)

            }
        }
    }
}

这是预览效果: 预览屏幕


7
你的解决方案没有动画效果。这是因为你的布局方式是通过创建4个不同的视图来实现下划线。原始问题的提问者希望有一个单一的下划线可以滑动并调整大小到正确的位置。我也发表了我的答案,使用一个单一的视图来绘制下划线,并且相应地移动和调整大小。 - kontiki
2
嗯,他们在问题中没有提到这一点,但我也不使用Twitter,所以如果你使用的话可能很明显! - piebie
我也不是Twitter用户,那只是我的解释。原帖说:动态更改一个视图的宽度。他谈论的是“一个”视图,而不是四个。因此,我的假设是这样的。不过我可能是错的。 - kontiki

3

让我谦虚地建议对这个很亮的答案稍作修改: 不使用偏好设置的版本:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
                Spacer()
                Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
                Spacer()
                Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
                Spacer()
                Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    @Binding var widths: [CGFloat]
    let idx: Int

    func body(content: Content) -> some View {
        var w: CGFloat = 0
        return Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    w = d.width
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }.onAppear(perform: {self.widths[self.idx] = w})

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

使用偏好设置和GeometryReader的版本:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    static var defaultValue = CGFloat(0)

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }

    typealias Value = CGFloat
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0, widthStorage: $w))

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1, widthStorage: $w))

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2, widthStorage: $w))

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3, widthStorage: $w))

                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int
    @Binding var widthStorage: [CGFloat]

    func body(content: Content) -> some View {
        Group {

            if activeIdx == idx {
                content.background(GeometryReader { geometry in
                    return Color.clear.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
                })
                .alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })


            } else {
                content.onTapGesture { self.activeIdx = self.idx }.onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })
            }
        }
    }
}

0

这里有一个超级简单的解决方案,虽然它没有考虑标签被拉伸到全宽 - 但这只是额外计算填充的小问题。

import SwiftUI

struct HorizontalTabs: View {

  private let tabsSpacing = CGFloat(16)

  private func tabWidth(at index: Int) -> CGFloat {
    let label = UILabel()
    label.text = tabs[index]
    let labelWidth = label.intrinsicContentSize.width
    return labelWidth
  }

  private var leadingPadding: CGFloat {
    var padding: CGFloat = 0
    for i in 0..<tabs.count {
      if i < selectedIndex {
        padding += tabWidth(at: i) + tabsSpacing
      }
    }
    return padding
  }

  let tabs: [String]

  @State var selectedIndex: Int = 0

  var body: some View {
    VStack(alignment: .leading) {
      HStack(spacing: tabsSpacing) {
        ForEach(0..<tabs.count, id: \.self) { index in
          Button(action: { self.selectedIndex = index }) {
            Text(self.tabs[index])
          }
        }
      }
      Rectangle()
        .frame(width: tabWidth(at: selectedIndex), height: 3, alignment: .bottomLeading)
        .foregroundColor(.blue)
        .padding(.leading, leadingPadding)
        .animation(Animation.spring())
    }
  }
}

HorizontalTabs(tabs: ["one", "two", "three"]) 会渲染出以下内容:

screenshot


-2

你只需要指定一个具有高度的框架。这里是一个例子:

VStack {
    Text("First Text Label")

    Spacer().frame(height: 50)    // This line

    Text("Second Text Label")
}

-3

这个解决方案非常棒。

但现在它变成了编译错误,已经进行了更正。(Xcode11.1)

这是整个代码。

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat(0)
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}


struct HorizontalTabsView : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })

                }
                .frame(height: 50)
                .padding(.horizontal, 10)

            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.default)
        }
    }
}

struct TextGeometry: View {
    var body: some View {
        GeometryReader { geometry in
            return Rectangle()
                .foregroundColor(.clear)
                .preference(key: WidthPreferenceKey.self, value: geometry.size.width)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

struct HorizontalTabsView_Previews: PreviewProvider {
    static var previews: some View {
        HorizontalTabsView()
    }
}

1
如果他们的代码有错误,请编辑他们的原始答案,而不是创建自己的答案。 - George

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接