首页 > 解决方案 > SwiftUI:围绕 UITableView 实现包装器以实现自定义 List-like 视图

问题描述

我想在 SwiftUI 中实现自定义的类似列表的视图,它必须启用比 SwiftUI 中的标准原生列表视图更多的功能。我想添加 List 中不存在的拖放(尽管有 onMove())。

我以这种方式实现了这个列表:

import SwiftUI
import MobileCoreServices

final class ReorderIndexPath: NSIndexPath {

}

extension ReorderIndexPath : NSItemProviderWriting {

    public static var writableTypeIdentifiersForItemProvider: [String] {
        return [kUTTypeData as String]
    }

    public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {

        let progress = Progress(totalUnitCount: 100)

        do {
            let data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: false)

            progress.completedUnitCount = 100

            completionHandler(data, nil)
        } catch {
            completionHandler(nil, error)
        }

        return progress
    }
}

extension ReorderIndexPath : NSItemProviderReading {

    public static var readableTypeIdentifiersForItemProvider: [String] {
        return [kUTTypeData as String]
    }

    public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> ReorderIndexPath {

        do {
            return try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! ReorderIndexPath
        } catch {
            fatalError(error.localizedDescription)
        }
    }
}

struct ReorderableList: UIViewControllerRepresentable {

    struct Model {
        private(set) var items : [AnyView]

        init(items: [AnyView]) {
            self.items = items
        }

        mutating func addItem(_ item: AnyView, at index: Int) {
            items.insert(item, at: index)
        }

        mutating func removeItem(at index: Int) {
            items.remove(at: index)
        }

        mutating func moveItem(at sourceIndex: Int, to destinationIndex: Int) {
            guard sourceIndex != destinationIndex else { return }

            let item = items[sourceIndex]
            items.remove(at: sourceIndex)
            items.insert(item, at: destinationIndex)
        }

        func canHandle(_ session: UIDropSession) -> Bool {

            return session.canLoadObjects(ofClass: ReorderIndexPath.self)
        }

        func dragItems(for indexPath: IndexPath) -> [UIDragItem] {

            //let item = items[indexPath.row]
            //let data = item.data(using: .utf8)

            let itemProvider = NSItemProvider()
            itemProvider.registerObject(ReorderIndexPath(row: indexPath.row, section: indexPath.section), visibility: .all)

            return [
                UIDragItem(itemProvider: itemProvider)
            ]

        }
    }

    @State private var model : Model

    // MARK: - Actions
    let onReorder : (Int, Int) -> Void
    let onDelete : ((Int) -> Bool)?

    // MARK: - Init
    public init<Data, RowContent>(onReorder: @escaping (Int, Int) -> Void = { _, _ in }, onDelete: ((Int) -> Bool)? = nil, _ content: @escaping () -> ForEach<Data, Data.Element.ID, RowContent>) where Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable {

        let content = content()

        var items = [AnyView]()

        content.data.forEach { element in
            let item = content.content(element)
            items.append(AnyView(item))
        }

        self.onReorder = onReorder
        self.onDelete = onDelete
        self._model = State(initialValue: Model(items: items))
    }


    public init<Data, RowContent>(onReorder: @escaping (Int, Int) -> Void = { _,_ in }, onDelete: ((Int) -> Bool)? = nil, _ data: Data, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable {

        self.init(onReorder: onReorder, onDelete: onDelete) {
            ForEach(data) { element in HStack { rowContent(element) } }
        }
    }


    public init<Data, ID, RowContent>(onReorder: @escaping (Int, Int) -> Void = { _,_ in }, onDelete: ((Int) -> Bool)? = nil, _ content: @escaping () -> ForEach<Data, ID, RowContent>) where Data : RandomAccessCollection, ID : Hashable, RowContent : View {

        let content = content()

        var items = [AnyView]()

        content.data.forEach { element in
            let item = content.content(element)
            items.append(AnyView(item))
        }

        self.onReorder = onReorder
        self.onDelete = onDelete
        self._model = State(initialValue: Model(items: items))
    }

    public init<Data, ID, RowContent>(onReorder: @escaping (Int, Int) -> Void = { _,_ in }, onDelete: ((Int) -> Bool)? = nil, _ data: Data, id: KeyPath<Data.Element, ID>, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Data : RandomAccessCollection, ID : Hashable, RowContent : View {

        self.init(onReorder: onReorder, onDelete: onDelete) {
            ForEach(data, id: id) { element in HStack { rowContent(element) } }
        }
    }

    public init<RowContent>(onReorder: @escaping (Int, Int) -> Void = { _,_ in }, onDelete: ((Int) -> Bool)? = nil, _ content: @escaping () -> ForEach<Range<Int>, Int, RowContent>) where RowContent : View {

        let content = content()

        var items = [AnyView]()

        content.data.forEach { i in
            let item = content.content(i)
            items.append(AnyView(item))
        }

        self.onReorder = onReorder
        self.onDelete = onDelete
        self._model = State(initialValue: Model(items: items))
    }

    public init<RowContent>(onReorder: @escaping (Int, Int) -> Void = {_,_ in }, onDelete: ((Int) -> Bool)? = nil, _ data: Range<Int>, @ViewBuilder rowContent: @escaping (Int) -> RowContent) where RowContent : View {

        self.init(onReorder: onReorder, onDelete: onDelete) {
            ForEach(data) { i in
                HStack { rowContent(i) }
            }
        }
    }

    func makeUIViewController(context: Context) -> UITableViewController {

        let tableView = UITableViewController()

        tableView.tableView.delegate = context.coordinator
        tableView.tableView.dataSource = context.coordinator
        tableView.tableView.dragInteractionEnabled = true
        tableView.tableView.dragDelegate = context.coordinator
        tableView.tableView.dropDelegate = context.coordinator

        tableView.tableView.register(HostingTableViewCell<AnyView>.self, forCellReuseIdentifier: "HostingCell")

        context.coordinator.controller = tableView

        return tableView
    }

    func updateUIViewController(_ uiView: UITableViewController, context: Context) {
        //print("Reorderable list update")
        //uiView.tableView.reloadData()
    }

    func makeCoordinator() -> Coordinator {

        Coordinator(self)
    }

    class Coordinator: NSObject, UITableViewDelegate, UITableViewDataSource, UITableViewDragDelegate, UITableViewDropDelegate {

        let parent: ReorderableList

        weak var controller : UITableViewController?

        // MARK: - Init
        init(_ parent: ReorderableList) {
            self.parent = parent
        }

        // MARK: - Data Source
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            parent.model.items.count
        }

        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

            let cell = tableView.dequeueReusableCell(withIdentifier: "HostingCell") as! HostingTableViewCell<AnyView>

            let item = parent.model.items[indexPath.row]

            cell.host(item, parent: controller!)

            return cell
        }

        // MARK: - Delegate
        func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
            return parent.onDelete != nil ? .delete : .none
        }

        func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
            return false
        }

        func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {

            if editingStyle == .delete {
                if parent.onDelete?(indexPath.row) ?? false {
                    tableView.beginUpdates()
                    parent.model.removeItem(at: indexPath.row)
                    tableView.deleteRows(at: [indexPath], with: .fade)
                    tableView.endUpdates()
                }
            } else if editingStyle == .insert {

            }
        }

        /*
        func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
            let object = parent.model.items[sourceIndexPath.row]
            parent.model.items.remove(at: sourceIndexPath.row)
            parent.model.items.insert(object, at: destinationIndexPath.row)
        }
        */

        // MARK: - Drag Delegate
        func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {

            return parent.model.dragItems(for: indexPath)
        }


        // MARK: - Drop Delegate
        func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
            return parent.model.canHandle(session)
        }

        func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {

            if tableView.hasActiveDrag {
                if session.items.count > 1 {
                    return UITableViewDropProposal(operation: .cancel)
                } else {
                    return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
                }
            } else {
                return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
            }
        }

        func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {

            let destinationIndexPath: IndexPath

            if let indexPath = coordinator.destinationIndexPath {
                destinationIndexPath = indexPath
            } else {
                // Get last index path of table view.
                let section = tableView.numberOfSections - 1
                let row = tableView.numberOfRows(inSection: section)
                destinationIndexPath = IndexPath(row: row, section: section)
            }

            coordinator.session.loadObjects(ofClass: ReorderIndexPath.self) { items in

                // Consume drag items.
                let indexPaths = items as! [IndexPath]

                for (index, sourceIndexPath) in indexPaths.enumerated() {

                    let destinationIndexPath = IndexPath(row: destinationIndexPath.row + index, section: destinationIndexPath.section)

                    self.parent.model.moveItem(at: sourceIndexPath.row, to: destinationIndexPath.row)
                    tableView.moveRow(at: sourceIndexPath, to: destinationIndexPath)

                    self.parent.onReorder(sourceIndexPath.row, destinationIndexPath.row)
                }
            }
        }
    }
}

这是使用它的客户端代码

struct ContentView: View {

    @State private var items: [String] = ["Item 1", "Item 2", "Item 3"]

    var body: some View {

        NavigationView {
            ReorderableList(onReorder: reorder, onDelete: delete) {
                 ForEach(self.items, id: \.self) { item in
                    Text("\(item)")
                }
            }
            .navigationBarTitle("Reorderable List", displayMode: .inline)
            .navigationBarItems(trailing: Button(action: add, label: {
                Image(systemName: "plus")
            }))
        }
    }

    func reorder(from source: Int, to destination: Int) {
        items.move(fromOffsets: IndexSet([source]), toOffset: destination)
    }

    func delete(_ idx: Int) -> Bool {
        items.remove(at: idx)
        return true
    }

    func add() {
        items.append("Item \(items.count)")
    }
}

问题是它没有 List 的自然刷新行为,因此在导航栏中点击 + 按钮并添加项目不会刷新我的 ReorderableList

更新

我还简化了示例来测试这种单元格的刷新,因为上面的代码有点长。

struct ReorderableList2<T, Content>: UIViewRepresentable where Content : View {

    @Binding private var items: [T]

    let content: (T) -> Content

    init(_ items: Binding<[T]>, @ViewBuilder content: @escaping (T) -> Content) {

        self.content = content
        self._items = items
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITableView {

        let tableView = UITableView()
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator

        tableView.register(HostingTableViewCell.self, forCellReuseIdentifier: "HostingCell")

        return tableView
    }

    func updateUIView(_ uiView: UITableView, context: Context) {
        uiView.reloadData()
    }

    class Coordinator : NSObject, UITableViewDataSource, UITableViewDelegate {

        private let parent: ReorderableList2

        // MARK: - Init
        init(_ parent: ReorderableList2) {
            self.parent = parent
        }

        // MARK: - Data Source
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            parent.items.count
        }

        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

            let cell = tableView.dequeueReusableCell(withIdentifier: "HostingCell") as! HostingTableViewCell

            let item = parent.items[indexPath.row]
            let rootView = parent.content(item)
            cell.host(rootView: rootView)

            return cell

        }

        // MARK: - Delegate
    }


}

即使绑定项目添加了新项目,这个简化版本也不起作用。

tableView:numberOfRowsInSection:每次我添加新项目时都会正确调用但parent.items.count旧号码错误

▿ ReorderableList2<String, Text>
  ▿ _items : Binding<Array<String>>
    ▿ transaction : Transaction
      ▿ plist : []
        - elements : nil
    ▿ location : <LocationBox<ScopedLocation>: 0x6000016a4bd0>
    ▿ _value : 3 elements
      - 0 : "Item 1"
      - 1 : "Item 2"
      - 2 : "Item 3"
  - content : (Function)

即使在构造函数或 updateUIView() 检查相同的项目绑定提供正确的更新项目列表。

▿ ReorderableList2<String, Text>
  ▿ _items : Binding<Array<String>>
    ▿ transaction : Transaction
      ▿ plist : []
        - elements : nil
    ▿ location : <LocationBox<ScopedLocation>: 0x6000016a4bd0>
    ▿ _value : 5 elements
      - 0 : "Item 1"
      - 1 : "Item 2"
      - 2 : "Item 3"
      - 3 : "Item 3"
      - 4 : "Item 4"
  - content : (Function)

标签: listuikitswiftuiwrapper

解决方案


我发现了这样的技巧,但不喜欢这种DispatchQueue.main.async { }变异状态updateUIView()如果有人有更好的想法如何解决这个问题,请在评论中留下其他解决方案。

我发现:

  1. View 是结构,因此每次调用它的初始化程序时都会创建对项目/模型属性的新引用,尽管它们是类或结构

  2. makeCoordinator() 只被调用一次,所以当重绘时有旧的 Coordinator 和旧的引用

  3. 正如我们所知,@State 保留在视图重绘之间,因为它有一些与之相关的底层存储,因此在每个重绘的视图模型引用(不同的指针)中都从相同的底层存储读取。因此,在 updateUIView() 中更新此 @State 会刷新所有引用路径上的此状态,包括 Coordinator 通过不可变父视图引用保持的状态。

    导入 SwiftUI

    扩展 ReorderableList2 {

    struct Model<T> {
    
        private(set) var items: [T]
    
        init(items: [T]) {
            self.items = items
        }
    
        mutating func addItem(_ item: T, at index: Int) {
            items.insert(item, at: index)
        }
    
        mutating func removeItem(at index: Int) {
            items.remove(at: index)
        }
    
        mutating func moveItem(at source: Int, to destination: Int) {
            guard source != destination else { return }
    
            let item = items[source]
            items.remove(at: source)
            items.insert(item, at: destination)
        }
    
        mutating func replaceItems(_ items: [T]) {
            self.items = items
        }
    }
    

    }

    struct ReorderableList2: UIViewRepresentable where Content : View {

    // MARK: - State
    @State private(set) var model = Model<T>(items: [])
    
    // MARK: - Properties
    private let items: [T]
    private let content: (T) -> Content
    
    init(_ items: [T], @ViewBuilder content: @escaping (T) -> Content) {
    
        self.content = content
        self.items = items
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> UITableView {
    
        let tableView = UITableView()
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator
    
        tableView.register(HostingTableViewCell.self, forCellReuseIdentifier: "HostingCell")
    
        return tableView
    }
    
    func updateUIView(_ uiView: UITableView, context: Context) {
        DispatchQueue.main.async {
            self.model.replaceItems(self.items)
            uiView.reloadData()
        }
    
    }
    
    class Coordinator : NSObject, UITableViewDataSource, UITableViewDelegate {
    
        private let parent: ReorderableList2
    
        // MARK: - Init
        init(_ parent: ReorderableList2) {
            self.parent = parent
        }
    
        // MARK: - Data Source
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            parent.model.items.count
        }
    
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
            let cell = tableView.dequeueReusableCell(withIdentifier: "HostingCell") as! HostingTableViewCell
    
            let item = parent.model.items[indexPath.row]
            let rootView = parent.content(item)
            cell.host(rootView: rootView)
    
            return cell
    
        }
    
        // MARK: - Delegate
    }
    

    }


推荐阅读