UIKit和SwiftUI的混合使用

1,727 阅读7分钟

swiftUI中已经提供了很多基础的原生功能,但是还是有很多无法直接通过原生swiftUI来完成的,工程中开发者仍需要在swiftUI中依赖UIKit或AppKit代码。而swiftUI也为开发者提供了便捷的方式将UIKit(AppKit)视图或控制器包装成swiftUI视图。

SwiftUI 和 UIKit(AppKit)视图中的异同

swiftUI和UIKit或AppKit中的主要区别之一就是

  • SwiftUI中视图是值类型,并不负责对视图进行绘制,只是视图的描述,实际上并不渲染它们。
  • UIKit或AppKit中视图为引用类型,并对视图进行绘制和渲染
  • UIKit或AppKit中有明确的生命周期节点,ViewDidLoad, viewWillAppear等,SwiftUI中的视图没有明确的生命周期,它提供了几个Modifier来实现UIKit或AppKit中钩子方法的行为,onAppearonDisappear等。

将UIKit视图包装成SwiftUI视图非常简单,只需要遵守一个UIViewRepresentable协议即可,需要实现对应的协议方法,其中 makeUIViewupdateUIView是必须要实现的。且UIViewRepresentable协议本身是遵守View协议,因此SwiftUI会将任何符合该协议的视图都当做一般的SwiftUI视图来处理。

1.SwiftUI中使用UIKit

定义结构体遵循UIViewRepresentable,对UIViewRepresentable中方法执行顺序进行测试,

struct ActivityIndicator: UIViewRepresentable {
    typealias UIViewType = UIActivityIndicatorView
    
    var style: UIActivityIndicatorView.Style = .medium
    
    init(_ style: UIActivityIndicatorView.Style = .medium) {
        self.style = style
        print("init 111")
    }
    
    func makeUIView(context: Context) -> UIActivityIndicatorView {
        print("makeUIView 222")
        let view = UIActivityIndicatorView()
        view.color = .black
        view.style = self.style
        return view
    }
    
    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        print("updateUIView 333")
        uiView.startAnimating()
    }
    
    func makeCoordinator() -> () {
        print("makeCoordinator 444")
    }
    
    static func dismantleUIView(_ uiView: UIActivityIndicatorView, coordinator: ()) {
        print("dismantleUIView 555")
    }
}
//函数执行顺序
init 111
makeCoordinator 444
makeUIView 222
updateUIView 333
updateUIView 333
dismantleUIView 555

  • Init

视图初始化声明,在视图生命周期中只会调用一次

  • makeCoordinator

如果我们声明了Coordinator,UIViewRepresentable视图会在初始化后首先创建它的实例,以便在其他的方法中调用,Coordinator默认为Void,该方法在UIViewRepresentable的生命周期内只会调用一次,也只有一个协调器实例。

  • makeUIView

创建一个包装UIView的容器,该方法在UIViewRepresentable生命周期中只会调用一次

  • updateUIView

SwiftUI会在视图状态发生变化的时候更新受这些视图状态的视图,当UIViewRepresentable视图中的注入依赖发生变化时,swiftUI会调用updateUIView,调用时机同swiftUI视图的body一致,最大的不同为调用body为计算值,而调用updateView仅为通知UIViewRepresentable视图依赖有变化,在这里决定是否要处理这些依赖变化。

该方法在UIViewRepresentable生命周期中会多次调用,直到视图被移出视图树。在makeUIView方法执行后 updateUIView方法必然会执行一次

  • dismantleUIView

UIViewRepresentable视图被移出视图树之前,swiftUI会调用该方法,通常在该方法中执行删除观察器等善后操作

  • sizeThatFits

返回视图需要的尺寸,该方法 iOS16 以后才可用

上面实现的视图是对UIKit的UIActivityIndicatorView进行包装,展示一个选装的菊花

同样的将AppKit包装为SwiftUI对应的视图 NSViewRepresentable 对应 NSView,NSViewContrllerRepresentable对应NSViewController,其内部的实现结构和逻辑都是一致。

makeCoordinator(协调器) 作用

  • 实现UIKit视图的代理

  • 同swiftUI框架进行沟通

  • 处理UIKit视图中的复杂逻辑

接下来 对UIKit中的TextField进行包装,使用makeCoordinator对TextField的代理方法和键盘按钮点击事件进行相应,直接设置如下,展示一个TextField,并对当前输入的文字,实时同步到Text中

struct ZJTextFieldTest: View {
    
    @State private var inputText:String = ""
    
    var body: some View {
        VStack{
            Text("测试展示数据")
            TextFieldWrapper("请输入", text: $inputText)
                .border(.blue)
                .padding()
            
            Text("修改同步展示")
            Text(inputText)
        }
    }
}


struct TextFieldWrapper: UIViewRepresentable {
    /// 对UITextField进行包装
    typealias UIViewType = UITextField
    
    let placeholder: String
    @Binding var text: String
    
    init(_ placeholder: String,text: Binding<String>) {
        self.placeholder = placeholder
        self._text = text
    }
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.placeholder = placeholder
        textField.text = text
        /// UItextField在swiftUI中不进行约束的情况下回默认占据全部可用空间,需要尽心以下设置就可以达到预期的效果
        textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        
    }
    
    func makeCoordinator() -> () {
    
    }
    
    static func dismantleUIView(_ uiView: UITextField, coordinator: ()) {
        
    }
}

此时设置的View会充满整个屏幕,是因为TextField在不进行约束的情况下会占据全部的可用空间,进行如下设置会达到预期效果。

textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

进行设置后在UI上满足了我们,但是进行输入之后,并不会在下方同步展示输入文字,那是因为在包装的视图中并没有对绑定的text进行修改,外部也不会进行展示。UITextField在每次录入文字的时候都会自动调用func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool代理方法,需要在该方法中将对应的值回调回初时设置中。

1.创建Coordinator(协调器)
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
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            //点击发送  分行等 return按钮的回调
            
            return true
        }
    }
}
2.初始化Coordinator 设置TextField代理
 func makeUIView(context: Context) -> UITextField {
        ………………
        textField.delegate = context.coordinator
        ………………
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        //初始化 协调器
        .init(text: $text)
    }

此时输入文字,下方也同步展示输入文字

3.SwiftUI 更新TextField展示数据

在展示TextField视图中修改inputText的值,预期是 TextField和下方同步展示的Text都会修改,但是点击按钮的时候,只有同步的Text变化,TextField中并不会相应。

由于在makeUIView中设置了textField.text = text 该方法在生命周期内只会执行一次,swiftUI层中的inputText进行修改,TextField并不会同时修改。我们需要在updateUIView方法中更新text

func updateUIView(_ uiView: UITextField, context: Context) {
    uiView.text = text
}

这样就实现了 UItextField输入和swiftUI同步修改状态。上面这些操作基本上实现了TextField的输入和文字对外同步功能,但是TextField对外处理中和一些UIKit中常用的功能还需要对Coordinator进行修改,支持文字修改和事件回调.接下来添加如下属性设置和回调

let placeholder: String
@Binding var text: String
let color: UIColor
let font: UIFont
let returnKeyType: UIReturnKeyType
let clearButtonMode: UITextField.ViewMode
var onCommit: () -> Void
var onEditingChanged: (Bool) -> Void

/// 更新对应的展示视图
func updateUIView(_ uiView: UITextField, context: Context) {
    uiView.font = font
    uiView.textColor = color
    uiView.returnKeyType = returnKeyType
    uiView.clearButtonMode = clearButtonMode
    uiView.text = text
}

func makeCoordinator() -> Coordinator {
    .init(text: $text, onCommit:onCommit, onEditingChanged: onEditingChanged)
}

在协调器中设置对应的回调事件和TextField相应的代理方法

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    //点击发送  分行等 return按钮的回调
    onCommit()
    return true
}

func textFieldDidBeginEditing(_ textField: UITextField) {
    onEditingChanged(true)
}

func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
    onEditingChanged(false)
}

这样我们在初始化方法中进行对应属性的设置就可以监听TextField 输入和点击return 按钮

TextFieldWrapper("请输入", 
                 text: $inputText,
                 color:.red,
                 font: .systemFont(ofSize: 18),
                 onCommit: {
                    print("点击提交")
                },
                 onEditingChanged: { value in
                    print("编辑状态变化")
                })
.border(.blue)
.padding()
4.swiftUI 风格化处理

上面在TextFieldWrapper初始化时需要传入很多的参数,对比swiftUI设置属性的链式语法显的很异类,而且随着功能的逐渐增加,初始化越来越冗余,接下来将上面的属性设置包装swiftUI化,

    /// swiftUI 支持的风格
    TextFieldWrapper(text: $inputText)
        .font(.systemFont(ofSize: 18))
        .color(.red)
        .returnKeyType(.done)
        .onCommit {
            print("点击提交")
        }
        .onEditingChanged { value in
            print("编辑状态变化(value)")
        }
        .border(.blue)
        .padding()
    /// 传值设置    
    TextFieldWrapper("请输入",
             text: $inputText,
             color:.red,
             font: .systemFont(ofSize: 18),
             onCommit: {
                print("点击提交")
            },
             onEditingChanged: { value in
                print("编辑状态变化")
            })
        .border(.blue)
        .padding()

支持swiftUI风格 变量设置为private,通过对应的方法对变量进行修改

extension TextFieldWrapper {
    //swiftUI风格写法
    func color(_ color: UIColor) -> Self {
        then { $0.color = color }
    }
    
    func font(_ font: UIFont) -> Self {
        then{ $0.font = font }
    }
    
    func placeholder(_ placeholder: String) -> Self {
        then{ $0.placeholder = placeholder }
    }
    
    func returnKeyType(_ returnKeyType: UIReturnKeyType) -> Self {
        then { $0.returnKeyType = returnKeyType }
    }
    
    func clearButtonMode(_ clearButtonMode: UITextField.ViewMode) -> Self {
        then { $0.clearButtonMode = clearButtonMode }
    }
    func onCommit(_ task: @escaping () -> ()) -> Self {
        then { $0.onCommit = task }
    }
    
    func onEditingChanged(_ task: @escaping (Bool) -> ()) -> Self {
        then { $0.onEditingChanged = task }
    }
}

/// 对View的拓展 支持链式语法
extension View {
    func then(_ body: (inout Self) -> Void) -> Self {
        var result = self
        body(&result)
        return result
    }
}

对于onCommit和onEditingChanged 可以通过在内部修改协调器内的变量的方法进行,这是一种非常有效的方法。

func updateUIView(_ uiView: UITextField, context: Context) {
    uiView.font = font
    uiView.textColor = color
    uiView.returnKeyType = returnKeyType
    uiView.clearButtonMode = clearButtonMode
    uiView.text = text
    //修改协调器内部变量
    context.coordinator.onCommit = onCommit
    context.coordinator.onEditingChanged = onEditingChanged
}

回过头来看UIViewRepresentable中的协议方法和对UITextField的包装过程

截屏2024-07-23 16.35.30.png

同理对应UIViewControllerRepresentable协议也是如此,将UIViewController进行包装可以在SwiftUI中直接使用。

2.UIKit或AppKit中使用SwiftUI

UIKit要使用SwiftUI中视图,需要借助UIHostingController

AppKit中使用SwiftUI中视图需要借助NSHostingView或NSHostingController

接下来我们以UIKit中UIHostingController为例

1.创建SwiftUI视图

struct SwiftUITestPage: View {
    var title: String = "我就是我不一样的烟火"
    var textColor: Color = .black
    /// 点击回调方法
    var onSubmit: (() -> ())?
    
    var body: some View {
        VStack{
            Text(title)
                .font(.system(size: 14))
                .foregroundStyle(textColor)
                .padding()
            
            Button {
                print("按钮开始点击了")
                onSubmit?()
            } label: {
                Text("我是一个按钮")
                    .padding()
                    .font(.system(size: 14))
                    .foregroundStyle(textColor)
                    .frame(width: 150,height: 40)
                    .background(.teal)
                    .clipShape(RoundedRectangle(cornerRadius: 20))
                    .overlay {
                        RoundedRectangle(cornerRadius:20).strokeBorder(.blue, style: StrokeStyle(lineWidth:1))
                    }
            }

        }
    }
}

swiftUI 视图中 定义了一个title,color和按钮点击事件回调,

2.UIKit中包装SwiftUI

 func addSwiftUI(){
    //展示SwiftUi 视图
    let hostController = UIHostingController(rootView: SwiftUITestPage())
    addChild(hostController)
    self.view.addSubview(hostController.view)
    
    hostController.view.snp.makeConstraints { make in
        make.size.equalTo(CGSize(width: 200, height: 200))
        make.center.equalToSuperview()
    }
}

展示如下

3.UIKit和SwiftUI视图交互

在UIKit中我们定义一个可以改变titile和 color的点击事件,SwiftUI视图中同样定义了一个回调方法,在UIKit监听SwiftUI中 按钮的点。修改titile和color 是通过对UIHostingController中的rootView的修改,其实就是重新创建SwiftUI视图,在swiftUI中创建视图是很轻量的,SwiftUI中视图是值类型的且只是负责对UI样式的声明,占用内存少,创建几乎没有开销。

var hostVC: UIHostingController<SwiftUITestPage>!

// 事件接收
let swiftUICallClosure: () -> () = {
    print("按钮事件点击===1111")
}

@objc func buttonAction(sender: UIButton){
    if sender.tag == 1 {
        hostVC.rootView = recreateSwiftUIPage(title: "我就是我------")
    }else{
        hostVC.rootView = recreateSwiftUIPage(color: .red)
    }
}

/// 每次都是创建新的SwiftUI视图,回调执行保持不变
func recreateSwiftUIPage(title: String? = nil, color: Color? = nil) -> SwiftUITestPage {
    SwiftUITestPage(title: title, textColor: color, onSubmit: swiftUICallClosure)
}

sync_module3.gif

这样修改下来会有一个弊端,每次修改title和color,之前修改的样式又会回到初始状态,这就需要我们在创建swiftUI视图的时候,同步保存修改的数据。对其进行修改如下(简单使用)

let titles = ["123我来了","大哥火箭走一波","哈哈哈哈","测试是不是好了","我自横刀向天笑,去留肝胆两昆仑","感时花溅泪,恨别鸟惊心"]
let colors: [Color] = [.red,.green,.pink,.purple,.accentColor,.brown,.cyan]
var testModel: SwiftTestModel = SwiftTestModel(title: "我是初始值", textColor: .black)


///每次修改属性值 都重新创建SwiftUI
@objc func buttonAction(sender: UIButton){
    if sender.tag == 1 {
        testModel.title = titles[Int.random(in: 0..<titles.count)]
    }else{
        testModel.textColor = colors[Int.random(in: 0..<colors.count)]
    }
     hostVC.rootView = recreateSwiftUIPage()
}

func recreateSwiftUIPage() -> SwiftUITestPage {
    SwiftUITestPage(testModel: testModel, onSubmit: swiftUICallClosure)
}

这样每次修改都会保存最新的值,创建SwiftUI视图就不会回到初始状态

sync_module3.gif

由于系统只提供了UIHostingController这个容器,需要先把对应的swiftUI视图包装成ViewController才能使用,之后通过rootView获取到当前的swiftUI视图设置约束 当做UIView 来使用。

同理 AppKit中 提供了NSHostingViewNSHostingController来分别包装NSViewNSViewController

参考文档

在 SwiftUI 中使用 UIKit 视图 | 肘子的 Swift 记事本