SwiftUI - 獲取目標視圖 Frame

語言: CN / TW / HK

省流

文章地址:Github

代碼實現:FrameGetter

需要獲取 Frame 的場景

通常我們使用 SwiftUI 佈局 UI 時,利用 VStack、HStack 等即可約束不同 View 之間的位置關係,因此不會再需要獲取指定 View 的 Frame。但是有些時候為了實現複雜的頁面,或者為了一些佈局相關的計算時,就不得不獲取 Frame。

比如下面這個場景,為了實現下拉時放大標題的效果,必須要獲取 ScrollView 的 minY,從而計算出頂部區域的放大倍數:

shot_209.png

初嘗使用 GeometryReader

GeometryReader - Apple doc

A container view that defines its content as a function of its own size and coordinate space.

使用 GeometryReader 可以在佈局時獲取到對應 View 在指定座標系的佈局信息,我們可以利用這個特點將 frame 信息讀取出來。

@State var frame: CGRect = .zero ​ var body: some View {    GeometryReader { geometry in        self.frame = geometry.frame(in: .global)        MainContent()    } }

當然我們不能直接設置,因為 block 內的內容必須要遵循 View 協議,直接這樣寫編譯器無法推斷。

View - Apple doc

You create custom views by declaring types that conform to the View protocol. Implement the required body computed property to provide the content for your custom view.

shot_210.png

我們將佈局代碼提取出來,明確返回值即可:

var body: some View {    GeometryReader { geometry in        makeView(geometry)    } } ​ func makeView(_ geometry: GeometryProxy) -> some View {    print(geometry.size.width, geometry.size.height)    self.frame = geometry.frame(in: .global)    return MainContent() }

不過現在有新的問題產生:在佈局階段,不能夠更改 @State 關鍵字修飾的屬性。

shot_211.png

這個問題的原因在於,@State 關鍵字的含義簡單來説,其實就是 View 的狀態;而如果在計算 View 的時候更改 @State,那就有可能造成時序上的混亂,導致佈局錯誤,所以必須要將 @State 變量的修改延後一個週期。

於是現在的完整代碼變成了:

struct ContentView: View {       @State var frame: CGRect = .zero        var body: some View {        GeometryReader { (geometry) in            self.makeView(geometry)        }    }        func makeView(_ geometry: GeometryProxy) -> some View {        print(geometry.size.width, geometry.size.height)        DispatchQueue.main.async { self.frame = geometry.frame(in: .global) }        return MainContent()    } }

最終實現便捷的 Extension: FrameGetter

首先,我們將已有的部分提取到 ViewModifier 中,當然需要一定的改造 —— 將 GeometryReader 的部分放入 background 中使用。這樣做既不會影響佈局的獲取,又能快捷優雅,並且避免 body 直接返回 GeometryReader 導致的類型推斷錯誤。

extension View {    func frameGetter(_ frame: Binding<CGRect>) -> some View {        modifier(FrameGetter(frame: frame))    } } ​ struct FrameGetter: ViewModifier {    @Binding var frame: CGRect        func body(content: Content) -> some View {        content            .background(                GeometryReader { proxy -> AnyView in                    let rect = proxy.frame(in: .global)                    DispatchQueue.main.async {                        self.frame = rect                    }                    return AnyView(EmptyView())                })    } }

使用起來也很簡單:

struct MyView: View {    @State private var frame: CGRect = CGRect() ​    var body: some View {        Rectangle()            .frameGetter($frame)    } }