SwiftUI: Реализация оболочки вокруг UITableView для достижения настраиваемого представления в виде списка - PullRequest
0 голосов
/ 18 января 2020

Я хотел бы реализовать пользовательское представление списка в 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, поэтому нажатие кнопки + на панели навигации и добавление элементов не обновляет sh мой 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 () проверяются эти же элементы Binding дает правильный обновленный список предметов.

▿ 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)

1 Ответ

0 голосов
/ 18 января 2020

Я нашел такой трюк, но ему не нравится DispatchQueue.main.async { } состояние мутации в updateUIView() Если у кого-то есть идея, как решить эту проблему, оставьте другое решение в комментариях.

Как я обнаружил:

  1. Представление является структурой, поэтому каждый раз, когда вызывается его инициализатор, создаются новые ссылки на элементы / свойства модели, несмотря на то, что они являются классом или структурой

  2. makeCoordinator () вызывается только один раз, поэтому, когда происходит перерисовка, существует старый координатор со старыми ссылками

  3. , поскольку мы знаем, что @State сохраняется между просматривать перерисовки, так как они связаны с каким-то базовым хранилищем, поэтому в каждой перерисовке просматривать ссылки на модели (разные указатели), считанные из одного и того же базового хранилища. Таким образом, обновление в updateUIView () этого @State обновляет это состояние на всех ссылочных путях, включая тот, который Координатор хранит через неизменяемую родительскую ссылку View.

    import SwiftUI

    extension 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 Содержимое: Просмотр {

    // 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
    }
    

    }

...