在 上篇 中,咱们对 SwiftUI 布局过程中涉及的很多尺度概念进行了阐明。本篇中,咱们将经过对视图润饰器 frame 和 offset 的拷贝进一步加深对 SwiftUI 布局机制的理解,并经过一些示例展示在布局时需求留意的问题。

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

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

相同的长相、不同的内涵

在 SwiftUI 中,咱们可以运用不同的布局容器生成看起来几乎相同的显现成果。例如,不管是 ZStack、overlay、background、VStack、HStack 都可以完成下图的版式。

SwiftUI 布局 —— 尺寸( 下 )

以 ZStack、overlay、background 举例:

struct HeartView: View {
    var body: some View {
        Circle()
            .fill(.yellow)
            .frame(width: 30, height: 30)
            .overlay(Image(systemName: "heart").foregroundColor(.red))
    }
}
struct ButtonView: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(Color.blue.gradient)
            .frame(width: 150, height: 50)
    }
}
// ZStack
struct IconDemo1: View {
    var body: some View {
        ZStack(alignment: .topTrailing) {
            ButtonView()
            HeartView()
                .alignmentGuide(.top, computeValue: { $0.height / 2 })
                .alignmentGuide(.trailing, computeValue: { $0.width / 2 })
        }
    }
}
// overlay
struct IconDemo2: View {
    var body: some View {
        ButtonView()
            .overlay(alignment: .topTrailing) {
                HeartView()
                    .alignmentGuide(.top, computeValue: { $0.height / 2 })
                    .alignmentGuide(.trailing, computeValue: { $0.width / 2 })
            }
    }
}
// background
struct IconDemo3: View {
    var body: some View {
            HeartView()
            .background(alignment:.center){
                ButtonView()
                    .alignmentGuide(HorizontalAlignment.center, computeValue: {$0[.trailing]})
                    .alignmentGuide(VerticalAlignment.center, computeValue: {$0[.top]})
            }
    }
}

尽管 IconDemo1、IconDemo2、IconDemo3 在单独预览时看起来彻底相同,但假如将它们放置到其他的布局容器中,你会发现它们在容器内的布局后的摆放成果显着不同 —— 需求尺度的构成和巨细不相同( 下图中,用红框标注了各自的需求尺度 )。

SwiftUI 布局 —— 尺寸( 下 )

布局容器在规划本身的需求尺度上的战略不同是造成上述现象的原因。

像 ZStack、VStack、HStack 这几个容器,它们的需求尺度是由其悉数子视图依照指定的布局攻略进行摆放后的获得的总尺度所构成的。而 overlay 和 background 的需求尺度则彻底取决于它们的主视图( 本例中,overlay 的需求尺度由 ButtonView 决议,background 的需求尺度由 HeartView 决议 )。假定当时的规划需求是想将 ButtonView 和 HeartView 视作一个全体进行布局,那么 ZStack 是一个不错的选择。

每种容器都有其合适的场景,例如关于如下需求 —— 创建相似视频 app 中的点赞功用的子视图( 在布局时,仅需考虑手势图标的方位和尺度),overlay 这种需求尺度仅依赖于主视图的容器便有了用武之地:

struct FavoriteDemo: View {
    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            Rectangle()
                .fill(Color.cyan.gradient.opacity(0.5))
            Favorite()
                .alignmentGuide(.bottom, computeValue: { $0[.bottom] + 200 })
                .alignmentGuide(.trailing, computeValue: { $0[.trailing] + 100 })
        }
        .ignoresSafeArea()
    }
}
struct Favorite: View {
    @State var hearts = [(String, CGFloat, CGFloat)]()
    var body: some View {
        Image(systemName: "hand.thumbsup")
            .symbolVariant(.fill)
            .foregroundColor(.blue)
            .font(.title)
            .overlay(alignment: .bottom) {
                ZStack {
                    Color.clear
                    ForEach(hearts, id: \.0) { heart in
                        Text("+1")
                            .font(.title)
                            .foregroundColor(.white)
                            .bold()
                            .transition(.asymmetric(insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity)))
                            .offset(x: heart.1, y: heart.2)
                            .task {
                                try? await Task.sleep(nanoseconds: 500000000)
                                if let index = hearts.firstIndex(where: { $0.0 == heart.0 }) {
                                    let _ = withAnimation(.easeIn) {
                                        hearts.remove(at: index)
                                    }
                                }
                            }
                    }
                }
                .frame(width: 50, height: 100)
                .allowsHitTesting(false)
            }
            .onTapGesture {
                withAnimation(.easeOut) {
                    hearts.append((UUID().uuidString, .random(in: -10...10), .random(in: -10...10)))
                }
            }
    }
}

SwiftUI 布局 —— 尺寸( 下 )

相同长相的视图,未必有相同的内涵。当用布局容器创建组成视图时,必须将构成后的组成视图对父容器的布局影响考虑到其中。针对不同的需求,选择恰当的容器。

体面和里子

与 UIKit 和 AppKit 相似,SwiftUI 的布局操作是在视图层面( 里子 )进行的,而一切针对相关图层( backing layer )的操作仍是经过 Core Animation 来完成的。因此,针对 CALayer( 体面 )直接做出的调整,SwiftUI 的布局系统是无法感知的。

而这种在布局之后、渲染之前对内容进行调整的操作,很多存在于 SwiftUI 之中,例如:offset、scaleEffect、rotationEffect、shadow、background、cornerRadius 等操作都是在此阶段进行的。

例如:

struct OffsetDemo1:View{
    var body: some View{
        HStack{
            Rectangle()
                .fill(.orange.gradient)
                .frame(maxWidth:.infinity)
            Rectangle()
                .fill(.green.gradient)
                .frame(maxWidth:.infinity)
            Rectangle()
                .fill(.cyan.gradient)
                .frame(maxWidth:.infinity)
        }
        .border(.red)
    }
}

SwiftUI 布局 —— 尺寸( 下 )

咱们运用 offset 调整中间矩形的方位,并不会对 HStack 的尺度造成任何影响,在此种情况下,体面和里子是脱节的:

Rectangle()
    .fill(.green.gradient)
    .frame(width: 100, height: 50)
    .border(.blue)
    .offset(x: 30, y: 30)
    .border(.green)

SwiftUI 布局 —— 尺寸( 下 )

在 SwiftUI 中,offset 润饰符对应的是 Core Animation 中的 CGAffineTransform 操作。.offset(x: 30, y: 30) 相当于 .transformEffect(.init(translationX: 30, y: 30))。这种直接在 CALayer 层面进行的修改,并不会对布局造成影响

上面或许便是你想要的效果,但假如想完成让位移后的视图可以对它的父视图( 容器 )的布局有所影响 ,或许就需求换一种方式 —— 用布局容器而非 Core Animtion 操作:

// 经过 padding
Rectangle()
    .fill(.green.gradient)
    .frame(width: 100, height: 50)
    .border(.blue)
    .padding(EdgeInsets(top: 30, leading: 30, bottom: 0, trailing: 0))
    .border(.green)

SwiftUI 布局 —— 尺寸( 下 )

或许:

// 经过 frame
Rectangle()
    .fill(.green.gradient)
    .frame(width: 100, height: 50)
    .border(.blue)
    .frame(width: 130, height: 80, alignment: .bottomTrailing)
    .border(.green)
// 经过 position
Rectangle()
    .fill(.green.gradient)
    .frame(width: 100, height: 50)
    .border(.blue)
    .position(x: 80, y: 55)
    .frame(width: 130, height: 80)
    .border(.green)

相较于 offset 视图润饰器,由于没有现成的可替换手法,想让 rotationEffect 修改后的成果反过来影响布局则要略显烦琐:

struct RotationDemo: View {
    var body: some View {
        HStack(alignment: .center) {
            Text("HI")
                .border(.red)
            Text("Hello world")
                .fixedSize()
                .border(.yellow)
                .rotationEffect(.degrees(-40))
                .border(.red)
        }
        .border(.blue)
    }
}

SwiftUI 布局 —— 尺寸( 下 )

extension View {
    func rotationEffectWithFrame(_ angle: Angle) -> some View {
        modifier(RotationEffectWithFrameModifier(angle: angle))
    }
}
struct RotationEffectWithFrameModifier: ViewModifier {
    let angle: Angle
    @State private var size: CGSize = .zero
    var bounds: CGRect {
        CGRect(origin: .zero, size: size)
            .offsetBy(dx: -size.width / 2, dy: -size.height / 2)
            .applying(.init(rotationAngle: CGFloat(angle.radians)))
    }
    func body(content: Content) -> some View {
        content
            .rotationEffect(angle)
            .background(
                GeometryReader { proxy in
                    Color.clear
                        .task(id: proxy.frame(in: .local)) {
                            size = proxy.size
                        }
                }
            )
            .frame(width: bounds.width, height: bounds.height)
    }
}
truct RotationDemo: View {
    var body: some View {
        HStack(alignment: .center) {
            Text("HI")
                .border(.red)
            Text("Hello world")
                .fixedSize()
                .border(.yellow)
                .rotationEffectWithFrame(.degrees(-40))
                .border(.red)
        }
        .border(.blue)
    }
}

SwiftUI 布局 —— 尺寸( 下 )

scaleEffect 也可以用相似的方式完成以影响原有的布局

在 SwiftUI 中,开发者在对视图进行调整前需求清楚该操作是针对里子( 根据布局机制 )仍是体面( 在 CALayer 层面),或许是想经过对体面的修改进而影响里子,只有这样,才能让终究的出现效果与预期的布局一致。

从模仿中学习

本章中,咱们将经过运用 Layout 协议完成对 frame 和 offset 的拷贝以加深对布局过程中的不同尺度概念的知道。

有关 frame、offset 的布局逻辑在上篇中已有描绘,本文仅对要害代码进行阐明。可在 此处获取 本文的拷贝代码

frame

SwiftUI 中有两个版本的 frame,本节咱们将拷贝 frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center)

frame 视图润饰器本质上是对布局容器 _FrameLayout 的包装,本例中咱们将自界说的布局容器命名为 MyFrameLayout ,视图润饰器命名为 myFrame 。

用 viewModifier 包装布局容器

在 SwiftUI 中,通常需求对布局容器进行二次包装后再运用。例如 _VStackLayout 被包装成 VStack,_FrameLayout 被包装成 frame 视图润饰器。

这种包装行为的作用为( 以 MyFrameLayout 举例 ):

  • 简化代码

    改进由 Layout 协议的 callAsFunction 所带来的多括号问题

  • 预处理子视图

    在 SwiftUI 布局 —— 对齐 一文中咱们已经介绍了“对齐”是发生在容器中子视图之间的行为,因此关于 _FrameLayout 这种开发者只供给一个子视图同时又需求对齐的布局容器,咱们需求经过在 modifier 中添加一个 Color.clear 视图来处理对齐目标不足的问题

private struct MyFrameLayout: Layout, ViewModifier {
    let width: CGFloat?
    let height: CGFloat?
    let alignment: Alignment
    func body(content: Content) -> some View {
        MyFrameLayout(width: width, height: height, alignment: alignment)() { // 由于 callAsFunction 所导致的多括号
            Color.clear // 添加用于辅助对齐的视图
            content
        }
    }
}
public extension View {
    func myFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View {
        self
            .modifier(MyFrameLayout(width: width, height: height, alignment: alignment))
    }
    @available(*, deprecated, message: "Please pass one or more parameters.")
    func myFrame() -> some View {
        modifier(MyFrameLayout(width: nil, height: nil, alignment: .center))
    }
}

frame(width:,height:) 的完成

这一版本的 frame 有如下功用:

  • 当两个维度都设置了详细值时,将运用这两个值作为 _FrameLayout 容器的需求尺度,以及子视图的布局尺度
  • 当只有一个维度设置了详细值 A,则将该值 A 作为 _FrameLayout 容器在该维度上的需求尺度,另一维度的需求尺度则运用子视图在该维度上的需求尺度( 以 A 及 _FrameLayout 获得的主张尺度作为子视图的主张尺度 )
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    guard subviews.count == 2, let content = subviews.last else { fatalError("Can't use MyFrameLayout directly") }
    var result: CGSize = .zero
    if let width, let height { // 两个维度都有设定
        result = .init(width: width, height: height)
    }
    if let width, height == nil {  // 仅宽度有设定
        let contentHeight = content.sizeThatFits(.init(width: width, height: proposal.height)).height // 子视图在该维度上的需求尺度
        result = .init(width: width, height: contentHeight)
    }
    if let height, width == nil {
        let contentWidth = content.sizeThatFits(.init(width: proposal.width, height: height)).width
        result = .init(width: contentWidth, height: height)
    }
    if height == nil, width == nil {
        result = content.sizeThatFits(proposal)
    }
    return result
}

在 placeSubviews 中,咱们将运用 modifier 中添加的辅助视图,对子视图进行对齐摆放。

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    guard subviews.count == 2, let background = subviews.first, let content = subviews.last else {
        fatalError("Can't use MyFrameLayout directly")
    }
    // 在 bounds 中满铺 Color.clear
    background.place(at: .zero, anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
    // 获取 Color.clear 对齐攻略的方位
    let backgroundDimensions = background.dimensions(in: .init(width: bounds.width, height: bounds.height))
    let offsetX = backgroundDimensions[alignment.horizontal]
    let offsetY = backgroundDimensions[alignment.vertical]
    // 获取子视图对齐攻略的方位
    let contentDimensions = content.dimensions(in: .init(width: bounds.width, height: bounds.height))
    // 计算 content 的 topLeading 偏移量
    let leading = offsetX - contentDimensions[alignment.horizontal] + bounds.minX
    let top = offsetY - contentDimensions[alignment.vertical] + bounds.minY
    content.place(at: .init(x: leading, y: top), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
}

现在咱们已经可以在视图中运用 myFrame 替代 frame ,并完成彻底相同的效果。

fixedSize

fixedSize 为子视图的特定维度供给未指定形式( nil )的主张尺度,以使其在该维度大将抱负尺度作为其需求尺度回来,并以该尺度作为本身的需求尺度回来给父视图。

private struct MyFixedSizeLayout: Layout, ViewModifier {
    let horizontal: Bool
    let vertical: Bool
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard subviews.count == 1, let content = subviews.first else {
            fatalError("Can't use MyFixedSizeLayout directly")
        }
        // 准备提交给子视图的主张尺度
        let width = horizontal ? nil : proposal.width // 假如 horizontal 为 true 则提交非指定形式的主张尺度,否则则供给父视图在改维度上的主张尺度
        let height = vertical ? nil : proposal.height // 假如 vertical 为 true 则提交非指定形式的主张尺度,否则则供给父视图在改维度上的主张尺度
        let size = content.sizeThatFits(.init(width: width, height: height)) // 向子视图提交上方确认的主张尺度,并获取子视图的需求尺度
        return size // 以子视图的需求尺度作为 MyFixedSizeLayout 容器的需求尺度
    }
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard subviews.count == 1, let content = subviews.first else {
            fatalError("Can't use MyFixedSizeLayout directly")
        }
        content.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
    }
    func body(content: Content) -> some View {
        MyFixedSizeLayout(horizontal: horizontal, vertical: vertical)() {
            content
        }
    }
}
public extension View {
    func myFixedSize(horizontal: Bool, vertical: Bool) -> some View {
        modifier(MyFixedSizeLayout(horizontal: horizontal, vertical: vertical))
    }
    func myFixedSize() -> some View {
        myFixedSize(horizontal: true, vertical: true)
    }
}

又见 frame

鉴于两个版本的 frame 不管在功用上仍是完成上均有巨大的不同,因此在 SwiftUI 中它们分别对应着不同的布局容器。 frame(minWidth:, idealWidth: , maxWidth: , minHeight: , idealHeight:, maxHeight: , alignment:) 是对布局容器 _FlexFrameLayout 的二次包装。

_FlexFrameLayout 实际上是两个功用的结合体:

  • 在设置了 ideal 值且父视图的在该维度上供给了未指定形式的主张尺度时,以 ideal value 作为需求尺度回来,并将其作为子视图的布局尺度
  • 当 min 或( 和 ) max 有值时,会按如下规则回来 _FlexFrameLayout 的在该维度上的需求尺度( 下图来自于 SwiftUI-Lab )

SwiftUI 布局 —— 尺寸( 下 )

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    guard subviews.count == 2, let content = subviews.last else { fatalError("Can't use MyFlexFrameLayout directly") }
    var resultWidth: CGFloat = 0
    var resultHeight: CGFloat = 0
    let contentWidth = content.sizeThatFits(proposal).width // 以父视图的主张尺度为主张尺度,获取子视图在宽度上的需求尺度
    // idealWidth 有值,且父视图在宽度上的主张尺度为未指定形式,需求宽度为 idealWidth
    if let idealWidth, proposal.width == nil {
        resultWidth = idealWidth
    } else if minWidth == nil, maxWidth == nil { // min 和 max 均没有指定,回来子视图在宽度上的需求尺度
        resultWidth = contentWidth
    } else if let minWidth, let maxWidth { // min 和 max 都有值时
            resultWidth = clamp(min: minWidth, max: maxWidth, source: proposal.width ?? contentWidth)
    } else if let minWidth { // min 有值时,确保需求尺度不小于最小值
        resultWidth = clamp(min: minWidth, max: maxWidth, source: contentWidth)
    } else if let maxWidth { // max 有值时,确保需求尺度不大于最大值
        resultWidth = clamp(min: minWidth, max: maxWidth, source: proposal.width ?? contentWidth)
    }
    // 将上面确认的需求宽度作为主张宽度,获取子视图的需求高度
    let contentHeight = content.sizeThatFits(.init(width: proposal.width == nil ? nil : resultWidth, height: proposal.height)).height
    if let idealHeight, proposal.height == nil {
        resultHeight = idealHeight
    } else if minHeight == nil, maxHeight == nil {
        resultHeight = contentHeight
    } else if let minHeight, let maxHeight {
            resultHeight = clamp(min: minHeight, max: maxHeight, source: proposal.height ?? contentHeight)
    } else if let minHeight {
        resultHeight = clamp(min: minHeight, max: maxHeight, source: contentHeight)
    } else if let maxHeight {
        resultHeight = clamp(min: minHeight, max: maxHeight, source: proposal.height ?? contentHeight)
    }
    let size = CGSize(width: resultWidth, height: resultHeight)
    return size
}
// 将值约束在最小和最大之间
func clamp(min: CGFloat?, max: CGFloat?, source: CGFloat) -> CGFloat {
    var result: CGFloat = source
    if let min {
        result = Swift.max(source, min)
    }
    if let max {
        result = Swift.min(source, max)
    }
    return result
}

在 View 扩展中需求判别 min、ideal、max 的值是否满足了升序要求:

public extension View {
    func myFrame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View {
        // 判别是否 min < ideal < max
        func areInNondecreasingOrder(
            _ min: CGFloat?, _ ideal: CGFloat?, _ max: CGFloat?
        ) -> Bool {
            let min = min ?? -.infinity
            let ideal = ideal ?? min
            let max = max ?? ideal
            return min <= ideal && ideal <= max
        }
        // SwiftUI 官方完成在数值过错的情况下仍会执行,但会在控制台显现过错信息。
        if !areInNondecreasingOrder(minWidth, idealWidth, maxWidth)
            || !areInNondecreasingOrder(minHeight, idealHeight, maxHeight) {
            fatalError("Contradictory frame constraints specified.")
        }
        return modifier(MyFlexFrameLayout(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment))
    }
}

总结

Layout 协议为咱们供给了一个绝佳的可以深化了解 SwiftUI 布局机制的窗口,不管你在未来的工作中是否需求运用 Layout 协议创建自界说布局容器,掌握它都将获得莫大的好处。

期望本文可以对你有所帮助。

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

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

我正在参加技能社区创作者签约计划招募活动,点击链接报名投稿。