SwiftUI之AlignmentGuides

3,419 阅读13分钟

本质上,Alignment Guides属于SwiftUI中布局的知识点,在某些特殊场景下,使用Alignment Guides能起到事半功倍的效果,比如我们平时经常用的下边的这样的效果:

1

上图显示了,当切换背景容器的最大宽度时,使用Alignment Guides能够自动执行动画效果,这正是我们想要的,核心代码如下:

struct TestWrappedLayout: View {
    let w: CGFloat
    var texts: [String]

    var body: some View {
        self.generateContent(in: w)
    }

    private func generateContent(in w: CGFloat) -> some View {
        var width = CGFloat.zero
        var height = CGFloat.zero

        return ZStack(alignment: .topLeading) {
            ForEach(self.texts, id: \.self) { t in
                self.item(for: t)
                    .padding([.trailing, .bottom], 4)
                    .alignmentGuide(.leading, computeValue: { d in
                     
                        if (abs(width - d.width) > w)
                        {
                            width = 0
                            height -= d.height
                        }
                        let result = width
                        if t == self.texts.last! {
                            width = 0 //last item
                        } else {
                            width -= d.width
                        }
                        return result
                    })
                    .alignmentGuide(.top, computeValue: {d in
                        let result = height
                        if t == self.texts.last! {
                            height = 0 // last item
                        }
                        return result
                    })
            }
        }
    }

    func item(for text: String) -> some View {
        Text(text)
            .padding([.leading, .trailing], 8)
            .frame(height: 30)
            .font(.subheadline)
            .background(Color.orange)
            .foregroundColor(.white)
            .cornerRadius(15)
            .onTapGesture {
                print("你好啊")
        }
    }
}

在本篇文章中,最核心的思想就是容器container中的每个View都有它的alignment guide。

Alignment Guide是什么?

说到对齐,大家头脑中一定要有一个组的概念,也就是group,如果只有一个view,那对齐就失去了意义,我们在设计对齐相关的ui的时候,是对一组中的多个view进行考虑的,这也恰恰和容器的概念对应上了,我这里说的容器指的是VStack,HStack,ZStack

换句话说,我们对容器内的Views使用Alignment guide。

对齐共分为两种:水平对齐(horizontal),垂直对齐(vertical)

我们先以水平对齐为例,先看下图:

align-horizontal-1.png

有3个view,分别为A,B,C,他们alignment guide返回的值分别为0, 20, 10,从上图可以看出,他们的偏移关系正好和值对应上了,当值为正的时候,往左偏移,为负的时候,往右偏移。这里有下边几个概念,大家一定要理解,如果不理解这几个概念,就无法真正明白对齐的奥义:

  • 我们把A,B,C放到了VStack中,VStack中使用的对齐方式是水平对齐,比如VStack(alignment: .center)`
  • alignment guide返回的值表达的是这3个view的位置关系,并不是说A的返回值为0,A就不偏移,我们需要把他们作为一个整体来看,通过偏移量来描述他们之间的位置关系,然后让他们3个view在VStack中整体居中

上边的重点是,alignment guide描述的是views之间的位置关系,系统在布局的时候,会把他们看成一个整体,然后在使用frame alignment guide对整体进行布局。

同样的道理,下边图片展示的是垂直对齐,我们就不再多做解释了:

align-vertical.png

通过上边这两个例子,我们得出一个结论:VStack需要水平对齐,HStack需要垂直对齐,虽然这听上去有点怪,但只需在头脑中想一想他们中view的排列方式,就不难理解。至于ZStack,即需要水平对齐,也需要垂直对齐,这个我们在下边的小节中,详细解释。

Alignment Guide中的疑惑

相信大家在代码中的很多地方会用到.leading,在SwiftUI中,用到对齐的地方一共有下边几种:

alignment-confusion.png

这张图片覆盖了对齐所有的使用方式,现在大家可能是一脸茫然,但读完剩下的文章后,再回过头来看这张图片,就会发现,这张图片实在是太经典了,毫不夸张的说,你以后在SwiftUI中使用alignment guide的时候,头脑中一定会浮现出这张图片。

我们对上边的几个概念做个简单的介绍:

  • Container Alignment: 容器的对齐方式主要有2个目的,首先它定义了其内部views的隐式对齐方式,没有使用alignmentGuides()modifier的view都使用隐式对齐,然后定义了内部views中使用了alignmentGuides()的view,只有参数与容器对齐参数相同,容器才会根据返回值计算布局
  • Alignment Guide:如果该值和Container Alignment的参数不匹配,则不会生效
  • Implicit Alignment Value:通常来说,隐式对齐采用的值都是默认的值,系统通常会使用和对齐参数相匹配的值
  • Explicit Alignment Value:显式对齐跟隐式对齐相反,是我们自己用程序明确给出的返回值
  • Frame Alignment:表示容器中views的对齐方式,把views看作一个整体,整体偏左,居中,或居右
  • Text Alignment:控制多行文本的对齐方式

隐式和显式对齐的区别

每个view都有一个alignment,记住这一点非常重要,当我们使用.alignmentGuide()设置对齐方式时,我们称之为显式,相反则称之为隐式。隐式的情况下,.alignmentGuide()的返回值和它父类容器的对齐参数有关。

如果我们没有为VStack, HStackZStack提供alignment参数,默认值为center。

ViewDimensions

func alignmentGuide(_ g: HorizontalAlignment, computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View
func alignmentGuide(_ g: VerticalAlignment, computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View

我们已经知道了computeValue函数的返回值是一个CGFloat类型,但我们不太清楚ViewDimensions是个什么东西?很简单,我们可以直接查看它的系统定义:

public struct ViewDimensions {
    public var width: CGFloat { get } // The view's width
    public var height: CGFloat { get } // The view's height

    public subscript(guide: HorizontalAlignment) -> CGFloat { get }
    public subscript(guide: VerticalAlignment) -> CGFloat { get }
    public subscript(explicit guide: HorizontalAlignment) -> CGFloat? { get }
    public subscript(explicit guide: VerticalAlignment) -> CGFloat? { get }
}

很容易发现,通过widthheight,我们很容易获得该view的宽和高,这在我们返回对齐值的时候非常有用,我们不做过多解释,我们往下看,subscript表明我们可以像这样访问:d[HorizontalAlignment.leading]

那么这有什么用呢? 我们先看段代码:

struct Example6: View {
    var body: some View {
        ZStack(alignment: .topLeading) {
            Text("Hello")
                .alignmentGuide(HorizontalAlignment.leading, computeValue: { d in return 0 })
                .alignmentGuide(.top, computeValue: { d in return 0 })
                .background(Color.green)
            
            Text("World")
                .alignmentGuide(.top, computeValue: { d in return 100 })
                .alignmentGuide(HorizontalAlignment.leading, computeValue: { d in return 0 })
                .background(Color.purple)
            
        }
        .background(Color.orange)
    }
}

这段代码运行后的效果

1

由于我们给Text("World")设置了.alignmentGuide(.top, computeValue: { d in return 100 }),因此,它出现在hello的上边没什么问题,那么我如果把.alignmentGuide(HorizontalAlignment.leading, computeValue: { d in return 0 })改成.alignmentGuide(HorizontalAlignment.leading, computeValue: { d in return d[.top] })呢?

在设置leading对齐的时候使用了top对齐的数据,运行效果:

2

可以看出,完全符合我们的预期,world又向左偏移了100的距离,这就是我们上边说的用法,不过,通常情况下我们基本不需要这样操作。

类似d[HorizontalAlignment.leading]这样的参数,我们都可简写成d[.leading],Swift能够非常智能的识别这些类型,但是center除外,原因是HorizontalAlignmentVerticalAlignment都有center。

对齐类型

对于HorizontalAlignment来说,有下边几个参数:

extension HorizontalAlignment {
    public static let leading: HorizontalAlignment
    public static let center: HorizontalAlignment
    public static let trailing: HorizontalAlignment
}

当我们使用下标访问数据的时候,有两种方式:

d[.trailing]
d[explicit: .trailing]

d[.trailing]表示获取d的隐式leading,也就是默认值,通常情况下,.leading的值为0,.center的值为width的一半,.trailing的值为width。

d[explicit: .trailing]表示获取d的显式的trailing,当没有通过.alignmentGuide()指定值的时候,它返回nil,就像上一小节讲的一样,在ZStack中,我们可以获取显式的对齐值

对于VerticalAlignment来说,基本用法跟HorizontalAlignment差不多,但它多了几个参数:

extension VerticalAlignment {
    public static let top: VerticalAlignment
    public static let center: VerticalAlignment
    public static let bottom: VerticalAlignment
    public static let firstTextBaseline: VerticalAlignment
    public static let lastTextBaseline: VerticalAlignment
}

firstTextBaseline表示所有text的以各自最上边的那一行的base line对齐,lastTextBaseline表示所有text的以最下边的那一行的base line对齐。对于某个view而言,如果它不是多行文本,则firstTextBaselinelastTextBaseline是一样的。

我们可以通过print(d[.lastTextBaseline])打印出这些值,他们都为正值。

我们先看个firstTextBaseline的例子:

        HStack(alignment: .firstTextBaseline) {
            Text("床前明月光")
                .font(.caption)
                .frame(width: 50)
                .background(Color.orange)
               
            Text("疑是地上霜")
                .font(.body)
                .frame(width: 50)
                .background(Color.green)
            
            Text("举头望明月")
                .font(.largeTitle)
                .frame(width: 50)
                .background(Color.blue)
             
        }

企业微信截图_b87e6bd8-84b4-4e49-9f44-3a88d13c64dc.png

可以看出来,这3个text都以他们各自的第一行的base line 对齐了,我们稍微改下代码:

        HStack(alignment: .lastTextBaseline) {
            ...
    }

企业微信截图_b92bc7fa-3042-4e7c-9026-7c0322ac19e7.png

他们以各自的最后一行的的base line对齐了,针对这3个text,上边的代码都使用了隐式的alignment guide,那么我们再进一步尝试,我们给第3个text一个显式的alignment guide会是怎么样的?

        HStack(alignment: .lastTextBaseline) {
            Text("床前明月光")
                .font(.caption)
                .frame(width: 50)
                .background(Color.orange)
               
            Text("疑是地上霜")
                .font(.body)
                .frame(width: 50)
                .background(Color.green)
            
            Text("举头望明月")
                .font(.largeTitle)
                .alignmentGuide(.lastTextBaseline, computeValue: { (d) -> CGFloat in
                    d[.firstTextBaseline]
                })
                .frame(width: 50)
                .background(Color.blue)
             
        }

企业微信截图_3aa000af-94ee-4224-9926-c6e220d4527a.png

重点来了,对齐描述的是容器内view之间的布局关系,由于computeValue函数的返回值都是CGFloat,因此不管是哪种对齐方式,最终都是得到一个CGFloat。

那么如果我们在text中间加入一个其他的view呢?

        HStack(alignment: .firstTextBaseline) {
            Text("床前明月光")
                .font(.caption)
                .frame(width: 50)
                .background(Color.orange)
            
            RoundedRectangle(cornerRadius: 3)
                .foregroundColor(.green)
                .frame(width: 50, height: 40)
               
            Text("疑是地上霜")
                .font(.body)
                .frame(width: 50)
                .background(Color.green)
            
            Text("举头望明月")
                .font(.largeTitle)
                .alignmentGuide(.firstTextBaseline, computeValue: { (d) -> CGFloat in
                    return 0
                })
                .frame(width: 50)
                .background(Color.blue)
             
        }

企业微信截图_afa2d94f-7faf-4f70-bfe4-1f5ed7c41e2c_副本.png

  • 除了text之外的其他view,都使用bottom对齐方式
  • 不管是lastTextBaseline还是firstTextBaseline,布局的算法都是.top + computeValue ,也就是说以它的顶部为布局的基线
  • alignment 描述的是view之间的关系,把他们作为一个整体或者一组来看待

这一块可能有点绕,但并不难理解,如果大家有问题,可以留言。

我们知道HStack使用VerticalAlignmentVStack使用HorizontalAlignment,他们只需要一种就行了,但是ZStack同时需要两种对齐方式,该如何呢?

这里引入Alignment类型,用法如下:

ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)) { ... }

本质上,它把horizontal和vertical封装在了一起,我们平时经常用的是下边这个写法,只是写法不同而已:

ZStack(alignment: .topLeading) { ... }

Container Alignment

所谓的容器的对齐方式指的是下边这里:

VStack(alignment: .leading)
HStack(alignment: .top)
ZStack(alignment: .topLeading)

那么它主要有什么作用呢?

  • 我们知道,容器中的view都能够用.alignmentGuides()modifier来显式的返回对齐值,.alignmentGuides()的第一个参数如果与Container Alignment不一样,容器在布局的时候就会忽略这个view的.alignmentGuides()
  • 它提供了容器中view的隐式alignment guide

大家看这段代码:

struct Example3: View {
    @State private var alignment: HorizontalAlignment = .leading
    
    var body: some View {
        VStack {
            Spacer()
            
             VStack(alignment: alignment) {
               LabelView(title: "Athos", color: .green)
                .alignmentGuide(.leading, computeValue: { _ in 30 } )
                   .alignmentGuide(HorizontalAlignment.center, computeValue: { _ in 30 } )
                   .alignmentGuide(.trailing, computeValue: { _ in 90 } )
                 
               LabelView(title: "Porthos", color: .red)
                   .alignmentGuide(.leading, computeValue: { _ in 90 } )
                   .alignmentGuide(HorizontalAlignment.center, computeValue: { _ in 30 } )
                   .alignmentGuide(.trailing, computeValue: { _ in 30 } )
                 
                LabelView(title: "Aramis", color: .blue) // use implicit guide
                
             }

            Spacer()
            HStack {
                Button("leading") { withAnimation(.easeInOut(duration: 2)) { self.alignment = .leading }}
                Button("center") { withAnimation(.easeInOut(duration: 2)) { self.alignment = .center }}
                Button("trailing") { withAnimation(.easeInOut(duration: 2)) { self.alignment = .trailing }}
            }
        }
    }
}

它的运行效果如下图所示:

Kapture 2020-06-01 at 17.11.25.gif

可以很明显的看出当我们切换container alignment的参数时,它内部的view的alignment那些被忽略,那些被使用。

Frame Alignment

我一直在不断的强调,我们上边看到的对齐方式,都应该把容器中的所有view看作一个整体,alignment描述的是view之间的一种位置关系。

大家思考一下,即使.alignmentGuide中的computeValue返回值为0,也不能说明该view保持不动。

如果我们把容器内部的view看成一组,那么Frame Alignment就非常容易理解了:

VStack(alignment: .leading)

VStack(alignment: .center)

VStack(alignment: .trailing)

上边3张图片分别展示了VStack(alignment: .leading),VStack(alignment: .center)VStack(alignment: .trailing)的情况,可以看出,他们内部图形的布局发生了变化,但是他们3个整体都是居中对齐的。

原因就是我们上一小节讲的,container alignment只影响容器内的布局,要让容器内的views整体左对齐或者居中,需要使用Frame Alignment.

.frame(maxWidth: .infinity, alignment: .leading)

Frame Alignment

关于Frame Alignment有一点需要特别注意,有时候看上去我们的设置没有生效,只要原因就是,在SwiftUI中,大多数情况下View的布局政策基于收紧策略,也就是View的宽度只是自己需要的宽度,这种情况下设置frame对齐当然就没有意义了。

Multiline Text Alignment()

多行文本对齐就比较简单了,大家直接看图就行了。

Multiline Text Alignment()

Interacting with the Alignment Guides

如果大家对上边讲的这些对齐方式还有疑惑,可以下载这里的代码gist.github.com/swiftui-lab…,自己动手做一些交互,就应该能够明白这些原理和用法了,放一张界面的截图:

交互

Custom Alignments

大多数情况,我们是不要自定义对齐的,使用系统提供的.leading.center等等几乎可以实现所有的UI效果,在本小节中,大家应该重点关注第二个例子,基本上只有这种情况,我们优先考虑自定义对齐。

自定义对齐的基本写法如下:

extension HorizontalAlignment {
    private enum WeirdAlignment: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d.height
        }
    }
    
    static let weirdAlignment = HorizontalAlignment(WeirdAlignment.self)
}
  • 决定是horizontal还是vertical
  • 提供一个隐式对齐的默认值

我们小试牛刀,在上边代码中,我们自定义一个alignment,默认值返回view的高度,这样产生的效果如下:

weird-alignment.png

可以看出,每个view的偏移都是它自身的高度,这样的效果看上去还挺有意思。完整代码如下:

struct Example4: View {
    var body: some View {
        VStack(alignment: .weirdAlignment, spacing: 10) {
            
            Rectangle()
                .fill(Color.primary)
                .frame(width: 1)
                .alignmentGuide(.weirdAlignment, computeValue: { d in 0 })
            
            ColorLabel(label: "Monday", color: .red, height: 50)
            ColorLabel(label: "Tuesday", color: .orange, height: 70)
            ColorLabel(label: "Wednesday", color: .yellow, height: 90)
            ColorLabel(label: "Thursday", color: .green, height: 40)
            ColorLabel(label: "Friday", color: .blue, height: 70)
            ColorLabel(label: "Saturday", color: .purple, height: 40)
            ColorLabel(label: "Sunday", color: .pink, height: 40)
            
            Rectangle()
                .fill(Color.primary)
                .frame(width: 1)
                .alignmentGuide(.weirdAlignment, computeValue: { d in 0 })
        }
    }
}

struct ColorLabel: View {
    let label: String
    let color: Color
    let height: CGFloat
    
    var body: some View {
        Text(label).font(.title).foregroundColor(.primary).frame(height: height).padding(.horizontal, 20)
            .background(RoundedRectangle(cornerRadius: 8).fill(color))
    }
}

重点来了,我要说,使用自定义对齐最大的优势是:当在不同的view继承分支上使用自定义对齐,会产生理想的效果。

custom-alignment.gif

大家看上图,一个HStack包裹这一个Image和VStack,VStack中有一组Text,当点击某个Text的时候,Image可以和点击的Text对齐。

这就是我们上边说的,对于属于不同层次的view进行对齐,SwiftUI非常智能的知道我们想要这样的效果。完整代码如下:

extension VerticalAlignment {
    private enum MyAlignment : AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.bottom]
        }
    }
    static let myAlignment = VerticalAlignment(MyAlignment.self)
}

struct CustomView: View {
    @State private var selectedIdx = 1
    
    let days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
    
    var body: some View {
            HStack(alignment: .myAlignment) {
                Image(systemName: "arrow.right.circle.fill")
                    .alignmentGuide(.myAlignment, computeValue: { d in d[VerticalAlignment.center] })
                    .foregroundColor(.green)

                VStack(alignment: .leading) {
                    ForEach(days.indices, id: \.self) { idx in
                        Group {
                            if idx == self.selectedIdx {
                                Text(self.days[idx])
                                    .transition(AnyTransition.identity)
                                    .alignmentGuide(.myAlignment, computeValue: { d in d[VerticalAlignment.center] })
                            } else {
                                Text(self.days[idx])
                                    .transition(AnyTransition.identity)
                                    .onTapGesture {
                                        withAnimation {
                                            self.selectedIdx = idx
                                        }
                                }
                            }
                        }
                    }
                }
            }
            .padding(20)
            .font(.largeTitle)
    }
}

如果要自定义ZStack的alignment,稍微麻烦一点,但原理都是一样的相信大家都能够理解,就直接上代码了:

extension VerticalAlignment {
    private enum MyVerticalAlignment : AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.bottom]
        }
    }
    
    static let myVerticalAlignment = VerticalAlignment(MyVerticalAlignment.self)
}

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

extension Alignment {
    static let myAlignment = Alignment(horizontal: .myHorizontalAlignment, vertical: .myVerticalAlignment)
}

struct CustomView: View {
    var body: some View {
        ZStack(alignment: .myAlignment) {
            ...
        }
    }
}

总结

通过这篇文章,大家应该对Alignment Guide有了一个全面的了解,它应该成为我们日常开发进行布局的强大工具,现在回过头来再看看这张图片,是不是有那么一点感觉了呢?

alignment-confusion.png

注:上边的内容参考了网站https://swiftui-lab.com/alignment-guides/,如有侵权,立即删除。