以swiftui方式布局
#初学者 #ios #swift #swiftui

最近,我的许多朋友一直在说,即使Swiftui的布局系统的学习曲线较低,他们在面对更复杂的设计要求时会感到迷失。 Swiftui真的有能力创建复杂的用户界面吗?本文将通过显示多种实现相同布局的方法来证明Swiftui布局系统的力量和灵活性,并帮助开发人员更好地了解Swiftui的布局逻辑。

您可以获取本文的代码here

要求

不久前,chat room中的网民提出了以下布局要求:

有两个垂直布置的视图。在初始状态(show == false)中,视图1的底部(红色视图)与屏幕底部对齐。当显示== true时,视图2的底部(绿色视图)与屏幕底部对齐。

近似效果如下:

https://cdn.fatbobman.com/layoutInSwiftUIWayDemo_2023-02-28_11.23.58.2023-02-28%2011_24_54.gif

layoutinswiftuiwaydemo

解决方案

对于上述要求,我相信许多读者可以首次提出多个解决方案。在以下文本中,我们将使用SwiftUI布局系统提供的各种手段来达到此要求。这些解决方案中的一些非常简单和直接,而其他解决方案可能有点麻烦或circuit缩。我将尝试为每个解决方案使用不同的布局逻辑。

准备

首先,我们将提取一些可重复使用的代码来简化后续工作:

// View1
struct RedView: View {
    var body: some View {
        Rectangle()
            .fill(.red)
            .frame(height: 600)
    }
}

// View2
struct GreenView: View {
    var body: some View {
        Rectangle()
            .fill(.green)
            .frame(height: 600)
    }
}

// Switch Button
struct OverlayButton: View {
    @Binding var show: Bool
    var body: some View {
        Button(show ? "Hide" : "Show") {
            show.toggle()
        }
        .buttonStyle(.borderedProminent)
    }
}

extension View {
    func overlayButton(show: Binding<Bool>) -> some View {
        self
            .overlay(alignment: .bottom) {
                OverlayButton(show: show)
            }
    }
}

// get size of view
struct SizeInfoModifier: ViewModifier {
    @Binding var size: CGSize
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { proxy in
                    Color.clear
                        .task(id: proxy.size) {
                            size = proxy.size
                        }
                }
            )
    }
}

extension View {
    func sizeInfo(_ size: Binding<CGSize>) -> some View {
        self
            .modifier(SizeInfoModifier(size: size))
    }
}

1.偏移

使用Vstack + Offset是一种非常直观的方法。

struct OffsetDemo: View {
    @State var show = false
    @State var greenSize: CGSize = .zero
    var body: some View {
        Color.clear
            .overlay(alignment: .bottom) {
                VStack(spacing: 0) {
                    RedView()
                    GreenView()
                        .sizeInfo($greenSize)
                }
                .offset(y: show ? 0 : greenSize.height)
                .animation(.default, value: show)
            }
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

代码提示:

  • Color.clear.ignoresSafeArea()将创建与屏幕大小匹配的视图
  • 覆盖层可以轻松控制推荐的尺寸,同时享受方便的对齐设置
  • 使用animation(.default, value: show)将动画与特定状态更改相关联

在上面的代码中,考虑到show == true时,视图2的底部(绿色视图)必须与屏幕底部对齐,因此将“覆盖层的对齐指南”设置为bottom可以极大地简化我们的初始布局声明。基于此布局,描述了两种状态的偏移值。

我们还可以使用其他修饰符(例如填充,位置)使用此布局想法来达到上述要求。

.offset(y: show ? 0 : greenSize.height) // 替换改行为
.padding(.bottom, show ? 0 : -greenSize.height)

尽管在此示例中,偏移和填充的视觉呈现是一致的,但两者之间在列出其他视图方面仍然存在显着差异。填充是在布局级别进行的调整,在视图中添加填充也会影响其他视图的布局。另一方面,偏移是在渲染级别进行的位置调整。即使位置有变化,其他视图在布置时也不会考虑其位移。有关此信息的更多信息,请参阅Swiftui布局中的“表面和内容”部分 - 大小(第2部分)文章。

https://cdn.fatbobman.com/image-20230228134936300.png

填充

2.对齐指导

在SwiftUI中,开发人员可以使用AlignmentGuide修饰符来修改视图的特定对齐指南的值(通过设置显式值)。由于Color.clear.overlay为我们提供了一个相对理想的布局环境,我们可以通过修改不同州的两种观点的对齐指南来满足本文的要求。

struct AlignmentDemo: View {
    @State var show = false
    @State var greenSize: CGSize = .zero
    var body: some View {
        Color.clear
            .overlay(alignment: .bottom) {
                RedView()
                    .alignmentGuide(.bottom) {
                        show ? $0[.bottom] + greenSize.height : $0[.bottom]
                    }
            }
            .overlay(alignment: .bottom) {
                GreenView()
                    .sizeInfo($greenSize)
                    .alignmentGuide(.bottom) {
                        show ? $0[.bottom] : $0[.top]
                    }
            }
            .animation(.default, value: show)
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

在此解决方案中,我们将两个视图放在两个单独的覆盖层中。尽管从视觉上看它们仍然垂直排列,但实际上它们彼此没有相关。

无论将多少个覆盖层或背景层添加到相同的视图中,它们的建议大小保持相同(与原始视图大小一致)。在上面的代码中,由于两种视图都使用相同的动画曲线设置,因此运动过程中不会有分离。但是,如果为每个视图设置了不同的动画曲线(例如,一个线性和一个松动),则在状态过渡期间无法保证视图之间的完整紧密度。

有关建议的大小,所需尺寸和其他相关内容的信息,请参阅文章“ Swiftui布局:大小(第1部分)”。

3.名称空间

从版本3.0开始(iOS 15),SwiftUI提供了一个新的名称空间和MatchEdgeMetryeffect修饰符,该修饰符使开发人员能够获得复杂的要求,例如使用最小代码的英雄动画。

严格来说,命名空间 + matchEdgeMetryFormeft是一组修饰符和代码的统一封装。特定视图的几何信息(位置,大小)通过命名空间和ID保存,并自动设置为具有要求的其他视图。

struct NameSpaceDemo: View {
    @State var show = false
    @Namespace var placeHolder
    @State var greenSize: CGSize = .zero
    @State var redSize: CGSize = .zero
    var body: some View {
        Color.clear
            // green placeholder
            .overlay(alignment: .bottom) {
                Color.clear // GreenView().opacity(0.01)
                    .frame(height: greenSize.height)
                    .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: .bottom, isSource: true)
                    .matchedGeometryEffect(id: "top", in: placeHolder, anchor: .top, isSource: true)
            }
            .overlay(
                GreenView()
                    .sizeInfo($greenSize)
                    .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)
            )
            .overlay(
                RedView()
                    .matchedGeometryEffect(id: "top", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)
            )
            .animation(.default, value: show)
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

在上面的代码中,我们绘制了与第一个覆盖层中的视图相同的视图(未显示),并将其底部边缘与屏幕的底部边缘对齐。我们使用matchedGeometryEffect为占位符视图的顶部和底部设置了两个标识符,以节省信息。

通过使用相应的ID位置作为查看一个并在两个状态中查看两个位置,我们可以在本文中达到要求。

命名空间 + matchEdgeometryeffect是一种非常强大的组合,尤其擅长处理同时位置和大小同时改变的方案。但是,应该注意的是,名称空间仅适用于在同一视图树中共享数据。如果涉及两棵树,例如@state注入机制引起的“超自然代码”中提到的情况,则无法共享几何信息。

4. scrollview

考虑本文所需的动画表格(垂直滚动),我们还可以使用ScrollViewReader提供的滚动定位功能来满足要求。

struct ScrollViewDemo: View {
    @State var show = false
    @State var screenSize: CGSize = .zero
    @State var redViewSize: CGSize = .zero
    var body: some View {
        Color.clear
            .overlay(
                ScrollViewReader { proxy in
                    ScrollView {
                        VStack(spacing: 0) {
                            Color.clear
                                .frame(height: screenSize.height - redViewSize.height)
                            RedView()
                                .sizeInfo($redViewSize)
                                .id("red")
                            GreenView()
                                .id("green")
                        }
                    }
                    .scrollDisabled(true)
                    .onAppear {
                        proxy.scrollTo("red", anchor: .bottom)
                    }
                    .onChange(of: show) { _ in
                        withAnimation {
                            if show {
                                proxy.scrollTo("green", anchor: .bottom)
                            } else {
                                proxy.scrollTo("red", anchor: .bottom)
                            }
                        }
                    }
                }
            )
            .sizeInfo($screenSize)
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

尽管两者都使用垂直轴,但在滚动浏览和VSTACK之间处理各种尺寸的逻辑仍然存在显着差异。

ScrollView将根据父视图给出的整个建议大小创建一个滚动区域,但仅在要求其子视图的所需尺寸时提供理想的尺寸。这意味着在ScrollView中,最适合该子视图明确设置大小(明确要求所需尺寸的请求)。因此,在上面的代码中,需要通过屏幕高度和视图高度之间的差异来计算顶部空白占位符视图的高度。

通过设置scrollTo的锚,我们可以在合理的要求下以特定位置停止视图。 scrollDisabled允许我们在iOS 16+中禁用Scrollview的滚动手势。

5. layoutpriority

在Swiftui中,设置视图优先级(使用layoutPriority)是一个有用但不经常使用的功能。当Swiftui列出视图时,如果布局容器所建议的大小无法满足所有子视图所需的大小,它将根据子视图的优先级优先考虑具有更高优先级的视图的布局要求。


struct LayoutPriorityDemo: View {
    @State var show = false
    @State var screenSize: CGSize = .zero
    @State var redViewSize: CGSize = .zero
    var body: some View {
        Color.clear
            .overlay(alignment: show ? .bottom : .top) {
                VStack(spacing: 0) {
                    Spacer()
                        .frame(height: screenSize.height - redViewSize.height)
                        .layoutPriority(show ? 0 : 2)
                    RedView()
                        .sizeInfo($redViewSize)
                        .layoutPriority(show ? 1 : 2)
                    GreenView().layoutPriority(show ? 2 : 0)
                }
                .animation(.default, value: show)
            }
            .sizeInfo($screenSize)
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

在上面的代码中,我们使用覆盖层在两个不同的状态下采用不同的布局指南策略,并在国家过渡期间提供不同的优先级状态以实现所需的布局结果。

尽管间隔器具有特定的大小,但在状态二中,由于建议的大小约束,因此没有参与布局。同样适用于查看两个。

6.对AnignmentGuide进行了重新审视

在使用上面的AlignmentGuide的示例中,我们通过几何学阅读器获得了视图的高度信息,并通过设置显式比对指南完成了运动。从某种意义上说,这种方法类似于偏移,因为它需要获得特定的位移值才能满足需求。

在此示例中,尽管仍使用ArignmentGuide,但不需要获得特定的尺寸值即可实现目标。

struct AlignmentWithoutGeometryReader: View {
    @State var show = false
    var body: some View {
        Color.clear
            .overlay(alignment: .bottom) {
                GreenView()
                    .alignmentGuide(.bottom) {
                        show ? $0[.bottom] : 0
                    }
                    .overlay(alignment: .top) {
                        RedView()
                            .alignmentGuide(.top) { $0[.bottom] }
                    }
                    .animation(.default, value: show)
            }
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

在上面的代码中,我们使用嵌套的叠加层来实现视图底部和视图顶部之间的对齐绑定。切换(视图1将自动使用视图2移动)。

此方法在视觉上与通过VSTACK实现相似,但是两者在需求大小上有显着差异。 VSTACK的垂直需求大小是视图1和View 2的高度的总和,而通过叠加嵌套,垂直需求大小只是视图2的高度(尽管视觉视图1在上方并紧密连接到视图2)。

7.过渡

通过设置视图的过渡,当视图从视图树插入或删除时,SwiftUi将生成相应的动画效果。

struct TransitionDemo:View {
    @State var show = false
    var body: some View {
        Color.clear
            .overlay(alignment:.bottom){
                VStack(spacing:0) {
                    RedView()
                    if show {
                        GreenView()
                            .transition(.move(edge: .bottom))
                    }
                }
                .animation(.default, value: show)
            }
            .ignoresSafeArea()
            .overlayButton(show: $show) // 不能使用显式动画
    }
}

请注意,过渡对动画设置的位置和方法有很高的要求。粗心的错误可能会导致过渡的完全或部分故障。例如,在这种情况下,添加withAnimation以明确设置按钮中的动画(切换显示状态时)将导致过渡失败。

过渡是Swiftui提供的强大功能之一,它极大地简化了实施动画的困难。我写的视图管理器SwiftUI Overlay Container是建立在过渡功能的完整应用的基础上的。

有关过渡动画的更多信息,请参见有关Swiftui动画机制的文章。

8.布局协议

在版本4.0中,SwiftUi添加了布局协议,该协议允许开发人员为特定方案创建自定义布局容器。尽管当前的要求仅涉及两种视图,但我们仍然可以提取场景特征:在垂直安排的前提下,将视图底部的对齐方式与特定状态的容器视图的底部进行对齐。

struct LayoutProtocolDemo: View {
    @State var show = false
    var body: some View {
        Color.clear
            .overlay(
                AlignmentBottomLayout {
                    RedView()
                        .alignmentActive(show ? false : true) // 设定当前的活动视图
                    GreenView()
                        .alignmentActive(show ? true : false)
                }
                .animation(.default, value: show)
            )
            .ignoresSafeArea()
            .overlayButton(show: $show)
    }
}

struct ActiveKey: LayoutValueKey {
    static var defaultValue = false
}

extension View {
    func alignmentActive(_ isActive: Bool) -> some View {
        layoutValue(key: ActiveKey.self, value: isActive)
    }
}

struct AlignmentBottomLayout: Layout {
    func makeCache(subviews: Subviews) -> Catch {
        .init()
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Catch) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
        var height: CGFloat = .zero
        for i in subviews.indices {
            let subview = subviews[i]
            if subview[ActiveKey.self] == true { // 获取活动视图
                cache.activeIndex = i
            }
            let viewDimension = subview.dimensions(in: proposal)
            height += viewDimension.height
            cache.sizes.append(.init(width: viewDimension.width, height: viewDimension.height))
        }
        return .init(width: proposal.replacingUnspecifiedDimensions().width, height: proposal.replacingUnspecifiedDimensions().height)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Catch) {
        guard !subviews.isEmpty else { return }
        var currentY: CGFloat = bounds.height - cache.alignmentHeight + bounds.minY // 初始 y 位置
        for i in subviews.indices {
            let subview = subviews[i]
            subview.place(at: .init(x: bounds.minX, y: currentY), anchor: .topLeading, proposal: proposal)
            currentY += cache.sizes[i].height
        }
    }
}

struct Catch {
    var activeIndex = 0
    var sizes: [CGSize] = []

    var alignmentHeight: CGFloat {
        guard !sizes.isEmpty else { return .zero }
        return sizes[0...activeIndex].map { $0.height }.reduce(0,+)
    }
}

在上面的代码中,我们指出了当前通过alignmentActive(LayoutValuekey)对齐的视图。

毫无疑问,这是所有解决方案中最复杂的实现。但是,如果我们有类似的要求,使用此自定义容器将非常方便。

struct LayoutProtocolExample: View {
    let views = (0..<8).map { _ in CGFloat.random(in: 100...150) }
    @State var index = 0
    var body: some View {
        VStack {
            Picker("", selection: $index) {
                ForEach(views.indices, id: \.self) { i in
                    Text("\(i)").tag(i)
                }
            }
            .pickerStyle(.segmented)
            .zIndex(2)
            AlignmentBottomLayout {
                ForEach(views.indices, id: \.self) { i in
                    RoundedRectangle(cornerRadius: 20)
                        .fill(.orange.gradient)
                        .overlay(Text("\(i)").font(.title))
                        .padding([.horizontal, .top], 10)
                        .frame(height: views[i])
                        .alignmentActive(index == i ? true : false)
                }
            }
            .animation(.default, value: index)
            .frame(width: 300, height: 400)
            .clipped()
            .border(.blue)
        }
        .padding(20)
    }
}

https://cdn.fatbobman.com/layoutProtocol_2023-02-28_16.24.29.2023-02-28%2016_25_19.gif

自定义布局容器

概括

像大多数布局框架一样,布局能力的上限最终取决于开发人员。 Swiftui为我们提供了许多布局方法。只有通过充分理解和掌握它们,我们才能应对复杂的布局要求。

Buy Me a Coffee

Donate with PAYPAL

我希望本文对您有帮助。也欢迎您通过TwitterDiscord channelmy blog的留言板与我交流。