在SwiftUI中使用UIKit视图
如想获得更好的阅读体验可以访问我的博客www.fatbobman.com,或点击下方的阅读原文
已迈入第三个年头的SwiftUI相较诞生初始已经提供了更多的原生功能,但仍有大量的事情是无法直接通过原生SwiftUI代码来完成的。在相当长的时间中开发者仍需在SwiftUI中依赖UIKit(AppKit)代码。好在,SwiftUI为开发者提供了便捷的方式将UIKit(AppKit)视图(或控制器)包装成SwiftUI视图。
本文将通过对UITextField的包装来讲解以下几点:
•如何在SwiftUI中使用UIKit视图•如何让你的UIKit包装视图具有SwiftUI风格•在SwiftUI使用UIKit视图需要注意的地方
如果你已经对如何使用
UIViewRepresentable
有所掌握,可以直接从SwiftUI风格化
部分阅读
基础
在具体演示包装代码之前,我们先介绍一些与在SwiftUI中使用UIKit视图有关的基础知识。
无需担心是否能立即理解下述内容,在后续的演示中会有更多的内容帮助你掌握相关知识。
生命周期
SwiftUI同UIKit和AppKit的主要区别之一是,SwiftUI的视图(View)是值类型,并不是对屏幕上绘制内容的具体引用。在SwiftUI中,开发者为视图创建描述,而并不实际渲染它们。
在UIKit(或AppKit)中,视图(或视图控制器)有明确的生命周期节点,比如vidwDidload
、loadView
、viewWillAppear
、didAddSubView
、didMoveToSuperview
等方法,它们本质上充当了钩子的角色,让开发者能够通过执行一段逻辑来响应系统给定的事件。
SwiftUI的视图,本身没有清晰(可适当描述)的生命周期,它们是值、是声明。SwiftUI提供了几个修改器(modifier)来实现类似UIKit中钩子方法的行为。比如onAppear
同viewWillAppear
的表现很类似。同UIKit的钩子方法的位置有很大的不同, onAppear
和onDisappear
是在当前视图的父视图上声明的。
将UIKit视图包装成SwiftUI的视图时,我们需要了解两者生命周期之间的不同,不要强行试图找到完全对应的方法,要从SwiftUI的角度来思考如何调用UIKit视图。
UIViewRepresentable协议
在SwiftUI中包装UIView非常简单,只需要创建一个遵守UIViewRepresentable
协议的结构体就行了。
UIViewControllerRepresentable
对应UIViewController
,NSViewRepresentable
对应NSView
,NSViewControllerRepresentable
对应NSViewController
。内部的结构和实现逻辑都一致。
UIViewrepresentable
的协议并不复杂,只包含:makeUIView
、updateUIView
、dismantleUIView
和makeCoordinator
四个方法。makeUIView
和updateUIView
为必须提供实现的方法。
UIViewRepresentable
本身遵守View
协议,因此SwiftUI会将任何符合该协议的结构体都当作一般的SwiftUI视图来对待。不过由于UIViewRepresentable
的特殊的用途,其内部的生命周期又同标准的SwiftUI视图有所不同。
UIViewRepresentableLifeCycle
•makeCoordinator如果我们声明了Coordinator(协调器),UIViewRepresentable
视图会在初始化后首先创建它的实例,以便在其他的方法中调用。Coordinator默认为Void
,该方法在UIViewRepresentable
的生命周期中只会调用一次,因此只会创建一个协调器实例。•makeUIView创建一个用来包装的UIKit视图实例。该方法在UIViewRepresentable
的生命周期中只会调用一次。•updateUIViewSwiftUI会在应用程序的状态(State)发生变化时更新受这些变化影响的界面部分。当UIViewRepresentable
视图中的注入依赖发生变化时,SwiftUI会调用updateUIView
。其调用时机同标准SwiftUI视图的body
一致,最大的不同为,调用body
为计算值,而调用updateview
仅为通知UIViewRepresentable
视图依赖有变化,至于是否需要根据这些变化来做反应,则由开发者来自行处理。该方法在UIViewRepresentable
的生命周期中会多次调用,直到视图被移出视图树(更准确地描述是切换到另一个不包含该视图的视图树分支)。在makeUIVIew执行后,updateUIVew必然会执行一次•dismantleUIView在UIViewRepresentable
视图被移出视图树之前,SwiftUI会调用dismantleUIView
,通常在此方法中可以执行u删除观察器等善后操作。dismantleUIView
为类型方法。
下面的代码将创建一个同ProgressView一样的转圈菊花:
代码语言:javascript复制struct MyProgrssView: UIViewRepresentable { func makeUIView(context: Context) -> UIActivityIndicatorView { let view = UIActivityIndicatorView() view.startAnimating() return view } func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {}}struct Demo: View { var body: some View { MyProgrssView() }}
黑匣子
SwiftUI在绘制屏幕时,会从视图树的顶端开始对视图的body
求值,如果其中还包含子视图则将递归求值,直到获得最终的结果。但SwiftUI无法真正进行无限量的调用来绘制视图,因此它必须以某种方式缩短递归。为了结束递归,SwiftUI包含了很多的原始类型(primitive types)。当SwiftUI递归到这些原始类型时,将结束递归,它将不再关心原始类型的body
,而让原始类型自行对其管理的区域进行处理。
SwiftUI框架通过将body
定义为Never
来标记该View
为原始类型。UIViewRepresentable
恰巧也为其中之一(Text
、ZStack
、Color
、List
等也都是所谓的原始类型)。
public protocol UIViewRepresentable : View where Self.Body == Never
事实上几乎所有的原始类型都是对UIKit或AppKit的底层包装。
UIViewRepresentable
作为原始类型,SwiftUI对其内部所知甚少(因为无需关心)。通常需要开发者在UIViewRepresentable
视图的Coordinator(协调器)中做一些的工作,从而保证两个框架(SwiftUI同UIKit)代码之间的沟通和联系。
协调器
苹果框架很喜欢使用协调器(Coordinator)这个名词,UIKit开发中有协调器设计模式、Core Data中有持久化存储协调器。在UIViewRepresentable
中协调器同它们的概念完全不同,主要起到以下几个方面的作用:
•实现UIKit视图的代理UIKit组件通常依赖代理(delegate)来实现一些功能,“代理”是响应其他地方发生的事件的对象。例如,UIKit中我们将一个代理对象附加到Text field
视图上,当用户输入时,当用户按下return
键时,该代理对象中对应的方法将被调用。通过将协调器声明为UIKit视图对应的代理对象,我们就可以在其中实现所需的代理方法。•同SwiftUI框架保持沟通上文中,我们提到UIViewRepresentable
作为原始类型,需要主动承担更多的同SwiftUI框架或其他视图之间的沟通工作。在协调器中,我们可以通过双向绑定(Binding
),通知中心(notificationCenter
)或其他例如Redux
模式的单项数据流等方式,将UIKit视图内部的状态报告给SwiftUI框架或其他需要的模块。同样也可以通过注册观察器、订阅Publisher等方式获取所需的信息。•处理UIKit视图中的复杂逻辑在UIKit开发中,通常会将业务逻辑放置在UIViewController中,SwiftUI没有Controller这个概念,视图仅是状态的呈现。对于一些实现复杂功能的UIKit模组,如果完全按照SwiftUI的模式将其业务逻辑彻底剥离是非常困难的。因此将无法剥离的业务逻辑的实现代码放入协调器中,靠近代理方法,便于相互之间的协调和管理。
包装UITextField
本节中我们将利用上面的知识实现一个具有简单功能的UITextField
包装视图——TextFieldWrapper
。
版本1.0
在第一个版本中,我们要实现一个类似如下原生代码的功能:
代码语言:javascript复制TextField("name:",text:$name)
image-20210822184949860
查看源代码
我们在makeUIView
中创建了UITextField
的实例,并对其placeholder和text进行了设定。在右侧的预览中,我们可以看到placeholder可以正常显示,如果你在其中输入文字,表现的状态也同TextField
完全一致。
通过.border
,我们看到TextFieldWrapper的视图尺寸没有符合预期,这是由于UITextField在不进行约束的情况下会默认占据全部可用空间。上文关于UIActivityIndicatorView
的演示代码并没有出现这个情况。因此对于不同的UIKit组件,我们需要了解其默认设置,酌情对其进行约束设定。
在makeUIView
中添加如下语句,此时文本输入框的尺寸就和预期一致了:
textfield.setContentHuggingPriority(.defaultHigh, for: .vertical) textfield.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
稍微调整一下Demo
视图,在.padding()
下添加Text("name:(name)")
。如果按照TextField
的正常行为,当我们在其中输入任何文本时,下方的Text
中应该显示出对应的内容,不过在我们当前的代码版本中,并没有表现出预期的行为。
image-20210822190605447
让我们再次来分析一下代码。
尽管我们声明了一个Binding<String>
类型的text
,并且在makeUIView
中将其赋值给了textfield
,不过UITextField
并不会将我们录入的内容自动回传给Binding<String>
的text
,这导致Demo
视图中的name
并不会因为文字录入而发生改变。
UITextfield
在每次录入文字时,都会自动调用func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool
的代理方法。因此我们需要创建协调器,并在协调器中实现该方法,将录入的内容传递给Demo
视图中的name
变量。
创建协调器:
代码语言:javascript复制extension TextFieldWrapper{ class Coordinator:NSObject,UITextFieldDelegate{ @Binding var text:String init(text:Binding<String>){ self._text = text } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if let text = textField.text as NSString? { let finaltext = text.replacingCharacters(in: range, with: string) self.text = finaltext as String } return true } }}
我们需要在textField
方法中回传数据,因此在Coordinator
中同样需要使用到Binding<String>
,如此对text
的操作即为对Demo
视图中name
的操作。
如果UIViewRepresentable
视图中的Coordinator
不为Void
,则必须通过makeCoordinator
来创建它的实例。在TextFieldWrapper
中添加如下代码:
func makeCoordinator() -> Coordinator { .init(text: $text) }
最后在makeUIView
中添加:
textfield.delegate = context.coordinator
UITextField在发生特定事件后将在协调器中查找并调用对应的代理方法。
image-20210822191834883
查看源代码
至此,我们创建的UITextField
包装已经同原生的TextField
的表现行为一致了。
你确定?
再度修改一下Demo
视图,将其修改为:
struct Demo: View { @State var name: String = "" var body: some View { VStack { TextFieldWrapper("name:", text: $name) .border(.blue) .padding() Text("name:(name)") Button("Random Name"){ name = String(Int.random(in: 0...100)) } } }}
按照对原生TextField
的表现预期,当我们按下Random Name
按钮时,Text
同TextFieldWrapper
中的文字都应该变成由String(Int.random(in: 0...100))
产生的随机数字,但是如果你使用上述代码进行测试,TextFieldWrapper
中的文字并没有变化。
在makeUIView
中,我们使用textfield.text = text
获取了Demo
视图中name
的值,但makeUIView
只会执行一次。当点击Random Name
引起name
变化时,SwiftUI将会调用updateUIView
,而我们并没有在其中做任何的处理。只需要在updateUIVIew
中添加如下代码即可:
func updateUIView(_ uiView: UIViewType, context: Context) { DispatchQueue.main.async { uiView.text = text } }
makeUIView
方法的参数中有一个context: Context
,通过这个上下文,我们可以访问到Coordinator
(自定义协调器)、transaction
(如何处理状态更新,动画模式)以及environment
(当前视图的环境值集合)。我们之后将通过实例演示其用法。该context
同样可以在updateUIVIew
和dismantleUIView
中访问。updataUIView
的参数_ uiView:UIViewType
为我们在makeUIVIew
中创建的UIKit视图实例。
查看源代码
现在,我们的TextFieldWrapper
的表现已经确实同TextField
一致了。
textFieldWrappertest
版本2.0——添加设定
在第一个版本的基础上,我们将为TextFieldWrapper
添加color
、font
、clearButtonMode
、onCommit
以及onEditingChanged
的配置设定。
考虑到尽量不将例程复杂化,我们使用
UIColor
、UIFont
作为配置类型。将SwiftUI的Color
和Font
转换成UIKit版本将增加不小的代码量。
color
、font
以及我们新增加的clearButtonMode
并不需要双向数据流,因此无需采用Binding
方式,仅需在updateView
中及时响应它们的变化既可。
onCommit
和onEditingChanged
分别对应着UITextField代理的textFieldShouldReturn
、textFieldDidBeginEditing
以及textFieldDidEndEditing
方法,我们需要在协调器中分别实现这些方法,并调用对应的Block
。
首先修改协调器:
代码语言:javascript复制extension TextFieldWrapper { class Coordinator: NSObject, UITextFieldDelegate { @Binding var text: String var onCommit: () -> Void var onEditingChanged: (Bool) -> Void init(text: Binding<String>, onCommit: @escaping () -> Void, onEditingChanged: @escaping (Bool) -> Void) { self._text = text self.onCommit = onCommit self.onEditingChanged = onEditingChanged } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if let text = textField.text as NSString? { let finaltext = text.replacingCharacters(in: range, with: string) self.text = finaltext as String } return true } func textFieldShouldReturn(_ textField: UITextField) -> Bool { onCommit() return true } func textFieldDidBeginEditing(_ textField: UITextField) { onEditingChanged(true) } func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { onEditingChanged(false) } }}
对TextFieldWrapper
进行修改:
struct TextFieldWrapper: UIViewRepresentable { init(_ placeholder: String, text: Binding<String>, color: UIColor = .label, font: UIFont = .preferredFont(forTextStyle: .body), clearButtonMode:UITextField.ViewMode = .whileEditing, onCommit: @escaping () -> Void = {}, onEditingChanged: @escaping (Bool) -> Void = { _ in } ) { self.placeholder = placeholder self._text = text self.color = color self.font = font self.clearButtonMode = clearButtonMode self.onCommit = onCommit self.onEditingChanged = onEditingChanged } let placeholder: String @Binding var text: String let color: UIColor let font: UIFont let clearButtonMode: UITextField.ViewMode var onCommit: () -> Void var onEditingChanged: (Bool) -> Void typealias UIViewType = UITextField func makeUIView(context: Context) -> UIViewType { let textfield = UITextField() textfield.setContentHuggingPriority(.defaultHigh, for: .vertical) textfield.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textfield.placeholder = placeholder textfield.delegate = context.coordinator return textfield } func updateUIView(_ uiView: UIViewType, context: Context) { DispatchQueue.main.async { uiView.text = text uiView.textColor = color uiView.font = font uiView.clearButtonMode = clearButtonMode } } func makeCoordinator() -> Coordinator { .init(text: $text,onCommit: onCommit,onEditingChanged: onEditingChanged) }}
修改Demo
视图:
struct Demo: View { @State var name: String = "" @State var color: UIColor = .red var body: some View { VStack { TextFieldWrapper("name:", text: $name, color: color, font: .preferredFont(forTextStyle: .title1), clearButtonMode: .whileEditing, onCommit: { print("return") }, onEditingChanged: { editing in print("isEditing (editing)") }) .border(.blue) .padding() Text("name:(name)") Button("Random Name") { name = String(Int.random(in: 0...100)) } Button("Change Color") { color = color == .red ? .label : .red } } }}struct TextFieldWrapperPreview: PreviewProvider { static var previews: some View { Demo() }}
查看源代码
textfieldwrapperdemo2
SwiftUI 风格化
我们不仅实现了对字体、色彩的设定,而且增加了原生TextField
没有的clearButtonMode
设置。按照上述的方法,可以逐步为其添加更多的设置,让TextFieldWrapper
获得更多的功能。
代码好像有点不太对劲?!
随着功能配置的增加,上面代码在使用中会愈发的不方便。如何实现类似原生TextFiled
的链式调用呢?譬如:
TextFieldWrapper("name:",text:$name) .clearMode(.whileEditing) .onCommit{print("commit")} .foregroundColor(.red) .font(.title) .disabled(allowEdit)
本节中,我们将重写配置代码,实现UIKit包装风格SwiftUI化。
本节以版本1.0结束时的代码为基础。
所谓的SwfitUI风格化,更确切地说应该是函数式编程的链式调用。将多个操作通过点号(.)链接在一起,增加可读性。作为将函数视为一等公民的Swift,实现上述的链式调用非常方便。不过有以下几点需要注意:
•如何改变View内的的值(View是结构)•如何处理返回的类型(保证调用链继续有效)•如何利用SwiftUI框架现有的数据并与之交互逻辑
为了更全面的演示,下面的例子,采用了不同的处理方式。在实际使用中,可根据实际需求选择适当的方案。
foregroundColor
我们在SwiftUI中经常会用到foregroundColor
来设置前景色,比如下面的代码:
VStack{ Text("hello world") .foregroundColor(.red) } .foregroundColor(.blue)
不知道大家是否知道上面的两个foregroundColor
有什么不同。
extension Text{ public func foregroundColor(_ color: Color?) -> Text}extension View{ public func foregroundColor(_ color: Color?) -> some View}
方法名一样,但作用的对象不同。Text
只有在针对本身的foregroundColor
没有设置的时候,才会尝试从当前环境中获取foregroundColor
(针对View)的设定。原生的TextFiled
没有针对本身的foregroundColor
,不过我们目前也没有办法获取到SwiftUI针对View的foregroundColor
设定的环境值(估计是),因此我们可以使用Text
的方式,为TextFieldWrapper
创建一个专属的foregroundColor
。
为TextFieldWrapper
添加一个变量
private var color:UIColor = .label
在updateUIView
中增加
uiView.textColor = color
设置配置方法:
代码语言:javascript复制extension TextFieldWrapper { func foregroundColor(_ color:UIColor) -> Self{ var view = self view.color = color return view }}
查看源代码
就这么简单。现在我们就可以使用.foreground(.red)
来设置TextFieldWrapper
的文字颜色了。
这种写法是为特定视图类型添加扩展的常用写法。有以下两个优点:
•使用private
,无需暴露配置变量•仍返回特定类型的视图,有利于维持链式稳定
我们几乎可以使用这种方式完成全部的链式扩展。如果扩展较多时,可以采用下面的方式,进一步清晰、简化代码:
代码语言:javascript复制 extension View { func then(_ body: (inout Self) -> Void) -> Self { var result = self body(&result) return result } } func foregroundColor(_ color:UIColor) -> Self{ then{ $0.color = color } }
disabled
SwiftUI针对View预设了非常多的扩展,其中有相当的部分都是通过环境值EnvironmentValue
来逐级传递的。通过直接响应该环境值的变化,我们可以在不编写特定TextFieldWrapper
扩展的情况下,即可为其增加配置功能。
例如,View
有一个扩展.disabled
,通常我们会用它来控制交互控件的可操作性(.disable
对应的EnviromentValue
为isEnabled
)。
在TextFieldWrapper
中添加:
@Environment(.isEnabled) var isEnabled
在updateUIView
中添加:
uiView.isEnabled = isEnabled
只需要两条语句,TextFieldWrapper
便可以直接使用View
的disable
扩展来控制其是否可以录入数据。
还记得上文中介绍的context
吗?我们可以直接通过context
获取上下文中的环境值。因此支持原生的View
扩展将一步简化。
无需添加@Environemnt
,只需要在updateUIView
中添加一条语句既可:
uiView.isEnabled = context.environment.isEnabled
查看源代码
在写本文时,在iOS15 beta下运行该代码,会出现
AttributeGraph: cycle detected through attribute
的警告,这个应该是iOS15的Bug,请自行忽略。
通过环境值来设置是一种十分便捷的方式,唯一需要注意的是,它会改变链式结构的返回值。因此,在该节点后的链式方法只能是针对View
设置的,像之前我们创建的foregroundColor
就只能放置在这个节点之前。
font
我们也可以自己创建环境值来实现对TextFieldWrapper
的配置。比如,SwiftUI提供的font
环境值的类型为Font
,本例中我们将创建一个针对UIFont
的环境值设定。
创建环境值myFont
:
struct MyFontKey:EnvironmentKey{ static var defaultValue: UIFont?}extension EnvironmentValues{ var myFont:UIFont?{ get{self[MyFontKey.self]} set{self[MyFontKey.self] = newValue} }}
在updateUIVIew
中添加:
uiView.font = context.environment.myFont
font
方法可以有多种写法:
•同forgroundColor
一样的对TextFieldWrapper
进行扩展
func font(_ font:UIFont) -> some View{ environment(.myFont, font) }
•对View
进行扩展
extension View { func font(_ font:UIFont?) -> some View{ environment(.myFont, font) }}
两种方式的链式节点的返回值都不再是TextFieldWrapper
,后面应该接针对View
的扩展。
查看源代码
onCommit
在版本2的代码中,我们为TextFieldWrapper
添加了onCommit
设置,在用户输入return
时会触发该段代码。本例中,我们将为onCommit
添加一个可修改版本,且不需要通过协调器构造函数传递。
本例中的技巧在之前都出现过,唯一需要提醒的是在updateUIView
中,可以通过
context.coordinator.onCommit = onCommitcontext.coordinator.onEditingChanged = onEditingChanged
改变协调器内的变量。这是一种非常有效的在SwiftUI和协调器之间进行沟通的手段。
image-20210823091321562
查看源代码
避免滥用UIKit包装
尽管在SwiftUI中使用UIKit或AppKit并不麻烦,但是当你打算包装一个UIKit控件时(尤其是已有SwiftUI官方原生解决方案),请务必三思。
苹果对SwiftUI的野心非常大,不仅为开发者带来了声明 响应式的编程体验,同时苹果对SwiftUI在跨设备、跨平台上(苹果生态)也做出了巨大的投入了。
苹果为每一个原生控件(比如TextField
),针对不同的平台(iOS、macOS、tvOS、watchOS)做了大量的优化。这是其他任何人都很难自己完成的。因此,在你打算为了某个特定功能重新包装一个系统控件时,请先考虑以下几点。
官方的原生方案
SwiftUI这几年发展的很快,每个版本都增加了不少新功能,或许你需要的功能已经被添加。苹果最近两年对SwiftUI的文档支持提高了不少,但还没到令人满意的地步。作为SwiftUI的开发者,我推荐大家最好购买一份javier开发的A Companion for SwiftUI。该app提供了远比官方丰富、清晰的SwiftUI API指南。使用该app你会发现原来SwiftUI提供了如此多的功能。
用原生方法组合解决
在SwiftUI 3.0版本之前,SwiftUI并不提供searchbar
,此时会出现两种路线,一种是自己包装一个UIKit的UISearchbar
,另外就是通过使用SwiftUI的原生方法来组合一个searchbar
。在多数情况下,两种方式都能取得满意的效果。不过用原生方法创建的searchbar
在构图上更灵活,同时支持使用LocalizedString
作为placeholder。我个人会更倾向于使用组合的方案。
SwiftUI中很多数据类型官方并不提供转换到其他框架类型的方案。比如
Color
、Font
。不过这两个多写点代码还是可以转换的。LocalizedString
目前只能通过非正常的手段来转换(使用Mirror
),很难保证可以长久使用该转换方式。
Introspect for SwiftUI
在版本2代码中,我们为TextFieldWrapper
添加了clearButtonMode
的设置,也是我们唯一增加的目前TextField
尚不支持的设定。不过,如果我们仅仅是为了添加这个功能就自己包装UITextField
那就大错特错了。
Introspect通过自省的方法来尝试查找原生控件背后包装的UIKit(或AppKit)组件。目前官方尚未在SwiftUI中开放的功能多数可以通过此扩展库提供的方法来解决。
比如:下面的代码将为原生的TextField
添加clearButtonMode
设置
import Introspect extension TextField { func clearButtonMode(_ mode:UITextField.ViewMode) -> some View{ introspectTextField{ tf in tf.clearButtonMode = mode } } } TextField("name:",text:$name) .clearButtonMode(.whileEditing)
总结
SwiftUI与UIKit和AppKit之间的互操作性为开发者提供了强大的灵活性。学会使用很容易,但想用好确实有一定的难度。在UIKit视图和SwiftUI视图之间共享可变状态和复杂的交互通常相当复杂,需要我们在这两种框架之间构建各种桥接层。
本文并没有涉及包装具有复杂逻辑代码的协调器同SwiftUI或Redux模式沟通交互的话题,里面包含的内容过多,或许需要通过另一篇文章来探讨。
希望本文能对你学习和了解如何将UIKit组件导入SwiftUI提供一点帮助。