美文网首页iOSUI工具
SwiftUI中.frame修饰器的使用

SwiftUI中.frame修饰器的使用

作者: MambaYong | 来源:发表于2022-07-07 11:29 被阅读0次

    在学习 SwiftUI 的过程中,首先会学到的 modifiers 可能就是 .frame,关·modifiers这里有必要提一下 ,其实SwiftU中的modifies并不是改变View上的某个属性而是用一个带有相关属性的新View来包装原有的View,当然.frame也不例外,具体的modifiers的使用可以见我的另外一篇文章,本篇文章主要弄清楚.frameSwiftUI布局体系中到底扮演着什么重要的角色。

    基本View

    SwiftUI的布局原则其实很简单:对于层级中的ViewSwiftUI都会提供一个建议尺寸 ,然后View将自己布局在这个建议尺寸的可用空间内,并报告自己的实际尺寸,默认情况下系统将View置于可用空间的中心。但是View的实际尺寸和收到的建议尺寸是会有出入的,比如实际尺寸比建议尺寸大该如何显示?实际尺寸比建议尺寸小呢?有多的空间时该如何分配给各个View呢?这些都和SwiftUI中的基本View有关,下面我们从基本的View入手。

    stack

    Stack是尽可能的占据更多的空间在满足自己的内容,可不会委屈它们。

    Text

    Text的行为和UIKit中的差不多,这类View的特点是及其''老实本分'',根据自身内容来占据应有的尺寸,当空间不够时像Text就宁愿改变自己也不会超出父View当初提议的尺寸,比如宽度不够时则截断文本显示,高度不够时就显示单行,他们尽可能的尊重提议的尺寸。

    Text("hello there, this is a long line that won't fit parent's size.")
         .border(Color.blue)
         .frame(width: 200, height:30)
         .border(Color.green)
         .font(.title)
         .padding(0)
    

    由于.frame包装后的ViewText提议的尺寸是宽度为200高度为30,但是Text的实际宽度是大于提议尺寸的200的,为了"尊重"提议的尺寸,Text截断自身显示,当然也是可以忽略提议尺寸的,后面会解释到。

    Image

    默认情况下Image的尺寸是固定的,其会忽略掉布局系统建议的尺寸而总是返回图片的尺寸。要让一个图片 view尺寸可变,或者说,想让它能接受建议尺寸,并将图片适配显示在这个空间里,我们可以在它上面调用 .resizable,默认情况下,这会拉伸图片,让它填满整个建议尺寸的空间 (我们也可以设置成以瓷砖平铺的方式,或者只拉伸图片的某个部分来进行填充),因为大部分的图片应该是需要以固定的高宽比展示的,所以 .aspectRatio 经常被直接搭配在 .resizable 后面组合使用。
    .aspectRatio 修饰器会去获取建议尺寸,并且基于给定的高宽比,创建一个能最大限度填满建议尺寸的新的尺寸值。接下来,它会将这个尺寸建议给大小可变的图像 (resizable 的图像会填满整个建议尺寸),并将该尺寸返回给上层View。我们可以选择适配或者填充这个建议尺寸,我们也可以决定是要指定一个高宽比,还是将高宽比留给子View去做决定。

    let image = Image(systemName: "ellipsis")
    HStack {
       image
       image.resizable()
       image.resizable().aspectRatio(contentMode: .fit)
    }
    

    Path

    Path类型代表了一组 2D的绘制指令 (和Cocoa 中的 CGPath 类似),它总会将建议的尺寸作为实际尺寸返回,如果所建议的某个方向的值为 nil,那么它返回默认值10

    Path { p in
        p.move(to: CGPoint(x: 50, y: 0))
        p.addLines([
          CGPoint(x: 100, y: 75),
          CGPoint(x: 0, y: 75),
          CGPoint(x: 50, y: 0)
      ])
    }
    

    可能上面的话你不太理解,那么我在写的详细点,下面的这段代码你能想到为什么是这个效果吗?

     var body: some View {
        VStack(spacing:0) {
               Text("hello there, this is a long line that won't fit parent's size.")
               .frame(width: 200, height:30)
               .border(Color.green)
               .font(.title)
               .padding(0)
               Path { p in
                    p.move(to: CGPoint(x: 50, y: 0))
                    p.addLines([
                      CGPoint(x: 100, y: 75),
                      CGPoint(x: 0, y: 75),
                      CGPoint(x: 50, y: 0)
                  ])
                }
        }
         .border(Color.red)
     }
    

    布局流程解析:

    • 首先VStack会将整个屏幕尺寸建议给它的子Views,这里就是TextPath
    • Text在显示完自己的内容后上报自己所需要的尺寸,Path就比较特别,上面我们已经说到它总会将建议的尺寸作为实际尺寸返回,所以这里它会返回建议尺寸,那么建议尺寸是什么?其实就是当初VStack建议的尺寸减去Text所用的尺寸后的尺寸,因为VStack是尽可能占用更多的空间的,所以VStack收到了TextPath上报的尺寸后就确定了自己的尺寸,上图中红色边框显示的区域。

    如果Path所建议的某个方向的值为nil,那么它返回的默认值为10,采用.fixedSize可以把建议尺寸设置为nil

     var body: some View {
        VStack(spacing:0) {
               Text("hello there, this is a long line that won't fit parent's size.")
               .frame(width: 200, height:30)
               .border(Color.green)
               .font(.title)
               .padding(0)
               Path { p in
                    p.move(to: CGPoint(x: 50, y: 0))
                    p.addLines([
                      CGPoint(x: 100, y: 75),
                      CGPoint(x: 0, y: 75),
                      CGPoint(x: 50, y: 0)
                  ])
                }
                  .fixedSize(horizontal: false, vertical: true)
        }
         .border(Color.red)
     }
    

    上面的代码只是利用fixedsize忽略了Path竖直方向的建议尺寸,那么Path在此方向的上报尺寸则会忽略父View的建议尺寸和直接返回10,所以VStack收到的Path的高度尺寸为10,所以红色边框的区域在高度方向会如下所示,Path绘制在了VStack的外面。

    Shape

    Path一样 Shape也总会将建议的尺寸进行返回,在某个方向上建议尺寸为nil时返回默认值10Shape会尽可能的将自身绘制在建议尺寸甚至填满建议尺寸,像是 RectangleCircleEllipseCapsule 这些内建的形状,会将它们自身绘制在建议尺寸中。那些没有高宽比约束的形状,像是 Rectangle,会选择填满整个可用的空间,在布局过程中,Shape 会接收到 path(in:) 的调用,其中的 rect 参数所包含的尺寸正是建议尺寸。

    struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        return Path { p in
              p.move(to: CGPoint(x: rect.midX, y: rect.minY))
              p.addLines([
              CGPoint(x: rect.maxX, y: rect.maxY),
              CGPoint(x: rect.minX, y: rect.maxY),
              CGPoint(x: rect.midX, y: rect.minY)
          ])
         }
       }
    }
    

    Frame的使用

    方式一

    在使用Frame的过程中很容易以为Frame设置的多大,View就会占据多少空间,其实Frame改变的只是建议尺寸,具体View的实际尺寸是要看View怎么来绘制自己的,上面我们已经介绍了基础的View,可以发现基础View在收到建议尺寸时会有不同的表现形式,有的是尊重建议尺寸,有的则填充建议尺寸,还有的甚至可以在建议尺寸外面绘制自己。

    Frame一共有二个初始化的方法:

    • func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center)
      
    • func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center)
      

    在使用时参数是可变的,可以只设置width或者height,也可以同时设置,其中关于aligment的内容请看我的另外一篇文章。

    struct ExampleView: View {
        @State private var width: CGFloat = 50
        
        var body: some View {
            VStack {
                SubView()
                    .frame(width: self.width, height: 120)
                    .border(Color.blue, width: 2)
                
                Text("Offered Width \(Int(width))")
                Slider(value: $width, in: 0...200, step: 1)
            }
        }
    }
    
    struct SubView: View {
        var body: some View {
            GeometryReader { proxy in
                Rectangle()
                    .fill(Color.yellow.opacity(0.7))
                    .frame(width: max(proxy.size.width, 120), height: max(proxy.size.height, 120))
            }
        }
    }
    

    代码解读

    • 首先VStack会将整个屏幕尺寸建议给它的子View,其中包括SubViewTextSlider,当然这里涉及到分配原则,比如有多的空间和空间不足时该如何分配的问题,这里我们先不谈(后面会出文章专门聊这个)。
    • SubView经过.frame修饰器修饰过后收到的建议宽度尺寸为width变量,当我们滑动Slider时会改变width变量的值。
    • width变量的值小于120时,在SubView内部利用GeometryReader读取了这个width变量的建议宽度,由于采用max取的是最大值,所以SubView内部的Rectangle会收到建议尺寸的宽度是120
    • 上面我们已经讲过,对于Shape来说,Rectangle会在指定了建议尺寸时会填满建议尺寸的,所以就算当width变量的值小于120,由于Rectangle填满了宽度120的尺寸,并将当初建议的宽度120尺寸直接返回了,所以SubView的宽度也为120了。
    • width变量大于120时,也很好理解,Rectangle依然填满建议尺寸,所以SubView会跟着变宽。

    上面的整个布局流程是一层层由外到内,然后由内到外确定的,这种思想和UIKit是有很大的不同的,只有清理的知道SwiftUI内部的布局流程,才能不会对有些实际出来的布局效果和自己所想有所出入时感到惊讶,上面的例子请好好体会,因为接下来会进阶到更难的。

    方式二

    上面的例子使用的是Frame的初始化方法1,对于初始化方法2则是指定最小,理想和最大尺寸,其中我们在传值时minimumidealmaximum必须按照升序的方式传入,不然会报错,我们可以将任意参数留空,这样它们会使用默认的nil值,某个方向上的最小值和最大值将作为建议尺寸和返回尺寸的钳位,举例来说,当我们对最大宽度进行了配置时,.frame 修饰器会去检查被建议的宽度,如果这个被建议的宽度超过了设置的最大宽度,那么它只会将最大宽度建议给它的子 view。类似地,如果子view返回了一个比最大宽度更大的宽度,那么这个结果也将被钳至最大宽度值,当在某个方向设置了fixedsize时,此时会采用ideal尺寸进行布局。

    struct ExampleView: View {
        @State private var width: CGFloat = 150
        @State private var fixedSize: Bool = true
        
        var body: some View {
            GeometryReader { proxy in
                
                VStack {
                    Spacer()
                    
                    VStack {
                        LittleSquares(total: 7)
                            .border(Color.green)
                            .fixedSize(horizontal: self.fixedSize, vertical: false)
                    }
                    .frame(width: self.width)
                    .border(Color.primary)
                    .background(MyGradient())
                    
                    Spacer()
                    
                    Form {
                        Slider(value: self.$width, in: 0...proxy.size.width)
                        Toggle(isOn: self.$fixedSize) { Text("Fixed Width") }
                    }
                }
            }.padding(.top, 140)
        }
    }
    
    struct LittleSquares: View {
        let sqSize: CGFloat = 20
        let total: Int
        
        var body: some View {
            GeometryReader { proxy in
                HStack(spacing: 5) {
                    ForEach(0..<self.maxSquares(proxy), id: \.self) { _ in
                        RoundedRectangle(cornerRadius: 5).frame(width: self.sqSize, height: self.sqSize)
                            .foregroundColor(self.allFit(proxy) ? .green : .red)
                    }
                }
                  .border(Color.orange).frame(height:proxy.size.height)
            }.frame(idealWidth: (5 + self.sqSize) * CGFloat(self.total), maxWidth: (5 + self.sqSize) * CGFloat(self.total))
        }
    
        func maxSquares(_ proxy: GeometryProxy) -> Int {
            return min(Int(proxy.size.width / (sqSize + 5)), total)
        }
        
        func allFit(_ proxy: GeometryProxy) -> Bool {
            return maxSquares(proxy) == total
        }
    }
    
    struct MyGradient: View {
        var body: some View {
            LinearGradient(gradient: Gradient(colors: [Color.red.opacity(0.1), Color.green.opacity(0.1)]), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1))
        }
    }
    

    上面这个例子是一个综合的例子,模拟的是类似Text的效果,当空间足够时则显示7个小方格,当空间不够时小方格尽可能的显示,并将颜色变成红色,下面是效果图,建议再看代码截图前,自己尝试理下上述代码的布局流程。

    代码解读

    • Toggle没有打开时,fixedSize这个变量为false,装着LittleSquares的VStack容器通过.frameLittleSquares传递了指为变量width的宽度建议尺寸。
    • 由于fixedSizefalse, 所以不会启动ideal尺寸参与布局,在LittleSquares的内部通过GeometryReader拿到的proxy就是上面的变量width的值,同时把这个值传递给了maxSquares方法来计算能最大显示方格的数量,封顶数量是7个,allFit方法则简单根据显示的数量是否为7个改变了方格的颜色。
    • 注意到GeometryReader设置了frame,当它收到的建议尺寸宽度为nil时会将对内部HStack的建议尺寸用idealWidth代替,此时由于fixedSizefalse,所以不会使用idealWidth的值建议给内部的HStack,而是采用maxWidth的值,当它收到的建议尺寸的宽度超过了这个maxWidth的时候它将建议尺寸的宽度钳至maxWidth的值传递给内部的HStack,如果小于maxWidth,则直接把收到的建议宽度尺寸传递给内部的HStack,同时在收到内部HStack上报返回的尺寸时,也会利用这个规则,如果超过了maxWidth则也会被钳至maxWidth这个值。
    • 所以当滑动Slider使红色的矩形框变大时,矩形也只会最多显示7个,但是由于没有设置minWidth,所以当红色的矩形框变小时则不足以显示7个,会根据收到的建议尺寸空间尽量的显示更多的方格。
    • Toggle没有打开时,fixedSize这个变量为trueGeometryReaderidealWidth传递给了内部的HStack,所以始终显示7个方格。

    总结

    本文主要是介绍了SwiftUI的布局思想,并介绍了几种常见的基本View的布局原则,同时介绍了.frame修饰器的内部实现原理,同时利用实际例子进行了演示,只有理解SwiftUI的布局思想,才不会对意想不到的布局效果感到意外。

    相关文章

      网友评论

        本文标题:SwiftUI中.frame修饰器的使用

        本文链接:https://www.haomeiwen.com/subject/djtpbrtx.html