SwiftUI之frame詳解

隨著本人對SwiftUI了解地越來越深入,我發現SwiftUI并不像表面上看上去的那么簡單,在初學的時候,我們看到的東西往往是浮在水面上最直觀的表象,隨著我們的下潛,我們就看到了那些有趣深奧,充滿魅力的東西。也許,之前我們認為用SwiftUI比較難實現的功能,此時此刻,卻變得十分easy。

對于frame來說,很多人覺得它實在是太簡單了,做過iOS開發的都知道frame是怎么一回事,bounds是怎么一回事,但在SwiftUI中,它幾乎完全不同于我們平時用過的frame。SwiftUI本質上運行在一套新的規則之上,對于SwiftUI來說,frame當然也有它自己的規則。

在原作者的文章中,他并沒有講解SwiftUI中布局的基本原則, 對于部分讀者來說,理解原文可能會有一點困難,在本篇文章中,我會用一部分的篇幅,來講解SwiftUI中布局的基本原則,結合這些原則,再回頭去看frame,一定會發出這樣一句驚嘆:“原來如此?。。 ?/p>

frame是什么

在SwiftUI中,frame()是一個modifier,modifier在SwiftUI中并不是真的修改了view。大多數情況下,當我們對某個view應用一個modifier的時候,實際上會創建一個新的view。

在SwiftUI中,views并沒有frame的概念,但是它們有bounds的概念,也就是說每個view都有一個范圍和大小,它們的bounds不能夠直接通過手動的方式去修改。

當某個view的frame改變后,其child的size不一定會變化,比如,我們修改一個容器VStack的寬度后,其內部child的布局有可能變化,也有可能不變化。我們會在下邊驗證這個說法。

大家記住這句話,每個view對自己需要的size,都有自己的想法,這是我們下邊內容講解的核心思想。

Behaviors

在SwfitUI中,view在計算自己size的時候會有不同的行為方式,我們分為4類:

  • 類似于Vstack,它們會盡可能讓自己內部的內容展示完整,但也不會多要其他的額外空間
  • 類似于Text這種只返回自身需要的size,如果size不夠,它非常聰明的做一些額外的操作,比如換行等等
  • 類似于Shape這種給多大尺寸就使用多大尺寸
  • 還有一些可能超出父控件的view

還存在其他一些比較特殊的例外,比如Spacer,他的特性跟他屬于哪個容器或者哪個軸有關系。當他在VStack中時,他會盡可能的占據剩余垂直的全部空間,而占據的水平空間為0,在HStack中,他的行為卻又恰恰相反。

我們在下一小節的布局原則中,就會看到這些不同行為的表現了。

布局原則

大家仔細思考我接下來的這3句話:

  • 當布局某個view時,其父view會給出一個建議的size
  • 如果該view存在child,那么就拿著這個建議的尺寸去問他的child,child根據自身的behavior返回一個size,如果沒有child,則根據自身的behavior返回一個size
  • 用該size在其父view中進行布局

我們看一個簡單的例子:

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

布局的過程是自下而上的,我們計算ContentView的size

  • ContentView的父view為其提供了一個size等于全屏幕的建議尺寸
  • ContentVIew拿著該尺寸去問其child,Text返回了一個自身需要的size
  • 用該size在父view中布局

基于這3個基本原則,我們分析出,ContentView的size其實是跟Text一樣的:

1

我們在此基礎上再增加一點難度:

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .frame(width: 200, height: 100)
            .background(Color.green)
            .frame(width: 400, height: 200)
            .background(Color.orange.opacity(0.5))
    }
}

上邊這段代碼基本上能夠代表任何一個自定義view的情況了,不要忘記,在考慮布局的時候,是自下而上的。

我們先考慮ContentVIew,他的父view給他的建議尺寸為整個屏幕的大小,我們稱為size0,他去詢問他的child,他的child為最下邊的那個background,這個background自己也不知道自己的size,因此他繼續拿著size0去詢問他自己的child,他的child是個frame,返回了width400, height200, 因此background告訴ContentView他需要的size為width400, height200,因此最終ContentView的size為width400, height200。

很顯然,我們也計算出了最下邊background的size,注意,里邊的Color也是一個view,Color本身是一個Shape,background返回一個透明的view

我們再考慮最上邊的background,他父view給的建議的size為width: 400, height: 200,他詢問其child,得到了需要的size為width: 200, height: 100,因此該background的size為width: 200, height: 100。

我們在看Text,父View給的建議的size為width: 200, height: 100,但其只需要正好容納文本的size,因此他的size并不會是width: 200, height: 100

我們看下布局的效果:

2

這里大家必須要理解Text的size并不會是width: 200, height: 100,這跟我們平時開發的思維有所不同。

了解了這些布局的知識后,我們再往下看文章,就不會有那么的疑惑,在平時的開發中,對于出現比較奇怪的布局問題,也能知道造成這些問題的原因是什么了。

基本用法

我們在開發中,使用frame最頻繁的方法是:

func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center)

我們之前寫了一篇專門講解alignment的文章;SwiftUI之AlignmentGuides,沒有看過的同學一定要去看一下, 在SwiftUI中,理解Alignment Guides的用法,能夠讓我們開發效果更加高效。

當我們修改了width或者height的時候,大多數情況下布局的效果跟我們想象中的一樣,表面上看,我們通過這個方法能夠設置width和height,實際上frame本質上并不能直接修改view的size。

我們在上一小節,演示了布局的3個步驟,frame恰恰能夠改變父或者子的size值,當view詢問child的時候,如果遇到frame,則直接使用該size作為child返回的size。

接下來我們演示一個小demo, 當我們修改父view的寬度的時候,子view不一定完全隨著父view的寬度改變而改變。大家將會看到,布局的3個步驟再次驗證了這些變化。

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

可以看出,黃色方塊的寬度依賴frame(width: max(proxy.size.width, 120), height: max(proxy.size.height, 120)),他在計算size的時候,會使用該frame限定的size,因此,上邊顯示的效果正好符合我們的預期。

其他用法

出了上邊的基本用法外,還有下邊這樣的用法:

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

很明顯,這么多參數可以分為3組:

  • minWidth,idealWidth,maxWidth
  • minHeight,idealHeight,maxHeight
  • alignment

最后一組我們在其他文章中已經講的很明白了,第一組和第二組在原理上基本相同,我們重點拿出第一組來做一個詳細的講解。

當我們給minWidth,idealWidth,maxWidth賦值的時候,一定要遵循數值遞增原則,否則,xcode會給出錯誤提示。

minWidth表示的是最小的寬度, idealWidth表示最合適的寬度,maxWidth表示最大的寬度,通常如果我們用到了該方法,我們只需要考慮minWidth和maxWidth就行了。

在計算size的時候,他們遵循下邊這個流程:

4

其實,如果大家理解了布局的3個原則,那么理解這個流程就很簡單了,frame modifier通過計算minWidth,maxWidth和child size ,就可以看著上邊的規則返回一個size,view用這個size作為自身在父view中的size。

我們簡單看幾個例子:

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .frame(minWidth: 40, maxWidth: 400)
            .background(Color.orange.opacity(0.5))
            .font(.largeTitle)
    }
}

上邊的代碼中,我們同時設置了minWidth和maxWidth,background的size返回400:

5
struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .frame(minWidth: 400)
            .background(Color.orange.opacity(0.5))
            .font(.largeTitle)
    }
}

如果只設置了minWidth,那么background的size返回400:

6
struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .frame(maxWidth: 400)
            .background(Color.orange.opacity(0.5))
            .font(.largeTitle)
    }
}

只要設置了maxWidth,background返回的就是maxWidth的值。

關于這里流程的各種各樣的情況,大家只需要自己寫一點代碼實驗一下就行了,總之,按照前邊說的布局3大原則來理解布局就行了。

Fixed Size Views

我們一定見過 .fixedSize()`這個modifier,表面上看,他好像應該是用在Text上的,用來固定Text的寬度,相信很多同學應該是這個想法,在這一小節,我們就會徹底理解它究竟是怎樣一個東西。

func fixedSize() -> some View
func fixedSize(horizontal: Bool, vertical: Bool) -> some View

在SwiftUI中,任何View都可以用這個modifer,當我們應用了該modifier后,布局系統在返回size的時候,就會返回與之對應的idealWIdth或者idealHeight。

我們先看一段代碼:

struct ContentView: View {
    var body: some View {
        Text("這個文本還挺長的,到達了一定字數后,就超過了一行的顯示范圍了!!!")
            .border(Color.blue)
            .frame(width: 200, height: 100)
            .border(Color.green)
            .font(.title)
    }
}
7

按照3大布局原則,綠色邊框的寬為200, 高為100, 藍色邊框的父view提供的寬為200, 高為100,其child, text在寬為200, 高為100限制下,返回了籃框的size,因此籃框和text的size相同。這個結果符合我們分析的結果。

我們修改一下代碼:

struct ContentView: View {
    var body: some View {
        Text("這個文本還挺長的,到達了一定字數后,就超過了一行的顯示范圍了?。。?)
            .fixedSize(horizontal: true, vertical: false)
            .border(Color.blue)
            .frame(width: 200, height: 100)
            .border(Color.green)
            .font(.title)
    }
}
8

可以看到,綠框沒有任何變化,籃框變寬了,當在水平方向上應用了fixedSize時,.border(Color.blue)在詢問child的size時,child會返回它的idealWidth,我們并沒有給出一個指定的idealWidth,每個view里邊都有自己的idealWidth。

那么我們驗證下,我們給它顯式的指定一個idealWidth:

struct ContentView: View {
    var body: some View {
        Text("這個文本還挺長的,到達了一定字數后,就超過了一行的顯示范圍了!?。?)
            .frame(idealWidth: 300)
            .fixedSize(horizontal: true, vertical: false)
            .border(Color.blue)
            .frame(width: 200, height: 100)
            .border(Color.green)
            .font(.title)
    }
}
9

可以看出來,完全符合我們預想的結果,因此,當我們想要固定某個view的某個軸的尺寸的時候,fixedSize這個modifier是一個利器。

應用

原作者寫了一個演示fixedSize的小demo,下邊是完整代碼:

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

運行效果如下:

10

上邊的代碼其實很簡單,如果idealWidth來固定住view的寬度,那么view的寬度就不會改變,這在某些場景下還是挺有用的。

上邊例子中最核心的代碼是:

.frame(idealWidth: (5 + self.sqSize) * CGFloat(self.total), maxWidth: (5 + self.sqSize) * CGFloat(self.total))

Layout Priority

SwiftUI中,view默認的layout priority 都是0,對于同一層級的view來說,系統會按照順序進行布局,當我們使用.layourPriority()修改了布局的優先級后,系統則優先布局高優先級的view。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("床前明月光,疑是地上霜")
                .background(Color.green)
            Text("舉頭望明月,低頭思故鄉")
                .background(Color.blue)
        }
        .frame(width: 100, height: 100)
    }
}
11

可以看出來,這2個text的優先級是相同的,因此他們平分布局空間,我們給第2個text提升一點優先級:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("床前明月光,疑是地上霜")
                .background(Color.green)
            Text("舉頭望明月,低頭思故鄉")
                .background(Color.blue)
                .layoutPriority(1)
        }
        .frame(width: 100, height: 100)
    }
}

12

可以明顯的看出來,優先布局第2個text。符合我們的預期。

總結

這篇文章中,講解了frame的用法,fixedSize和layoutPriority的用法,要想理解這些用法,必須理解布局的3大原則:

  • 父view提供一個建議的size
  • view根據自身的特點再結合它的child計算出一個size
  • 使用該size在父view中布局

*注:上邊的內容參考了網站https://swiftui-lab.com/frame-behaviors/,如有侵權,立即刪除。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。