SwiftUI之View Tree(PreferenceKey)

學(xué)習(xí)SwiftUI,便繞不開視圖樹的概念,在接下來的4篇文章中,我會帶領(lǐng)大家學(xué)習(xí)相關(guān)的概念,通過對視圖樹的學(xué)習(xí),很多之前認(rèn)為很困難的問題,都會引刃而解。

視圖樹的概念不言而喻,在SwiftUI中,組成某個頁面的View的結(jié)構(gòu)是樹型的,如下圖所示:

2019-11-05-diagram-e8ae296b.png

在SwiftUI中,子view如果想獲取父view提供的數(shù)據(jù),一個最好的方式就是使用@EnvironmentObject或者@Environment,在這里只演示一個簡單的例子:

@main
struct PreferenceKeyDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.myEnvironmentTestValue, 10.0)
        }
    }
}

struct MyEnvironmentKey: EnvironmentKey {
    static var defaultValue: Double = 0.0
}

extension EnvironmentValues {
    var myEnvironmentTestValue: Double {
        get {
            self[MyEnvironmentKey.self]
        }
        set {
            self[MyEnvironmentKey.self] = newValue
        }
    }
}

上邊的代碼中,給ContentView設(shè)置了一個環(huán)境變量,然后我們在其子view中就可隨意獲取這個環(huán)境變量

struct ContentView: View {
    var body: some View {
        Example4()
    }
}
 
struct Example4: View {
    @Environment(\.myEnvironmentTestValue) var value: Double
    
    var body: some View {
        Text("\(value)")
    }
}

后續(xù)我會專門寫一篇文章介紹這兩個知識點(diǎn)。回到我們的話題,如果父view想獲取其子view的一些數(shù)據(jù),怎么辦呢?

企業(yè)微信截圖_0da5055f-cf08-4d33-9de8-67806d92c701.png

大家頭腦中一定要對上圖中的問號有深刻的思考,只有這樣才能掌握在什么場景下需要使用本文講解的技術(shù)。

舉個??

struct Example1: View {
    var body: some View {
        NavigationView {
            Text("Hello, world!")
                .padding()
                .navigationBarTitle("????????", displayMode: .inline)
        }
    }
}

大家看上邊這段代碼,navigationBarTitle這個modifier寫在了Text上,那么NavigationView是如何獲取到這些信息的呢?我們帶著這個疑問,在看一個??:

Kapture 2020-07-09 at 14.35.22.gif

上圖中演示的功能很簡單,點(diǎn)擊哪個數(shù)字,哪個數(shù)字就顯示一個border,用我們學(xué)過的知識就能實(shí)現(xiàn)這個功能,代碼如下:

struct Example2: View {
    @State private var activeNumber: Int = 1

    var body: some View {
        VStack {
            Spacer()
            HStack {
                NumberView(activeNumber: $activeNumber, number: 1)
                NumberView(activeNumber: $activeNumber, number: 2)
                NumberView(activeNumber: $activeNumber, number: 3)
            }
            HStack {
                NumberView(activeNumber: $activeNumber, number: 4)
                NumberView(activeNumber: $activeNumber, number: 5)
                NumberView(activeNumber: $activeNumber, number: 6)
            }
            HStack {
                NumberView(activeNumber: $activeNumber, number: 7)
                NumberView(activeNumber: $activeNumber, number: 8)
                NumberView(activeNumber: $activeNumber, number: 9)
            }
            Spacer()
        }
    }

    struct NumberView: View {
        @Binding var activeNumber: Int
        let number: Int

        var body: some View {
            Text("\(number)")
                .font(.system(size: 40, weight: .heavy, design: .rounded))
                .padding(20)
                .background(NumberBorder(show: activeNumber == number))
                .onTapGesture {
                    self.activeNumber = number
                }
        }
    }

    struct NumberBorder: View {
        let show: Bool

        var body: some View {
            Circle()
                .stroke(show ? Color.green : Color.clear, lineWidth: 5)
                .animation(.easeInOut)
        }
    }
}

核心思想就是,父view記錄一個activeNumber,然后為index等于activeNumber的NumberView的border設(shè)置顏色。我們把難度稍為提高一點(diǎn),要求實(shí)現(xiàn)下圖的功能:

Kapture 2020-07-09 at 14.41.20.gif

最明顯的改變就是,只有一個綠色圓圈在執(zhí)行動畫,仔細(xì)思考,我們發(fā)現(xiàn),要實(shí)現(xiàn)上述功能,需要父view獲取子view的位置信息,這恰恰引出了本文的核心內(nèi)容:父類如何獲取子view的信息。

關(guān)于這個問題,我們可以想像成子view可以把自己的一些信息先打包,然后和自身綁定,父view就能獲取到這些包裹。

那么,如何打包呢?

struct NumberPreferenceValue: Equatable {
    let viewIdx: Int
    let rect: CGRect
}

很簡單,把需要傳遞的信息封裝成一個結(jié)構(gòu)體就行了,但需要實(shí)現(xiàn)Equatable協(xié)議,在本例中,我們打包了兩個信息,原則上可以打包任何信息。

struct NumberPreferenceViewSetter: View {
    let idx: Int
    var body: some View {
        GeometryReader { proxy in
            Circle()
                .stroke(Color.clear, lineWidth: 5)
                .preference(key: NumberPreferenceKey.self, value: [NumberPreferenceValue(viewIdx: idx, rect: proxy.frame(in: .named("ZStackSpace")))])
        }

    }
}

我們?yōu)槊總€子view添加了一個透明的邊框,通過preference這個modifier綁定自身的信息,注意,preference要求傳入一個key和value:

struct NumberPreferenceKey: PreferenceKey {
    typealias Value = [NumberPreferenceValue]
    static var defaultValue: [NumberPreferenceValue] = []
    static func reduce(value: inout [NumberPreferenceValue], nextValue: () -> [NumberPreferenceValue]) {
        value.append(contentsOf: nextValue())
    }
}
  • key: 只需要實(shí)現(xiàn)PreferenceKey協(xié)議即可,該協(xié)議要求實(shí)現(xiàn)一個靜態(tài)變量defaultValue和靜態(tài)函數(shù)reduce
  • value:就是我們上邊封裝好的結(jié)構(gòu)體,在本例中,我們把NumberPreferenceValue放到了數(shù)組中

其實(shí),這些都是固定寫法,當(dāng)父view想要獲取子view信息的時候,他就會遍歷子view中的reduce,然后把所有的包裹合并成一個數(shù)組。

var body: some View {
    ZStack(alignment: .topLeading) {
        ...

        VStack {
            ...
        }
    }
    .onPreferenceChange(NumberPreferenceKey.self) { preferences in
        for pre in preferences {
            self.rects[pre.viewIdx] = pre.rect
        }
    }
    .coordinateSpace(name: "ZStackSpace")

ZStack通過.onPreferenceChange獲取了全部的preferences,然后根據(jù)包裹中的數(shù)據(jù)給self.rects賦值。這樣就實(shí)現(xiàn)了上述的功能。

完整代碼如下:

struct Example3: View {
    @State private var activeNumber: Int = 1
    @State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 9)

    var body: some View {
        ZStack(alignment: .topLeading) {
            Circle()
                .stroke(Color.green, lineWidth: 5)
                .frame(width: rects[activeNumber - 1].width, height: rects[activeNumber - 1].height)
                .offset(x: rects[activeNumber - 1].minX, y: rects[activeNumber - 1].minY)
                .animation(.easeInOut)
            
            VStack {
                Spacer()
                HStack {
                    NumberView(activeNumber: $activeNumber, number: 1)
                    NumberView(activeNumber: $activeNumber, number: 2)
                    NumberView(activeNumber: $activeNumber, number: 3)
                }
                HStack {
                    NumberView(activeNumber: $activeNumber, number: 4)
                    NumberView(activeNumber: $activeNumber, number: 5)
                    NumberView(activeNumber: $activeNumber, number: 6)
                }
                HStack {
                    NumberView(activeNumber: $activeNumber, number: 7)
                    NumberView(activeNumber: $activeNumber, number: 8)
                    NumberView(activeNumber: $activeNumber, number: 9)
                }
                Spacer()
            }
        }
        .onPreferenceChange(NumberPreferenceKey.self) { preferences in
            for pre in preferences {
                self.rects[pre.viewIdx] = pre.rect
            }
        }
        .coordinateSpace(name: "ZStackSpace")
        
    }

    struct NumberView: View {
        @Binding var activeNumber: Int
        let number: Int

        var body: some View {
            Text("\(number)")
                .font(.system(size: 40, weight: .heavy, design: .rounded))
                .padding(20)
                .background(NumberPreferenceViewSetter(idx: number - 1))
                .onTapGesture {
                    self.activeNumber = number
                }
        }
    }

    struct NumberPreferenceViewSetter: View {
        let idx: Int
        var body: some View {
            GeometryReader { proxy in
                Circle()
                    .stroke(Color.clear, lineWidth: 5)
                    .preference(key: NumberPreferenceKey.self, value: [NumberPreferenceValue(viewIdx: idx, rect: proxy.frame(in: .named("ZStackSpace")))])
            }
           
        }
    }
    
    struct NumberPreferenceValue: Equatable {
        let viewIdx: Int
        let rect: CGRect
    }
    
    struct NumberPreferenceKey: PreferenceKey {
        typealias Value = [NumberPreferenceValue]
        static var defaultValue: [NumberPreferenceValue] = []
        static func reduce(value: inout [NumberPreferenceValue], nextValue: () -> [NumberPreferenceValue]) {
            value.append(contentsOf: nextValue())
        }
    }
}

總結(jié)

當(dāng)某個場景下,父view需要獲取子view的某些信息,就可以考慮使用PreferenceKey這個技術(shù),它最大的優(yōu)點(diǎn)是可以讓子view封裝任何信息。在本文的例子中,我們主要封裝的是子view的frame信息,這可能存在一些潛在的問題,比如,如果父view的布局改變了,影響到了子view的布局,子view的布局又影響了父view的布局,這種情況下可能會出現(xiàn)死循環(huán)。

本文示例代碼:SwiftUI-PreferenceKeyDemo.swift

SwiftUI集合:FuckingSwiftUI

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容