访问 SwiftUI 内部的 UIKit 组件

3,095 阅读4分钟
更多内容,欢迎关注公众号 「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构建的。相似地,ImageButton也不是基于UIImageViewUIButton构建的。因此,我们无法访问它们底层的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)!]
            }
        }
    }
}

通过检视TabViewNavigationView,我们能够修改它们对应的 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。

进一步的,你还可以采用一样的方法定制 StepperSliderDatePicker

当然,我相信 Apple 会在未来的版本给 SwiftUI 赋予更强大的功能和更灵活的 API。在这之前,你可以借助这种思路,释放原来的 UIKit API 的定制能力。



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