swift. 扩展类添加属性_[SwiftUI 100天] Bucket List - part2 Enum 和 扩展

本文介绍了如何在 SwiftUI 应用中使用枚举来切换视图状态,以保持代码整洁。通过创建一个枚举来表示多种状态,并为每种状态创建独立的视图。接着展示了如何将 MapKit 集成到 SwiftUI 中,包括创建 `MapView`,使用 `UIViewRepresentable`,并实现 `MKMapViewDelegate` 以与 MapView 交互。最后,通过扩展添加了 @Binding 支持,允许用户在地图上添加和移动地点标记。
摘要由CSDN通过智能技术生成

13a694b924ab9874e0e4348db20940bc.png
译自 Switching view states with enums
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 ➕三连?关注专栏,关注我

用 enums 切换视图状态

你可能已经见过常规的 Swift 条件语句,用于区分两种视图呈现,代码像下面这样:

Group {
    if Bool.random() {
        Rectangle()
    } else {
        Circle()
    }
}

条件视图在我们需要展示不同状态的时候非常有用,如果我们计划有序,保持代码规模足够小,可以确保代码清爽整洁。但是状态多了怎么办呢?保持视图代码的简短,训练自己设计 SwiftUI 应用架构的能力。

解决方案分两部分,第一部分是为各种视图状态定义枚举。举个例子,你可以利用一个嵌套的 enum 定义几种状态:

enum LoadingState {
    case loading, success, failed
}

接下来,为每种状态创建单独的视图,简单起见,我这里就写文本视图。但是,在工程实践中,如果视图比较复杂,拆分就显得非常有必要了。

struct LoadingView: View {
    var body: some View {
        Text("Loading...")
    }
}

struct SuccessView: View {
    var body: some View {
        Text("Success!")
    }
}

struct FailedView: View {
    var body: some View {
        Text("Failed.")
    }
}

这些嵌套的视图是否有存在的必要,完全取决于你的 app 的大小,以及你是否打算复用视图。

有了这两部分,我们现在可以在 ContentView 中用一个简单的 wrapper 来追踪当前应用的状态,然后展示相应的子视图。

var loadingState = LoadingState.loading

然后根据状态显示不同视图,完成 body 属性,像下面这样:

Group {
    if loadingState == .loading {
        LoadingView()
    } else if loadingState == .success {
        SuccessView()
    } else if loadingState == .failed {
        FailedView()
    }
}

采用上面这种方法,我们的 body 属性的代码就不会随着新增的代码不断膨胀,因为它不需要去关系加载,成功,失败几种状态下具体的视图外观,它们由嵌套的子视图负责。


译自 Integrating MapKit with SwiftUI】

将 MapKit 集成进 SwiftUI 应用

从 2007 年的第一个代 iPhone 开始,地图就是一个核心的特性,其支撑的 framework 也从那个开始就对开发者开放。这个框架被称为 MapKit。正如我们可以在 SwiftUI 中使用 UIKit ,我们同样可以在 SwiftUI 中使用 MapKit ,只要我们不介意稍稍引入一些额外的工作。是的,这意味着要引入 coordinator 。

让我们先从一些简单的工作开始吧。创建一个新的 SwiftUI 视图,名字叫 “MapView”,然后添加一句 MapKit 的 import 。这一次我们不会用到 UIViewControllerRepresentable 协议,因为 MapKit 并不使用视图控制器。

有一种经典的构建软件的模式叫 “MVC”,它把我们的代码分成三类对象,即 Model (我们的数据), View (我们的布局) 和 Controller (连接 Model 和 View 的代码)。 Apple 在 UIKit 和它的其他框架中采用了 MVC ,包括 MapKit,但是增加了一些有趣的改变:view controllers 。它们究竟是 views,controllers,两者都是,或者两者都不是呢? Apple 官方并没有给出答案,这也是为什么你会在 iOS 开发中看到大量 MVC 变体的原因。

当我开始教授 UIKit 时,我是通过向大家解释一个视图是一块布局,注入文本,按钮,图像,而一个视图控制器是一屏内容这样的方式开始的。随着你对 UIKit 知识的掌握,你会知道你其实可以在一屏上拥有许多视图控制器,不过我的方式对于刚开始学习的你是一种有助益的心智模型。

这些之所以很重要,因为我们用到过 UIImagePickerController ,它被设计用来展示一整屏的信息 —— 我们不会视图给它添加功能,因为它被设计时是预期以一种自包含单位的方式运作的。 作为对比,MapKit 提供了MKMapView,从名字上你也知道它是一个视图而不是一个视图控制器,这意味着它只做展示内容这件事。

这也是为什么我们在处理 MapKit 时不用 UIViewControllerRepresentable 的原因: MKMapView 用视图,所以我们需要用 UIViewRepresentable 。不过运作方式基本一致:我们需要实现 makeUIView() 方法和 updateUIView() 方法,这两个方法处理实例化地图视图和 SwiftUI 状态变化时地图视图的更新。不过, update 方法在视图中相比视图控制器充当了更重要的角色,因为 SwiftUI 代码和 UIView 对象之间需要有更多的交互代码 —— 因此我们在 view controller 里放空这个方法,但 view 里则很常用。

我们稍晚一些再来解决更新方法,现在我们解决 make 方法,这个方法会创建一个新的 MKMapView 并且返回它。

把 MapView 结构体改造成下面这样:

struct MapView: UIViewRepresentable {
    func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
        let mapView = MKMapView()
        return mapView
    }

    func updateUIView(_ view: MKMapView, context: UIViewRepresentableContext<MapView>) {
    }
}

在继续之前,我想想你展示一个 Swift 的小魔法,在前面的项目中,我介绍 UIViewControllerRepresentable 协议的时候,我们简单实用了 typealias 。这是 Swift 中给已经存在的类型起别名的方式,这么做可以让它们更容易记忆。

UIViewControllerRepresentable 和 UIViewRepresentable 都内建了别名。如果你右键跳转 UIViewRepresentable的定义,你会在这个协议的定义里面看到这样一行代码:

typealias Context = UIViewRepresentableContext<Self>

这行代码创建了一个叫 “Context” 的别名,每当 Swift 看见 Context的时候,它会视为 UIViewRepresentableContext<Self>,其中 Self代表我们正在处理的类型。实践中,这表明我们只需要写 Context 而不是UIViewRepresentableContext<MapView>,两者的含义一模一样。

回到 ContentView.swift 替换文本视图成下面的代码:

MapView()
    .edgesIgnoringSafeArea(.all)

在 Xcode 中预览地图的体验目前还不是很好,所以我建议你在模拟器中运行 app 以查看效果。你会发现你可以在地图点击和拖曳,如果你按住 Option 键,你会看到第二个虚拟的手指,以便你可以做缩放和旋转的操作。一行代码就能得到这些,还不赖哦。

当然,我们真正想要的是让地图来到生活实际中的某处地表,我们会在下一节解决这个问题。


译自 Communicating with a MapKit coordinator

用 MapKit coordinator 和 MapView 交互

把一个空的 MKMapView 嵌入 SwiftUI 只是小试牛刀,如果你真的想用地图干掉有用的事,那你需要引入一个 coordinator —— 这个类充当你的地图视图的委托,负责和 SwiftUI 之间交换数据。

就像使用 UIImagePickerController 一样,我们需要创建一个继承自NSObject的嵌套类,令它遵循我们的视图或者视图控制器要求的委托协议,并让它持有父亲结构体的引用以便回传数据给 SwiftUI 。

对于地图视图,我们关心的协议是 MKMapViewDelegate,因此我们可以立即着手写一个 coordinator 类。在MapView 类添加下面这样一个嵌套类:

class Coordinator: NSObject, MKMapViewDelegate {
    var parent: MapView

    init(_ parent: MapView) {
        self.parent = parent
    }
}

和 UIViewControllerRepresentable 协议相似,我们需要添加一个方法 makeCoordinator() ,它返回一个配置好的Coordinator 实例。下面的代码需要添加在 MapView 结构体这一级,它把自己传入 coordinator 的构造器,以便后者可以报告发生的事情。

func makeCoordinator() -> Coordinator {
    Coordinator(self)
}

然后,在 makeUIView() 方法中构造地图视图的地方,把 MKMapView 和我们的 coordinator 连接起来:

mapView.delegate = context.coordinator

这样一来的我们的配置就完成了,接下来我们需要给 coordinator 添加方法,以响应地图视图的活动。记住,coordinator 是地图视图的委托,这意味着只要有事发生,它被会通知 —— 比如地图发生移动的时候,地图开始加载或者完成加载的时候,或者当用户在地图上被定位的时候,又或者当地图被缩放的时候,等等。

MapKit 会自动检查 coordinator 类,以了解我们关心哪些通知。这个动作是通过函数签名实现的:用精确的函数名和参数列表来匹配,并调用它们。

为了演示这一点,我们将添加一个叫 mapViewDidChangeVisibleRegion() 的方法,它接收单一的 MKMapView 参数。是的,这个方法名非常长。不过,相信我, UIKit 里比这长的多的是。我个人最爱的 API (现在已经废弃了) ,叫做willAnimateSecondHalfOfRotationFromInterfaceOrientation()!

言归正传,把下面这个方法添加到 Coordinator 类中:

func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
    print(mapView.centerCoordinate)
}

上面这个方法会被每一次地图改变可见区域的时候被调用,所以移动,放大或者旋转都会触发。我们只是简单的把中心坐标打印出来,如果你是在模拟器里运行 app ,那你会在 Xcode 的输出窗口看到大量的坐标信息。

地图视图的 coordinator 还负责提供更多地图需要的信息。举个例子,我们可以向地图添加标注,作为想要交互的兴趣点。作为 model 数据,相对于数据的视觉呈现,它只有标题和坐标,因此地图视图想要渲染我们的标注时,它会向 coordinator 要这些数据。

为了演示这一点,我们需要修改 makeUIView() 方法,以便我们向地图发送一个伦敦市的标注,就像下面这样:

func makeUIView(context: Context) -> MKMapView {
    let mapView = MKMapView()
    mapView.delegate = context.coordinator

    let annotation = MKPointAnnotation()
    annotation.title = "London"
    annotation.subtitle = "Capital of England"
    annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: 0.13)
    mapView.addAnnotation(annotation)

    return mapView
}

MKPointAnnotation 是一个遵循 MKAnnotation 协议的类,它被 MapKit 用于显示标注。如果你需要,可以创建自己的标注类型,不过在这里MKPointAnnotation已经够用了,它让你提供标题,副标题和坐标。如果你好奇的话,CLLocationCoordinate2D 之所以以 “CL” 开头是因为它来自另一个 Apple 的框架,叫做 Core Location 。

加好标注后,你不用再做什么 app 就已经可以运行了,找一找伦敦在哪,你会看到一个标记,点击它能够展示我们的副标题。

如果你想自定义标注的外观,我们又需要回到 coordinator 。地图视图会在我们的 coordinator 里查找一个特定的方法,叫 mapView(_:viewFor:),如果存在就会调用它。这个方法创建一个自定义的标注视图,不过 Apple 还是给了我们优雅的实现,叫 MKPinAnnotationView。

往 Coordinator 类中添加一下代码:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    let view = MKPinAnnotationView(annotation: annotation, reuseIdentifier: nil)
    view.canShowCallout = true
    return view
}

如你所见,我们需要传给 MKPinAnnotationView 标记实例,然后设置 canShowCallout为真,以便点击这个标记时显示信息。

在结束地图介绍之前,我想简单提一下 reuseIdentifier 属性。创建视图的开销可能是昂贵的。因此在 SwiftUI 中有 Identifiable 协议,如果它能唯一标识视图,那么它就能知道哪些视图发生变化,哪些没有发生变化,以便最小化需要的工作。

像 UIKit 和 MapKit 这样的框架有类似概率的简单版本,叫做 reuse identifiers。这些标识可以是我们想用的任何字符串,被框架用来重用一个数组里的视图。我们可以用特定的 ID 来向框架请求一个视图,如果存在,就不需要重新创建。

上面我们指定了 nil作为 reuse identifier ,这意味着我们不想要重用这个视图。鉴于我们正在学习,这样做没关系,不过之后我会向你展示更高效的方式,即重用视图。


扩展

接下来我们对传给 MapView 结构体的数据采用 @Binding ,以存储这个值。 Coordinator 会从 MapKit 接收到这个值,然后传给 MapView,然后 MapView把这个值放进一个 @Binding 属性,也就是把它另存起来了。

先往 MapView 里添加一个属性:

@Binding var centerCoordinate: CLLocationCoordinate2D

这个操作会立刻破坏 MapView_Previews 结构体,因为这时它也需要提供这个绑定了。这里的预览实际没有用处,因为 MKMapView 在预览上不起作用,所以即使删了也没关系。或者,你可以添加一些样例数据,以便修正预览的代码:

extension MKPointAnnotation {
    static var example: MKPointAnnotation {
        let annotation = MKPointAnnotation()
        annotation.title = "London"
        annotation.subtitle = "Home to the 2012 Summer Olympics."
        annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: -0.13)
        return annotation
    }
}

有了上面的代码,修正 MapView_Previews:

struct MapView_Previews: PreviewProvider {
    static var previews: some View {
        MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate))
    }
}

稍后我们会添加更多东西,在这之前我们先把它放进 ContentView。如果用户想要把他们想去的地点添加到地图上,我们需要在地图上用一个半透明的圆表示。这里简单采用一个ZStack 确保目标点总是在地图中央。

在 ContentView 里添加一个存储当前地图的中央坐标的属性。稍后我们会用它来添加地点标记:

@State private var centerCoordinate = CLLocationCoordinate2D()

接下来填充 body 属性:

ZStack {
    MapView(centerCoordinate: $centerCoordinate)
        .edgesIgnoringSafeArea(.all)
    Circle()
        .fill(Color.blue)
        .opacity(0.3)
        .frame(width: 32, height: 32)
}

当你运行 app ,你会发现你可以自由地在地图上移动,但是中央失踪有一个蓝色半透明的圆浮在中央。

如果我们需要蓝圈保持在中央的话,我们需要centerCoordinate 属性随着地图的移动而更新。 在 mapViewDidChangeVisibleRegion() 方法中改造:

func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
    parent.centerCoordinate = mapView.centerCoordinate
}

接下来我们要在右下角增加一个按钮,以便我们在地图添加地点标记。因为我们已经处于 ZStack 中,最便捷的方式是利用一个 VStack 加一个 HStack以及 spacer 将这个按钮做对齐。Spacer 会吃掉竖向或者横向剩余的所有空间,使得按钮处于右下角。

按钮的功能我们稍后添加,我们先处理一下它的样式:

下面的代码添加到刚才的圆下面:

VStack {
    Spacer()
    HStack {
        Spacer()
        Button(action: {
            // create a new location
        }) {
            Image(systemName: "plus")
        }
        .padding()
        .background(Color.black.opacity(0.75))
        .foregroundColor(.white)
        .font(.title)
        .clipShape(Circle())
        .padding(.trailing)
    }
}

注意这里我们添加了两次 padding() modifier —— 第一次用在添加背景色之前增大按钮,第二次用来把按钮从角落里推出来一些。

接下来比较有趣,我们在地图上放置大头针。前面我们已经绑定了一个属性到地图视图,但发送坐标需要用别的方式。

第一部分很显然,我们需要一个位置的数组,存放所有用户想去的地点。

往ContentView里加上这个属性:

@State private var locations = [MKPointAnnotation]()

接下来,每当按钮被点击时添加一个按钮。我们先不管地点的标题和副标题,只需要简单创建一个 MKPointAnnotation 然后利用centerCoordinate作为坐标。

let newLocation = MKPointAnnotation()
newLocation.coordinate = self.centerCoordinate
self.locations.append(newLocation)

接下来是挑战的部分:我们怎么同步地图视图呢?

updateUIView() 在这里派上用场了:SwiftUI 会在有任何数据被传入 UIViewRepresentable 结构体导致它改变时自动调用这个方法,这个方法负责同步视图的最新状态。

在我们的案例中,我们发送了 centerCoordinate 绑定给 MapView,这意味着每当用户在地图中移动导致这个值改变,会一直触发 updateUIView() 。 之前这个过程一直安静地发生,因为 updateUIView() 那时候还是空的。简单添加一句 print()调用:

func updateUIView(_ view: MKMapView, context: Context) {
    print("Updating")
}

在移动地图,你会看到 “Updating” 不断地被打印。

我们把 locations 数组传给 MapView ,让它利用数组里的点为我们插入标记。

因此,MapView里也需要声明持有所有地点的标记:

var annotations: [MKPointAnnotation]

MapView_Previews 也需要更新一遍发送样例标记点。

MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate), annotations: [MKPointAnnotation.example])

然后,我们还需要实现updateUIView() 它把当前标记和上一次标记做比较,如果不一样则替换。当然,我们可以逐一比较两个数组里的元素,但其实可以不用这么做。因为我们不可能同时添加或者删除标记,所以我们只需要简单比较前后两次数组的长度就知道变化是否发生。如果发生了,清空旧数组,重新填满就行。

修改 updateUIView()方法如下:

func updateUIView(_ view: MKMapView, context: Context) {
    if annotations.count != view.annotations.count {
        view.removeAnnotations(view.annotations)
        view.addAnnotations(annotations)
    }
}

最后,更新 ContentView,让它把 locations 数组发给地图:

MapView(centerCoordinate: $centerCoordinate, annotations: locations)

地图的介绍就到此为止。现在你可以在地图上随便移动,点击按钮来添加地点标记了。

你可以留意一下,当两个标记很近的时候,iOS 会自动合并这些大头针。举个例子,如果在 1 公里以内的范围放置了好几枚标记, iOS 会将其中的一些隐藏以便影响地图的阅读。


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~

5af135a87beb3e637e7cf74a740ddf1f.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值