更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀
已经学习并使用过 SwiftUI 一段时间的同学,可能会有这样的需求:想要禁用一个列表的滚动,在 SwiftUI 中要怎么实现?而熟悉 UIKit 的同学都知道,这在 UIScrollView 中是很简单的事情。
抛开 SwiftUI 尚不完备的工具不说,SwiftUI 的确因其构建 UI 的便捷性给开发者带来了兴奋。有一个令人欣慰的事实是,许多 SwiftUI 组件实际上是基于 UIKit 构建的。除此之外,SwiftUI 和 UIKit 的互操作性使得我们可以充分利用 UIViewRepresentable 和UIViewControllerRepresentable —— 这两者都是为了让你可以将 UIKit 组件移植到 SwiftUI 而存在的。
但这是我们大家已经知道的事情,那这篇文章的目的又是什么?
在接下来的几节,我将带你探索一个令人惊讶的 SwiftUI 库,它叫 Introspect (github.com/siteline/Sw…)。利用它,我们能够访问 SwiftUI 组件底层的 UIKit 视图。
我们会涉及下列主题:
- Introspect 库底层是如何工作的?
- 如何禁用一个 SwiftUI 的列表?
- 如何自定义 SwiftUI 里的 Segmented 风格的 Picker?
- 如何修改 NavigationView 和 TabView 的颜色?
- 如何让 SwiftUI 的 TextField 变成 first responder?
背后的原理
Introspect 库的工作方式是:通过添加一个自定义的 overlay 到视图层级里,以检视 UIKit 层级,找到相关的视图。
如果我的描述对你来说不是很好理解,让我们借助下面的步骤来进一步说明 introspec 库背后的原理。
- 第 1 步:给一个 SwiftUI List 添加一个 UIViewRepresentable overlay
- 第 2 步:找到它的 ViewHost (SwiftUI 会把每个 UIView 包裹进一个 ViewHost,然后放进一个 HostingView)
- 第 3 步:找到它在视图层级里的兄弟视图(就这样,我们从 SwiftUI.List 里拿到了 UITableView,接下来就可以利用这个 UITableView 的属性来定制 SwiftUI 的 List。)
基本上,我们是叠加了一个不可见的UIViewRepresentable到 SwiftUI 视图的上层,然后借助这个视图向内挖掘视图链,最后找到托管 SwiftUI 视图的UIHostingView。一旦我们拿到这个视图,就可以从中访问 UIKit 视图了。
不过,并非所有的 SwiftUI 视图都可以被检视。例如,SwiftUI 的Text就不是基于UILabel构建的。相似地,Image和Button也不是基于UIImageView和UIButton构建的。因此,我们无法访问它们底层的UIKit 视图 —— 因此它们根本就不存在。下面这个表格显示了可以被检视的 SwiftUI 视图。
接下来,让我们来看一些可以借助检视底层 UIKit 视图来构建 SwiftUI 里缺失的特性。
禁用 SwiftUI 列表滚动
SwiftUI 的 List 当前并没有一个isScrollEnabled属性可以让我们定制滚动行为,UITableView是有的。借助VStack + ForEach,我们也能实现无滚动的特性,但这样做有一个缺点: SwiftUI 列表或者UITableView的行可点击的效果缺失了。
相反,借助introspectTableView视图 modifier,我们在保留原生列表特性的同时,轻松禁用滚动,就像下面这样:

同样地,如果要隐藏 SwiftUI 列表元素间的分隔线,我们只需要简单地调用tableView.separatorColor = .none就可以了。
在 SwiftUI 中自定义Segmented 控件
SwiftUI 允许我们给Picker设置SegmentedPickerStyle,同时也有很多限制:自定义边框,半径,标题和背景都没法做到。
再一次,我们要借助底层的视图,来定制 SwiftUI 中 Segmented 控件的外观。在接下来的例子中,我们会移除 Segmented 控件里的圆角,并且设置一个边框颜色:
import SwiftUI
import Introspect
struct ContentView: View {
@State private var selectedIndex = 0
@State private var numbers = ["One", "Two", "Three"]
var body: some View {
VStack {
Picker("Numbers", selection: $selectedIndex) {
ForEach(0..<numbers.count) { index in
Text(self.numbers[index]).tag(index)
}
}
.pickerStyle(SegmentedPickerStyle())
.introspectSegmentedControl {
segmentedControl in
segmentedControl.layer.cornerRadius = 0
segmentedControl.layer.borderColor = UIColor.label.cgColor
segmentedControl.layer.borderWidth = 1.0
segmentedControl.selectedSegmentTintColor = .red
segmentedControl.setTitleTextAttributes([.foregroundColor:UIColor.white], for: .selected)
segmentedControl.setTitleTextAttributes([.foregroundColor:UIColor.red], for: .normal)
}
Text("选中的值:\(numbers[selectedIndex])").padding()
}
}
}预览效果如下:

自定义 NavigationView 和 TabView 的样式
修改 NavigationBar 中标题文本的颜色不是很直观,对于 TabView 也一样。有人可能建议在init方法里修改外观 —— 就像下面这样 —— 然后这并不是一个好的解决方案:
init() {
UINavigationBar.appearance().titleTextAttributes =
[.foregroundColor:UIColor.red]
UINavigationBar.appearance().backgroundColor = .green
UITabBar.appearance().backgroundColor = UIColor.blue
}这种实现方案实际上并不是定制了 NavigationView 或者 TabView。相反,它是全局覆盖了它们的外观。
对于这个需求,我们有更好的解决方案。比如,下面的代码片段就以一种更简明的方式修改 NavigationBar 的标题和背景色。
import SwiftUI
import Introspect
struct ContentView : View {
var body: some View {
NavigationView {
VStack {
Text("不使用 .appearance()")
}
.navigationBarTitle("标题", displayMode: .inline)
.introspectNavigationController{
navController in
navController.navigationBar.barTintColor = .blue
navController.navigationBar.titleTextAttributes = [
.foregroundColor: UIColor.white,
.font : UIFont(name:"Helvetica Neue", size: 20)!]
}
}
}
}通过检视TabView和NavigationView,我们能够修改它们对应的 UIKit 视图:
struct ContentView : View {
@State private var selection = 1
var body: some View {
NavigationView {
VStack {
Text("不使用 .appearance()")
TabView(selection: $selection) {
Text("第一屏")
.tabItem {
Image(systemName: "1.square.fill")
Text("第一屏")
}.tag(1)
Text("第二屏")
.tabItem {
Image(systemName: "2.square.fill")
Text("第二屏")
}.tag(2)
}
.accentColor(.white)
.introspectTabBarController { tabController in
tabController.tabBar.barTintColor = .blue
tabController.tabBar.isTranslucent = false
}
}
.navigationBarTitle("标题", displayMode: .inline)
.introspectNavigationController{
navController in
navController.navigationBar.barTintColor = .blue
navController.navigationBar.titleTextAttributes = [
.foregroundColor: UIColor.white,
.font : UIFont(name:"Helvetica Neue", size: 20)!]
}
}
}
}预览效果如下:

让 TextField 成为 First Responder
SwiftUI 当前没有提供自动弹出键盘的方法。除非我们做点什么,否则用户就得手动获取 TextField 的焦点。同样的,我们通过访问底层的UITextField,调用becomeFirstResponder函数来优化这个体验,像下面这样:
import SwiftUI
import Introspect
struct ContentView : View {
@State var text = ""
var body: some View {
VStack {
TextField("Enter some text", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.introspectTextField{
textField in
textField.becomeFirstResponder()
}
}
}
}总结
我们可以看到,检视 SwiftUI 底层的 UIKit 视图可以让我们突破某些 SwiftUI 组件的限制。比如,我们在文章中介绍了列表,segmented 风格的 Picker,还有 NavigationView,TabView 和 TextField。
进一步的,你还可以采用一样的方法定制 Stepper,Slider 和 DatePicker。
当然,我相信 Apple 会在未来的版本给 SwiftUI 赋予更强大的功能和更灵活的 API。在这之前,你可以借助这种思路,释放原来的 UIKit API 的定制能力。
我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~
