在之前的兩篇文章中,講解了高層次的視圖如何獲取低層次視圖信息的方法,在本篇文章中,我將給大家演示這些技術(shù)在開發(fā)中的實際用處。
本篇文章的主要思想來自https://swiftui-lab.com/communicating-with-the-view-tree-part-3/,我并不會對原作者的文章做一個簡單的翻譯,而是把他的思想進行一個總結(jié),用另一種更簡單,更容易理解的方式表達出來。
我們先看一下最終的效果圖:

細心的讀者應(yīng)該發(fā)現(xiàn)了,左邊較小的視圖正是右邊視圖的一個預(yù)覽,仿佛鏡子一般,把右邊視圖的變化映射出來。
其實,這個效果非常有意思,如果你不了解我們之前講解的技術(shù),實現(xiàn)這個效果對你來說實在是太難了,這就是我一直想表達的一個觀點,某些功能或者動畫,在SwiftUI中的實現(xiàn)實在是太簡單了。
要實現(xiàn)上述的功能,整體的步驟為:
- 設(shè)計需要傳遞的數(shù)據(jù)結(jié)構(gòu),這些信息會從子view傳遞到上層的view中
- 通過modifier綁定數(shù)據(jù)
- 根據(jù)數(shù)據(jù)生成視圖
MyPreferenceData
struct MyPreferenceData: Identifiable {
let id = UUID()
let viewType: ViewType
let bounds: Anchor<CGRect>
func getColor() -> Color {
switch self.viewType {
case .parent:
return Color.orange.opacity(0.5)
case .son(let c):
return c
default:
return Color.gray.opacity(0.3)
}
}
func show() -> Bool {
switch self.viewType {
case .parent:
return true
case .son:
return true
default:
return false
}
}
}
在我們這個例子中,我們需要知道3種類型view的位置信息:
enum ViewType: Equatable {
case parent
case son(Color)
case miniMapArea
}
其中parent對應(yīng)的是下圖的view:

son(Color)對應(yīng)下圖的view:

miniMapArea對應(yīng)左邊灰色的視圖,我們知道了這些信息后,才能把右邊的視圖映射到左邊。
MypreferenceKey
struct MypreferenceKey: PreferenceKey {
typealias Value = [MyPreferenceData]
static var defaultValue: Value = []
static func reduce(value: inout [MyPreferenceData], nextValue: () -> [MyPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
通過這段代碼,我們聲明了一個MypreferenceKey,然后需要把每個view需要攜帶的信息通過這個key進行綁定,為了方便計算,我們把每個view的信息放到了一個數(shù)組中[MyPreferenceData]。
DragableView
右邊視圖中的彩色塊支持拖拽手勢改變自身的frame,我們需要單獨將其封裝成一個view:
struct DragableView: View {
let color: Color
@State private var currentOffset: CGSize = CGSize.zero
@State private var preOffset: CGSize = CGSize(width: 100, height: 100)
var w: CGFloat {
self.currentOffset.width + self.preOffset.width
}
var h: CGFloat {
self.currentOffset.height + self.preOffset.height
}
var body: some View {
RoundedRectangle(cornerRadius: 5)
.foregroundColor(color)
.frame(width: w, height: h)
.anchorPreference(key: MypreferenceKey.self, value: .bounds) { anchor in
[MyPreferenceData(viewType: .son(color), bounds: anchor)]
}
.gesture(
DragGesture()
.onChanged { (value: DragGesture.Value) in
self.currentOffset = value.translation
}
.onEnded { _ in
self.preOffset = CGSize(width: w,
height: h)
self.currentOffset = CGSize.zero
}
)
}
}
這段代碼值得關(guān)注的有2點:
- w和h的計算
-
.anchorPreference:綁定數(shù)據(jù)
MiniMap
struct MiniMap: View {
let geometry: GeometryProxy
let preferences: [MyPreferenceData]
var body: some View {
guard let parentAnchor = preferences.first(where: { $0.viewType == .parent })?.bounds else {
return AnyView(EmptyView())
}
guard let miniMapAreaAnchor = preferences.first(where: { $0.viewType == .miniMapArea })?.bounds else {
return AnyView(EmptyView())
}
let factor = geometry[parentAnchor].width / (geometry[miniMapAreaAnchor].width - 10)
let miniMapAreaPosition = CGPoint(x: geometry[miniMapAreaAnchor].minX, y: geometry[miniMapAreaAnchor].minY)
let parentPosition = CGPoint(x: geometry[parentAnchor].minX, y: geometry[parentAnchor].minY)
return AnyView(miniMapView(factor, miniMapAreaPosition, parentPosition))
}
func miniMapView(_ factor: CGFloat,
_ miniMapAreaPosition: CGPoint,
_ parentPosition: CGPoint) -> some View {
ZStack(alignment: .topLeading) {
ForEach(preferences.reversed()) { pref in
if pref.show() {
self.rectangleView(pref, factor, miniMapAreaPosition, parentPosition)
}
}
}
.padding(5)
}
func rectangleView(_ pref: MyPreferenceData,
_ factor: CGFloat,
_ miniMapAreaPosition: CGPoint,
_ parentPosition: CGPoint) -> some View {
return Rectangle()
.fill(pref.getColor())
.frame(width: self.geometry[pref.bounds].width / factor,
height: self.geometry[pref.bounds].height / factor)
.offset(x: (self.geometry[pref.bounds].minX - parentPosition.x) / factor + miniMapAreaPosition.x,
y: (self.geometry[pref.bounds].minY - parentPosition.y) / factor + miniMapAreaPosition.y)
}
}
上邊的這么代碼,只為實現(xiàn)下邊圖片上的view:

其中,大部分代碼是非常容易理解的,只有兩個地方用到了一點點算法。
第一個是計算let factor = geometry[parentAnchor].width / (geometry[miniMapAreaAnchor].width - 10),表示右邊到左邊的映射因子,大家看我畫的示意圖就能明白了:

第二個則是計算彩色塊在父view中的相對位置,我們就不做過多解釋了。
overlayPreferenceValue
最后,我們把上邊的代碼組合起來:
struct ContentView: View {
var body: some View {
HStack {
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.gray.opacity(0.5))
.frame(width: 250, height: 300)
.anchorPreference(key: MypreferenceKey.self, value: .bounds) { anchor in
[MyPreferenceData(viewType: .miniMapArea, bounds: anchor)]
}
ZStack(alignment: .topLeading) {
VStack {
HStack {
DragableView(color: .green)
DragableView(color: .blue)
DragableView(color: .pink)
}
HStack {
DragableView(color: .black)
DragableView(color: .white)
DragableView(color: .purple)
}
}
}
.frame(width: 550, height: 300)
.background(Color.orange.opacity(0.5))
.transformAnchorPreference(key: MypreferenceKey.self, value: .bounds, transform: {
$0.append(contentsOf: [MyPreferenceData(viewType: .parent, bounds: $1)])
})
}
.overlayPreferenceValue(MypreferenceKey.self) { value in
GeometryReader { proxy in
MiniMap(geometry: proxy, preferences: value)
}
}
}
}
注意: .overlayPreferenceValue表示會把視圖放到最上層,如果想放到最下層,則使用.backgroundPreferenceValue。
transformAnchorPreference
在這里我想大概講一個transformAnchorPreference的用法,當(dāng)視圖的關(guān)系只有一層的時候,如下圖所示:

我們通常是不需要transformAnchorPreference的,只需要在子view上通過.anchorPreference綁定數(shù)據(jù)即可,除非要傳遞的信息不只一個,比如通過.anchorPreference傳遞了.bounds,還想傳遞.topLeading,那么這時就需要通過transformAnchorPreference把.topLeading傳遞過去。代碼類似于這樣:
.anchorPreference(key: MypreferenceKey.self, value: .bounds) { anchor in
[MyPreferenceData(viewType: .miniMapArea, bounds: anchor)]
}
.transformAnchorPreference(key: MypreferenceKey.self, value: .topLeading, transform: {
...
}
如果視圖的層級很深,則必須使用transformAnchorPreference來處理,否則系統(tǒng)就獲取不到更深層次的Preference。
系統(tǒng)在遍歷Preference的時候,采用了類似遞歸的方式,也可以認為是深度優(yōu)先算法,如果某個父類也寫了Preference,則系統(tǒng)不會遍歷子view的Preference,這種情況只有當(dāng)某個父view寫了transformAnchorPreference,系統(tǒng)才會往更深層次去獲取Preference。
關(guān)于上邊這句話的解讀,大家自己去理解吧,因為這也是我的猜測,不一定正確。
總結(jié)
在SwiftUI中,Preference絕對是一柄利器,大家應(yīng)該重視起來這項技術(shù)。
本文源代碼:NestedViwsDemo.swfit
SwiftUI集合:FuckingSwiftUI