SwiftUI之View Tree实战1

在之前的两篇文章中,讲解了高层次的视图如何获取低层次视图信息的方法,在本篇文章中,我将给大家演示这些技术在开发中的实际用处。

本篇文章的主要思想来自https://swiftui-lab.com/communicating-with-the-view-tree-part-3/,我并不会对原作者的文章做一个简单的翻译,而是把他的思想进行一个总结,用另一种更简单,更容易理解的方式表达出来。

我们先看一下最终的效果图:

Kapture 2020-07-14 at 19.17.30.gif

细心的读者应该发现了,左边较小的视图正是右边视图的一个预览,仿佛镜子一般,把右边视图的变化映射出来。

其实,这个效果非常有意思,如果你不了解我们之前讲解的技术,实现这个效果对你来说实在是太难了,这就是我一直想表达的一个观点,某些功能或者动画,在SwiftUI中的实现实在是太简单了。

要实现上述的功能,整体的步骤为:

  • 设计需要传递的数据结构,这些信息会从子view传递到上层的view中
  • 通过modifier绑定数据
  • 根据数据生成视图

MyPreferenceData

struct MyPreferenceData: Identifiable {
    let id = UUID()
    let viewType: ViewType
    let bounds: Anchor
    
    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对应的是下图的view:

企业微信截图_0b3329af-a2ea-49c8-926c-22db1c602884.png

son(Color)对应下图的view:

企业微信截图_6d895023-5e35-4c78-8b83-10f83d361240.png

miniMapArea对应左边灰色的视图,我们知道了这些信息后,才能把右边的视图映射到左边。

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的信息放到了一个数组中[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
                    }
            )
            
    }
}

这段代码值得关注的有2点:

  • w和h的计算
  • .anchorPreference:绑定数据

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

上边的这么代码,只为实现下边图片上的view:

企业微信截图_27ec77aa-ad97-4f00-aaab-5ceba59cd1a8.png

其中,大部分代码是非常容易理解的,只有两个地方用到了一点点算法。

第一个是计算let factor = geometry[parentAnchor].width / (geometry[miniMapAreaAnchor].width - 10),表示右边到左边的映射因子,大家看我画的示意图就能明白了:

企业微信截图_002cd1d6-4849-4e9c-be3a-4e5fb68c34d8.png

第二个则是计算彩色块在父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的用法,当视图的关系只有一层的时候,如下图所示:

企业微信截图_30b325d0-6e9b-432c-8569-f1c9d6e046d1.png

我们通常是不需要transformAnchorPreference的,只需要在子view上通过.anchorPreference绑定数据即可,除非要传递的信息不只一个,比如通过.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来处理,否则系统就获取不到更深层次的Preference。

系统在遍历Preference的时候,采用了类似递归的方式,也可以认为是深度优先算法,如果某个父类也写了Preference,则系统不会遍历子view的Preference,这种情况只有当某个父view写了transformAnchorPreference,系统才会往更深层次去获取Preference。

关于上边这句话的解读,大家自己去理解吧,因为这也是我的猜测,不一定正确。

总结

在SwiftUI中,Preference绝对是一柄利器,大家应该重视起来这项技术。

本文源代码:NestedViwsDemo.swfit

SwiftUI集合:FuckingSwiftUI

你可能感兴趣的:(SwiftUI之View Tree实战1)