SwfitUI 进阶 – View 与 NSView 互相转换

SwfitUI 进阶 – View 与 NSView 互相转换

开发环境:

  • macOS 10.15.5
  • Xcode 11.7

我其实挺喜欢 SwiftUI 的,首先 Swift 语言代码简练,SwfitUI 可以直接用代码进行布局且方便预览,很符合现代开发的习惯。而不是像 Objective-C 那样,语句繁复,还要用 Xib 这种可视化工具进行拖拽布局。

但不幸的是,由于 SwfitUI 是2019年才正式发布的,所以还有很多欠缺的功能,以及莫名其妙的 Bug。为了弥补这些不足,我们可以将成熟的 AppKit 中的各种 NSView 控件包装成 SwfitUI.View 来使用。

实际上,Xcode 11 版本的 SwiftUI 程序,我们可以在 AppDelegate.swift 文件中看出其生命周期其实是依赖于 UIKit (iOS App) 或者 AppKit (macOS App) 的。不知道以后版本的 SwfitUI 是否能彻底抛弃这些底层依赖。

1、将 NSView 包装成 SwiftUI.View 来使用

1.1 NSViewRepresentable

a. makeNSView 与 updateNSView

直接上代码吧,假设我们想要包装一个 NSDatePicker 来使用。

import SwiftUI

struct ContentView: View {
    @State var date = Date()
    
    var body: some View {
        VStack {
            Text("\(date)")
            
            // SwiftUI.DatePicker 对比
            DatePicker(selection: $date, label: { Text("SwiftUI") })
            
            Divider()
            
            CustomNSDatePicker(date: $date)
            
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

struct CustomNSDatePicker: NSViewRepresentable {
    @Binding var date: Date
    
    // 构建 View 时调用该函数,该函数必须返回我们需要包装的 NSView 对象
    // 注意!不需要,也不应该将这个 NSView 对象设置成当前 NSViewRepresentable 子类的存储属性
    func makeNSView(context: Context) -> NSDatePicker {
        NSLog("makeNSView")
        
        let datePicker = NSDatePicker()
        datePicker.dateValue = date
        
        return datePicker
    }
    
    // 刷新 View 时调用该函数,在该函数中更新 NSView 对象
    func updateNSView(_ nsView: NSDatePicker, context: Context) {
        NSLog("updateNSView")
        
        nsView.dateValue = date
    }
}

其效果如下图所示,SwiftUI 中数据的变化,会通过 updateNSView 传递给 NSView。但是 NSView 中数据的变化,还无法传递给 SwiftUI。

b. 将 NSView 的数据变化传递给 SwiftUI

为了让 NSView 的数据变换传递给 SwiftUI,我们还需要用到 NSRepresentable.makeCoordinator()。来生成一个协同器,并在协同器中绑定要处理的数据。

根据我的理解,NSRepresentable 好像对这个协同器类 Coordinator 并没有做任何要求。类名可以不叫 Coordinator,可以不用继承任何父类或者实现任何协议。只需要保证在你自定义的 Coordinator 类中,其定义的属性与方法,能够实现将 NSView 中的数据交互到 SwiftUI 中即可。

修改上面的 CustomNSDatePicker:

struct CustomNSDatePicker: NSViewRepresentable {
    @Binding var date: Date
    
    // 构建 View 时调用该函数,该函数必须返回我们想要包装的 NSView 对象
    // 注意!不需要,也不应该将这个 NSView 对象设置成当前 NSViewRepresentable 子类的存储属性
    func makeNSView(context: Context) -> NSDatePicker {
        NSLog("makeNSView")
        
        let datePicker = NSDatePicker()
        datePicker.dateValue = date
        
        // 设置 datePicker 的事件处理
        // 将事件发送目标指向协同器
        datePicker.target = context.coordinator
        // 将事件处理函数指向协同器中的方法。#selector 只需要指定函数入口,sender 实参会自动传递
        datePicker.action = #selector(Coordinator.onDateChanged(sender:))
        
        return datePicker
    }
    
    // 刷新 View 时调用该函数,在该函数中更新 NSView 对象
    func updateNSView(_ nsView: NSDatePicker, context: Context) {
        NSLog("updateNSView")
        
        nsView.dateValue = date
    }
    
    // 返回一个协同器对象,该方法会在 makeNSView() 之前调用
    // 协同器对象会保存在 context.coordinator 属性中,给 makeNSView 与 updateNSView 调用
    func makeCoordinator() -> CustomNSDatePicker.Coordinator {
        return Coordinator(date: $date)
    }
    
    // 自定义协同器类
    class Coordinator: NSObject {
        // 绑定 SwiftUI 中需要交互的数据
        @Binding var date: Date

        // 注意!这是在构造器中传递 @Binding 属性的正确方式
        init(date: Binding<Date>) {
            self._date = date
        }

        @objc func onDateChanged(sender: NSDatePicker){
            NSLog("NSDatePicker date changed to \(sender.dateValue)")
            self.date = sender.dateValue
        }
    }
}

1.2 NSViewControllerRepresentable

除了通过 NSViewRepresentable 协议将一个 NSView 对象包装成 SwiftUI.View,我们还可以通过 NSViewControllerRepresentable 协议将一个 NSViewController 对象包装成 SwiftUI.View,然后在这个 NSViewController 中控制实际用来交互的 NSView 对象。

关于 NSview 与 NSViewController 的关系,以我的理解,就是将 NSView 关联的数据与逻辑剥离出来,放在 NSViewController 中,由 NSViewController 来控制 NSView 的生命周期。这其实就是一种 MVC 结构。

所以如果只是简单的利用 NSView 进行数据展示时候,可以用 NSViewRepresentable。如果涉及到的数据或交互逻辑比较复杂,最好就是用 NSViewControllerRepresentable 来进行包装。

与 NSViewRepresentable 一样,满足 NSViewControllerRepresentable 协议必须实现 makeNSViewController()updateNSViewController() 这两个方法。如果需要用到协同器的话,还可以定义自己的 Coordinator 类并重写 makeCoordinator() 方法。

同样以 NSDatePicker 为例,写一个用 NSViewControllerRepresentable 来包装的示例:

import SwiftUI

struct TestNSVCRep: View {
    @State var date = Date()
    
    var body: some View {
        VStack {
            Text("\(date)")
            
            // SwiftUI.DatePicker 对比
            DatePicker(selection: $date, label: { Text("SwiftUI") })
            
            Divider()
            
            CustomDatePicker(date: $date)
            
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
        .padding()
    }
}

struct CustomDatePicker: NSViewControllerRepresentable {
    typealias NSViewControllerType = CustomDatePickerController
    
    @Binding var date: Date
    
    // 构建 View 时调用该函数,该函数必须返回一个 NSViewController 的派生类
    func makeNSViewController(context: Context) -> CustomDatePickerController {
        NSLog("makeNSViewController")
        
        return CustomDatePickerController(date: $date)
    }
    
    // 刷新 View 时调用该函数,在该函数中更新 NSView 对象
    func updateNSViewController(_ nsViewController: CustomDatePickerController, context: Context) {
        NSLog("updateNSViewController")
        
        nsViewController.update(to: date)
    }
}

class CustomDatePickerController: NSViewController {
    @Binding var date: Date
    private let datePicker = NSDatePicker()

    init(date: Binding<Date>) {
        self._date = date
        
        // 不从 nib 文件中加载视图,需要重写 loadView() 方法来手工加载视图
        super.init(nibName: nil, bundle: nil)
    }
    
    // 这个构造器是跟 Interface Builder 有关的
    // 如果使用了 storyboard 或者 xib/nib 来构建对象,就会调用该构造器
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        datePicker.dateValue = date
        
        // 设置 NSDatePicker 的事件处理函数
        datePicker.action = #selector(onDateChanged)
            
        // 设置 NSViewController 的主视图
        view = datePicker
    }
    
    @objc func onDateChanged() {
        NSLog("NSDatePicker change to \(date)")
        date = datePicker.dateValue
    }
    
    func update(to: Date) {
        NSLog("SwiftUI date update to \(to)")
        datePicker.dateValue = to
    }
}

1.3 为视图增加响应函数

以 SwiftUI.Toggle 为例,它只能控制一个 Binding 值的变化,而没办法为其添加一个事件响应函数,当 Toggle 状态变化时触发该函数。为此,我们可以利用 NSSwitch 实现一个自己的 toggle 控件,然后通过 NSSwitch 的 action 来调用自定义的响应函数。

/// 将 NSSwitch 包装成一个 SwiftUI.View
struct PerformableSwitch: NSViewRepresentable {
    typealias NSViewType = NSSwitch
    
    /// NSSwitch 状态关联的布尔值
    @Binding var isOn: Bool
    
    /// NSSwitch 状态变化时候调用的函数
    let perform: (Bool) -> Void
    
    // 构建 View 时调用该函数,该函数必须返回我们想要包装的 NSView 对象
    // 注意!不需要,也不应该将这个 NSView 对象设置成当前 NSViewRepresentable 子类的存储属性
    func makeNSView(context: Context) -> NSSwitch {
        NSLog("toggle make view")
        
        let toggle = NSSwitch()
        toggle.state = isOn ? .on : .off
        
        toggle.target = context.coordinator
        toggle.action = #selector(Coordinator.onChanged(sender:))
        
        return toggle
    }
    
    // 刷新 View 时调用该函数,在该函数中更新 NSView 对象
    func updateNSView(_ nsView: NSSwitch, context: Context) {
        NSLog("toggle update view")
        
        nsView.state = isOn ? .on : .off
    }
    
    // 返回一个协同器对象,该方法会在 makeNSView() 之前调用
    // 协同器对象会保存在 context.coordinator 属性中,给 makeNSView 与 updateNSView 调用
    func makeCoordinator() -> Coordinator {
        return Coordinator(isOn: $isOn, perform: perform)
    }
    
    // 自定义协同器类
    class Coordinator: NSObject {
        // 绑定 SwiftUI 中需要交互的数据
        @Binding var isOn: Bool
        let perform: (Bool) -> Void

        init(isOn: Binding<Bool>, perform: @escaping (Bool) -> Void) {
            self._isOn = isOn       // 注意!这是在构造器中传递 @Binding 属性的正确方式
            self.perform = perform
        }

        @objc func onChanged(sender: NSSwitch){
            NSLog("toggle on changed")
            
            self.isOn = sender.state == .on ? true : false
            perform(self.isOn)
        }
    }
}

 

 

2、将 SwiftUI.View 包装成 NSView 来使用

其实在我们的 macOS SwiftUI 项目的入口 AppDelegate.swift 中,就用到了将 SwiftUI.View 包装成 NSView,然后传递给程序的主窗口。

window.contentView = NSHostingView(rootView: contentView)

要将 SwiftUI.View 包装成 NSView,需要用到的就是 NSHostingView

以文章下面👇 会讲的 NSTableView 为例,我需要用到 NSTableView 来实现想要的列表效果(SwiftUI.List 太弱了),而列表中的每一行,由于只需要简单的展示数据,所以想要直接用 SwiftUI.View 来实现。那么这就需要将 SwiftUI.View 包装成 NSView 来添加到 NSTableView 中。

 

3、对于 UIKit.UIView 而言

同样的对于 iOS 开发用到的 UIKit 而言,也有 UIViewRepresentable、UIViewControllerRepresentable 与 UIHostingController,来进行 UIKit 与 SwiftUI 的混合使用。

 

4、实例:使用 NSTableView 替换 SwiftUI.List

4.1 使用 List 遇到的问题

最近在用 SwiftUI 写一个 macOS 上的倒计时程序,有一个功能就是将所有的倒计时事件用列表显示出来,每行显示一个倒计时事件。并且列表要能拖拽排序,列表中的每行能响应用户单击操作。

一开始我是使用 SwiftUI 的 List 与 ForEach 来实现事件列表。当时遇到的第一个问题是 List 控件有默认的背景色,还有默认的 padding 都无法通过 List 自身进行修改。因为 SwiftUI 的 List 底层其实是依赖于 NSTableView 进行构建的,所以我不得不对 NSTableView 类进行扩展,重载它的 viewDidMoveToWindow(),在其中对背景色、padding 等进行修改。

后面还遇到一个更棘手的问题是当使用 ForEach 的 onMove() 修饰器设置列表拖拽事件处理的时候,似乎 onMove() 会截断鼠标事件的传递,即拖拽事件与点击事件只能存在一个。我还在 StackOverflow 上提交了一个问题,也有网友给了一个变通的解决方案。

最后我还是决定,既然 List 有这么多问题,还不如直接包装一个 NSTableView 来用。

4.2 使用 NSViewControllerRepresentable 包装 NSTableView

直接上代码吧,懒得简化了。有些类型是我的项目中自定义的类型。

import SwiftUI
import AppKit


/// 将一个 NSTableVIew 包装成 SwiftUI.View 进行使用
struct EventList: NSViewControllerRepresentable {
    typealias NSViewControllerType = EventListNSTableController
    
    // UserData 是项目自定义的存储全局用户数据的类型,其中的 [CountdownEvent] 数组就是要用列表展示的元素
    @EnvironmentObject var userData: UserData
    
    func makeNSViewController(context: Context) -> EventListNSTableController {
        return EventListNSTableController(userData: userData)
    }
    
    func updateNSViewController(_ nsViewController: EventListNSTableController, context: Context) {
    }
}

struct EventList_Previews: PreviewProvider {
    static var previews: some View {
        EventList()
            .frame(width: 400, height: 300)
            .environmentObject(UserData(countdownEvents: loadCountdownEvent()))
    }
}


final class EventListNSTableController: NSViewController {
    let userData: UserData
    
    let tableView = NSTableView()
    let scrollView = NSScrollView()
    
    init(userData: UserData) {
        self.userData = userData
        
        // 不从 nib 文件中加载视图,需要重写 loadView() 方法来手工加载视图
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    deinit {
        log.verbose("释放 EventListNSTableController")
    }
    
    override func loadView() {
        view = BackgroundNSView()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 设置 NSTableView
        tableView.backgroundColor = .clear                          // 清空背景色
        tableView.usesAlternatingRowBackgroundColors = false        // 每行背景色是否交替
        tableView.selectionHighlightStyle = .none                   // 选中项不高亮
        tableView.intercellSpacing = NSSize(width: 0, height: 0)    // The horizontal and vertical spacing between cells
        tableView.usesAutomaticRowHeights = true                    // 自动计算行高
        tableView.headerView = nil                                  // 不显示列头
        
        tableView.addTableColumn(NSTableColumn())                   // 手工添加一列
        
        tableView.registerForDraggedTypes([.string])                // 设置 pasteboard 中用来标识被抓取项的 NSPasteboardItem 的数据类型
    
        tableView.delegate = self
        tableView.dataSource = self
        
        tableView.action = #selector(onItemClicked)                 // 选中某项时触发的事件处理
        
        
        // 设置 NSScrollView
        scrollView.documentView = tableView
        scrollView.drawsBackground = false
        scrollView.autoresizingMask = [.width, .height]
        scrollView.borderType = .noBorder

        view.addSubview(scrollView)
    }
    
    /// 当 NSTableVIew 中某一个项被选中后触发
    @objc private func onItemClicked() {
        log.verbose("row \(tableView.clickedRow), col \(tableView.clickedColumn) clicked")
        
        // 如果用户点击在非数据行列上时候,clickRow 与 clickedColumn 会是-1
        if tableView.clickedRow < 0 {
            return
        }
        
        // 设置当前点击的倒计时事件
        userData.currentEvent = userData.countdownEvents[tableView.clickedRow]
        withAnimation {
            // 跳转到编辑视图
            //   但这里好像会有个问题??
            //   我想在这个 NSTableView 的事件处理函数中,将列表视图切换到编辑视图。
            //   视图切换后,就要释放 EventListNSTableController 对象,然后本该要释放 NSTableView 对象的。
            //   但是可能就是由于这个 action 逻辑还未返回,还占用着 NSTableView 的引用计数,导致 NSTableView 无法被及时释放。
            //   我他妈也不知道这该怎么办了,查了一天了。。=。=
            userData.currentPopContainedViewType = PopContainedViewType.edit
        }
    }
}

extension EventListNSTableController: NSTableViewDelegate {
}

extension EventListNSTableController: NSTableViewDataSource {
    
    // 在 NSTableView 中抓取某项时自动调用该函数
    // 返回需要写入到 pasteboard 中的,可以唯一标识该 row 的数据
    func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
        return userData.countdownEvents[row].uuid.uuidString as NSString
    }

    // 在 NSTableView 中抓取某项并移动时自动调用该函数
    func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
        
        if dropOperation == .above {
            tableView.draggingDestinationFeedbackStyle = .gap
            return .move
        } else {
            return []
        }
    }

    // 在 NSTableView 中抓取某项并释放时自动调用该函数
    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {

        /* Read the pasteboard items and ensure there is at least one item,
         find the string of the first pasteboard item and search the datasource
         for the index of the matching string */
        guard let items = info.draggingPasteboard.pasteboardItems,
            let pasteBoardItem = items.first,
            let pasteBoardItemName = pasteBoardItem.string(forType: .string),
            let index = userData.countdownEvents.firstIndex(where: {$0.uuid.uuidString == pasteBoardItemName}) else { return false }
        
        // 修改 userData.countdownEvents 数组
        let indexset = IndexSet(integer: index)
        userData.countdownEvents.move(fromOffsets: indexset, toOffset: row)
        
        // 修改每个 CountdownEvent 对象的 listOrder 属性,并写入数据库
        for index in 0 ..< self.userData.countdownEvents.count {
            self.userData.countdownEvents[index].listOrder = index
            self.userData.countdownEvents[index].save(at: db)
        }

        /* Animate the move to the rows in the table view. The ternary operator
         is needed because dragging a row downwards means the row number is 1 less */
        tableView.beginUpdates()
        tableView.moveRow(at: index, to: (index < row ? row - 1 : row))
        tableView.endUpdates()

        return true
    }
    
    // 为 NSTableView 返回每一行 View
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        // EventRow 是项目中自定义的 SwiftUI View,每个 EventRow 展示一个 CountdownEvent 对象
        let eventRow = EventRow(cdEvent: userData.countdownEvents[row]).border(width: 1, edges: [.bottom], color: Color.gray.opacity(0.2))
       
        let view = NSHostingView(rootView: eventRow)

        return view
    }

    func numberOfRows(in tableView: NSTableView) -> Int {
        return userData.countdownEvents.count
    }
    
    private class BackgroundNSView: NSView {
        override func draw(_ dirtyRect: NSRect) {
            // 调用 set() 方法设置当前画笔颜色
            NSColor.clear.set()
            
            // 填色
            dirtyRect.fill()
        }
    }
}

关于 NSTableView 的拖拽排序,可以参考:https://kitcross.net/reorder-table-views-drag-drop/

关于 NSTableView 的点击事件,可以参考:https://stackoverflow.com/questions/18560509

发表评论

电子邮件地址不会被公开。 必填项已用*标注