SwiftUI 系列教程(2)—— 与 UIKit 结合的自定义视图

上一篇文章中,我们了解了 SwiftUI 的 Text 组件,并通过 Stack 系列的组件对内容进行了一些简单的布局。在这篇文章里,我们会认识一个全新的图片组件,并且会尝试利用这两篇文章的知识,结合 MapKit 框架,来实现一个简单的地点详情界面。

写完第一篇文章之后,本职的开发任务突然进入了紧张的预发布阶段,搞得早就写好的第二篇文章拖了这么久才完成润色和发布,看来“全网最早”要丢了…

自定义图片视图

首先把一张地标图片放到 Assets.xcassets 里去,我在百度找了张广州塔的照片:

然后,我们要为新的图片视图创建一个新的类,就放在上一篇文章的 ContentView.swift 旁边好了。新建文件的时候,选择 SwiftUI View

取个名字叫 CircleImage,因为我们将要在这里把广州塔裁剪成一个圆。新建的代码内容跟上一章看到的一样,我们把 Text 改成 Image,然后把图片的名字传进去,直接就可以通过预览在画布上看到我们的图片了:

接下来我们在代码里给它加上一个圆形的裁剪。原来的做法有很多了,最快速的做法应该是操作 clipsToBoundscornerRadius,给图片加上长度等于一半宽高的圆角,这还得要求图片是正方形的才能达到满意的显示效果。

而在 SwiftUI 里,这就是一句话的事情:

.clipShape() 给图片加了个裁剪的形状,其中 Circle 类型是一个用来当作遮罩的图形,你也可以给它加上填充色或者描边来单独使用,类似于以往通过 CALayer 去实现的效果。

但这也太大了,我们的屏幕装不下,我们可以再加两行代码,把图片缩放到一个合适的大小:

讲道理,这里设置的宽高应该是一样的,毕竟是个圆嘛…但是我懒得重新截图了,各位童鞋自己调整一下数值就可以了

为了让图片本身在不同背景下都能凸显出来,我们再给它加个描边,这要通过 overlay() 方法去实现;也许再加个阴影吧,用到的是 shadow() 方法;最后出来的效果是这样的:

是不是醒目多啦?

每当做完一个新视图,我就想对比一下用老方法实现同样的效果有什么不同…

在 SwiftUI 里使用 UIKit

不知道大家发现了没有,我们在 SwiftUI 里用到的视图全部都是 struct,这意味着它们跟我们原本熟悉的 UIKit 是两套不同的机制,那难道以前开发的视图就完全用不上了吗?

答案是可以的。

要在 SwiftUI 里使用 UIView 的子类,只需要把它用一个遵循 UIViewRepresentable 协议的 SwiftUI 视图包裹起来即可。

这里举的是 UIKit 的例子,但同样也适用于 AppKit 和 WatchKit。

我们再来创建一个新的 SwiftUI View 来做我们的地图界面,但这一次,我们要改一下内容视图的协议:

1
2
3
4
5
6
7
8
import SwiftUI
import MapKit // 引入 MapKit

struct MapView : UIViewRepresentable { // 把这里的协议改掉
var body: some View {
Text(“Hello World!”)
}
}

然后你会发现 Xcode 在 UIViewRepresentable 这里报错了,因为这个协议下有两个必须实现的方法:

  1. makeUIView(context:) 用来创建我们的 MKMapView
  2. updateUIView(_:context:) 用来进行视图的配置,并响应后续可能的变化

那下面我们就来实现一下。再加新代码之前,可以把已经用不上的 body 部分先删掉了。

对于 makeUIView(context:),只需要声明它返回的是 MKMapView 然后直接通过构造方法返回一个空对象就可以了:

1
2
3
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
MKMapView(frame: .zero)
}

updateUIView(_:context:) 要做的事情就比较多了,我们一步步说:

1
2
3
4
5
6
7
8
9
func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MapView>) {
// 1
let coordinate = CLLocationCoordinate2D(latitude: 23.112223, longitude: 113.331084)
// 2
let span = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
// 3
let region = MKCoordinateRegion(center: coordinate, span: span)
uiView.setRegion(region, animated: true)
}
  1. 先把表示广州塔坐标的对象给构造出来(这是我在网上查的,等会预览的时候看看准不准)
  2. 构造一个用来标识地图的缩放等级的对象,数值越小地图拉得越近
  3. 构造坐标区域,并把这个区域设置到我们的地图视图上

赶紧预览一下看看效果吧!你会发现画布上空白一片…

那是因为预览默认是静态模式的,它只能完整渲染 SwiftUI 的视图。因为我们现在用到了 UIView 的子类,所以要把预览切换到实时模式(右下角红框里的按钮):

emmmm…塔呢?这定位看起来也不是很准啊。

把一切都组装起来吧!

先看看预期实现的效果图:

然后花一点时间思考一下怎么弄,我们这两篇文章的知识是完全足够了的。

好啦!公布答案!

我们会在上一篇文章实现的 ContentView 里直接进行组装,下面来看看分解动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct ContentView: View {
var body: some View {
VStack { // 1
MapView()
.edgesIgnoringSafeArea(.top) // 2.1
.frame(height: 300) // 2.2

CircleImage()
.offset(y: -100)
.padding(.bottom, -100) // 3

VStack(alignment: .leading) {
Text(“Hello SwiftUI”)
.font(.title)
HStack(alignment: .top) {
Text(“First Description”)
.font(.subheadline)
Spacer()
Text(“Second Description”)
.font(.subheadline)
}
}
.padding()

Spacer() // 4
}
}
}
  1. 先用一个 VStack 把所有内容包裹起来,默认情况下 VStack 的内容布局是居中的,所以我们不需要做修改
  2. 针对 MapView 我们要做两个修改
    1. edgesIgnoringSafeArea 可以让我们的视图把系统预留给刘海和状态栏的区域也用掉,这样看起来会更自然一点
    2. 对于只设置了 height 的视图,它的宽度会默认占满所有父视图里的可用空间
  3. 我们的图片视图本身已经实现了所有效果了,现在只需要调整一下位置即可。需要注意的是,因为这里是通过 offset 移动的,所以为了保持与底部文字的间距,特意加上了一个负的 padding 来抵消掉位移导致的差距
  4. 做完前三步的操作后,这个 VStack 整体是在竖直方向上居中的,所以加上一个 Spacer 把整体有内容的部分顶到最上面(其实也可以通过 HStackalignment来实现,不过那样代码就没有现在的简单优雅了)

最终成果:

如果你发现照着实现出来之后,中间圆形部分特别大的话,别担心,你是对的!
因为文章前面的 CircleImage 确实是为了展示而特意设置得比较大的,所以调整一下里面的宽高即可。

小结

到这里大家应该对 SwiftUI 的使用比较上手了,但目前为止涉及到的组件还比较少,SwiftUI 光是各种强大的组件就已经够玩很久了。不过我打算在第四篇文章里再集中讲各种有意思的组件使用方式,因为下一篇文章我们要先解决数据来源的问题。

既然我们的视图组件已经是通过声明式的写法来构建的了,那我们的数据是不是也该换一种方式绑定到视图上呢?在 JS 上我们可以用 react-redux 这样的数据绑定手段,那 SwiftUI 是不是该搭配 RxSwift 来使用了?

这些问题都将在下一篇文章里为大家解答!

参考链接

SwiftUI | Creating and Combining Views
SwiftUI Essentials - WWDC 2019 - Videos - Apple Developer