最近时常有朋友反映,虽然 SwiftUI 的布局体系学习门槛很低,但确实正面临要求较高的规划需求时,好像又无从下手。SwiftUI 真的具有创立杂乱用户界面的才能吗?本文将经过用多种手法完成同一需求的方法,展示 SwiftUI 布局体系的强壮与灵敏,并经过这些示例让开发者对 SwiftUI 的布局逻辑有更多的知道和了解。

可在 此处 获取本文代码。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

需求

不久前,在 聊天室 中,有网友提出了这样一个布局需求:

有两个竖向摆放的视图。在初始状况时( show == false ),视图一( 红色视图 )的底部与屏幕底部对齐,当 show == true 时,视图二( 绿色视图 )的底部与屏幕底部对齐。

大致作用如下:

用 SwiftUI 的方式进行布局

解决计划

对于上面的需求,相信不少读者都会在第一时间想出多个解决计划。下文中,咱们将用 SwiftUI 布局体系供给的多种手法来完成该要求。在这些解决计划中,有些非常简单、直接,有些则会略显烦琐,曲折。我尽量让每种计划都采用不同的布局逻辑。

准备作业

咱们首先将一些可复用的代码提取出来,以简化之后的作业:

// 视图一
struct RedView: View {
    var body: some View {
        Rectangle()
            .fill(.red)
            .frame(height: 600)
    }
}
// 视图二
struct GreenView: View {
    var body: some View {
        Rectangle()
            .fill(.green)
            .frame(height: 600)
    }
}
// 状况切换按钮
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)
            }
    }
}
// 获取视图尺度
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))
    }
}

一、Offset

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() 将创立一个与屏幕尺度共同的视图
  • overlay 能够很好的操控主张尺度,一同又可享受到快捷的对齐设置
  • 经过 animation(.default, value: show) 使动画与特定的状况改变相相关

在上面的代码中,考虑到当 show == true 时,视图二( 绿色视图 )的底部必然与屏幕底部对齐,因而,将 overlay 的对齐攻略设置为 bottom ,能够极大地简化咱们的初始布局声明。以此布局为根底,经过 offset ,别离为两种状况进行了位移值描绘。

咱们也能够运用其他的润饰符( 例如:padding、postion )采用该布局思路完成上述需求。

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

虽然在本例中,offset 和 padding 的视觉呈现共同,但当需求与其他视图一同进行布局时,两者之间仍是有很大的不同。padding 是在布局层面进行的调整,增加 padding 后的视图,一同也会对其他视图的布局产生影响。offset 则是在渲染层面进行的方位调整,即使呈现了方位改变,其他视图在布局时,并不会将其位移考虑在其间。有关这方面的内容,请参阅 SwiftUI 布局 —— 尺度( 下 ) 一文中“体面和里子”章节。

用 SwiftUI 的方式进行布局

二、AlignmentGuide

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

在本解决计划中,咱们将两个视图别离置于两个 overlay 层中,虽然在视觉上,两者之间仍呈笔直摆放,但实际上两者之间并无相关。

不管为同一个视图增加多少层 overlay( 或 background ),它们为子视图所供给的主张尺度都是共同的( 与原视图的尺度共同 )。在上面的代码中,因为两个视图运用了相同的动画曲线设定,因而,在移动时并不会呈现分离的状况。但假如为视图别离设定不同的动画曲线( 例如:一个 linear、一个 easeIn ),状况切换时便无法保证视图之间的彻底紧密。

有关主张尺度、需求尺度等内容,请参阅 SwiftUI 布局 —— 尺度( 上 ) 一文

三、NameSpace

从 3.0 版别( iOS 15 )开端,SwiftUI 供给了新的 NameSpace 以及 matchedGeometryEffect 润饰器,让开发者只需少量代码便可完成例如英雄动画这类的杂乱需求。

严厉意义上来说,NameSpace + matchedGeometryEffect 是对一组润饰器以及代码的共同封装。经过命名空间以及 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)
    }
}

在上面的代码中,咱们在第一个 overlay 中绘制了一个与视图二尺度共同的视图( 不显示 ),并将其底边与屏幕底边对齐。经过 matchedGeometryEffect 别离为该站位视图的顶部和底部设置了两个标识符以保存信息。

让视图一、视图二在两个状况下别离运用对应的 ID 方位,即可完成本文需求。

NameSpace + matchedGeometryEffect 是一个十分强壮的组合,尤其拿手面临一同有方位及尺度改变的场景。不过需求留意的是,NameSpace 只适用于在同一棵视图树中共享数据,假如呈现了例如 一段因 @State 注入机制所产生的“灵异代码” 一文中提到了两棵树的状况,则无法完成几许信息的共享。

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

虽然都是笔直构图( axis 为 vertical ),但 ScrollView 与 VStack 在处理各种尺度的逻辑上仍是有非常大的差别。

ScrollView 会运用父视图给定的悉数主张尺度创立翻滚区域,但在询问其子视图的需求尺度时只会供给抱负尺度。这意味着,在 ScrollView 中,子视图最好清晰的设定尺度( 提出清晰地需求尺度 )。因而,在上面的代码中,需求经过屏幕高度和视图一的高度差来核算上方的空白站位视图高度。

经过设定 scrollTo 的 anchor,在合理的要求下,咱们能够让视图停在特定方位。scrollDisabled( 则让咱们能够在 iOS 16+ 中屏蔽 ScrollView 的翻滚手势 )。

五、LayoutPriority

在 SwiftUI 中,设置视图优先级( 运用 layoutPriority )是一个好用但并不常用的功用。SwiftUI 在进行布局时,当布局容器给出的主张尺度无法满意悉数子视图的需求尺度时,会依据子视图的 Priority,优先满意级别较高的视图的布局需求。

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

在上面的代码中,咱们让 overlay 在两种状况时,采取不同的布局攻略策略,并让视图具有不同的优先级状况( 状况切换时 ),以此来获得想要的布局结果。

虽然 Spacer 给定了清晰的尺度,但在状况二时,受限于主张尺度,其并不会参与布局。视图二同理

六、再战 AlignmentGuide

在上面运用 AlignmentGuide 的比如中,咱们经过 GeometryReader 获取了视图二的高度信息,并经过设置显式对齐攻略来完成了移动。从某种逻辑上来说,这种方法与 offset 类似,都需求获取到清晰的位移值才干满意需求。

在本例中,虽然仍运用 AlignmentGuide,但无需获取详细尺度值,便可达成方针。

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

在上面的代码中,咱们利用 overlay 嵌套 + alignmentGuide 的方法完成了将视图一的底边与视图二的顶部对齐绑定。因而,只需求在状况切换时,调整视图二的对齐攻略即可( 视图一将主动跟随视图二移动 )。

此种方法在视觉上与经过 VStack 的完成类似,但两者在需求尺度上有显着不同。VStack 的纵向需求尺度为视图一与视图二的高度和,而经过 overlay 嵌套,纵向需求尺度仅为视图二的高度( 虽然视觉上视图一在视图二的上方且紧密相连 )。

七、Transition

经过为视图设定 Transition( 转场 ),在视图刺进或将其移出视图树时,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) // 不能运用显式动画
    }
}

请留意,转场对动画设定的方位、方法要求很高。稍不留意便会呈现转场彻底失效或部分失效的状况,例如在本例中,假如在 Button 中( 切换 show 状况时 )增加 withAnimation 进行显式动画设定,将导致进入转场失效。

转场是 SwiftUI 供给的强壮才能之一,能够极大地简化动画完成的难度。我写的视图管理器 SwiftUI Overlay Container ,便是树立在对转场功用的充分运用之上。

有关转场动画的更多内容,请参阅 SwiftUI 的动画机制 一文

八、Layout 协议

在 4.0 版别中,SwiftUI 增加了 Layout 协议,经过该协议,开发者能够针对特定的场景,创立自界说布局容器。虽然当时的需求仅有两个视图,但咱们仍然能够从中提炼出场景特性:在笔直摆放的前提下,在特定状况时,指定视图的底部与容器视图的底部对齐。

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

用 SwiftUI 的方式进行布局

总结

同大多的布局结构相同,终究决议布局才能的上限首要取决于开发者。SwiftUI 为咱们供给了很多的布局手法,只有充分地了解并掌握它们,方可从容应对杂乱的布局需求。

期望本文能够对你有所协助。一同也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

订阅下方的 邮件列表,能够及时获得每周的 Tips 汇总。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】