How to correctly make 2-way binding with UITableViewRepresentable and SwiftUI

655 Views Asked by At

I'm creating my own custom SwiftUI List because I need to use some underlying methods of tableview. I would like to know how to correctly bind a TextField inside a UITableView Cell to my SwiftUI ViewModel.

So basically I have a textfield inside a SwiftUI View and use this as my tableview cell with the new UIHostingConfiguration struct. To check to see if things are working correctly I have a normal SwiftUI List above the TableViewRepresentable. If you type in a textfield of List, the TableViewRepresentable gets updated correctly as you type. But if you type in the textfield of TableViewRepresentable, only the Text beside the textfield updates. The List doesn't update the textfield's text. But then when you click inside any textfield inside the List, the actual update to the textfield happens.

I have found a way to get things to work but it doesn't seem correct. Let me know what you guys think. Hopefully someone has the answer. I have looked everywhere on the internet but nobody has done an example with UITableViewRepresentable or UICollectionViewRepresentable that works with 2 way binding from the cell to the ViewModel.

Also if anyone was curious why I need to use a TableView, it's to adjust the scrolling behavior of a List or TabView(.page). I need to get the velocity, like targetContentOffset method of scrollview, to have regular paging and then on fast swipe have fast paging.

  1. Here is the ViewModel and data.
class Item: Identifiable, ObservableObject {
    
    let id = UUID().uuidString
    @Published var title: String
    
    init(title: String) {
        self.title = title
    }
}

class ItemsViewModel: ObservableObject {
    @Published var items: [Item] = Array(0...40).map({ Item(title: "\($0)")})
}
  1. Hosting Cell. Just using as a sample cell to dequeue. This was the old way I was using to inject a SwiftUI View in a cell.
class HostingCell<Content: View>: UITableViewCell {
    var host: UIHostingController<Content>?
    
    func setup(with view: Content) {
        if host == nil {
            let controller = UIHostingController(rootView: view)
            host = controller
            
            guard let content = controller.view else { return }
            content.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(content)
            
            content.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
            content.leftAnchor.constraint(equalTo: contentView.leftAnchor).isActive = true
            content.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
            content.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
        } else {
            host?.rootView = view
        }
        setNeedsLayout()
    }
}
  1. Non-Working Code. Updated code to mimic sample code that @smileyborg suggested from Apple's sample code.
struct TableViewContent2: View {
    
    @EnvironmentObject var itemsViewModel: ItemsViewModel
    
    var body: some View {
        VStack {
            List($itemsViewModel.items) { $item in
                TextField("", text: $item.title)
            }
            
            TableViewRepresentable2(itemsViewModel) { item in
                CellView2(cellItem: item)
            }
            .ignoresSafeArea()
        }
    }
}

struct TableViewRepresentable2<Content: View>: UIViewRepresentable {
    
    @ObservedObject private var itemsViewModel: ItemsViewModel
    private var content: (Item) -> Content
    
    init(_ itemsViewModel: ItemsViewModel, @ViewBuilder content: @escaping (Item) -> Content) {
        self.itemsViewModel = itemsViewModel
        self.content = content
    }
    
    private let cellID = "CellID"
    
    func makeUIView(context: Context) -> UITableView {
        let tableView = UITableView()
        tableView.allowsSelection = false
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator
        tableView.register(HostingCell<Content>.self, forCellReuseIdentifier: cellID)
        return tableView
    }
    
    func updateUIView(_ uiView: UITableView, context: Context) {}
        
    func makeCoordinator() -> Coordinator {
        Coordinator(self, content: content)
    }
    
    class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate {
        
        var parent: TableViewRepresentable2
        var content: (Item) -> Content
        
        init(_ parent: TableViewRepresentable2, content: @escaping (Item) -> Content) {
            self.parent = parent
            self.content = content
        }
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            parent.itemsViewModel.items.count
        }
        
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            tableView.bounds.height / 4
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: parent.cellID, for: indexPath) as! HostingCell<Content>
            
            let cellItem = parent.itemsViewModel.items[indexPath.row]
            cell.configurationUpdateHandler = { cell, state in
                cell.contentConfiguration = UIHostingConfiguration {
                    self.content(cellItem)
                }
                .margins(.all, 0)
            }
            return cell
        }
    }
}

struct CellView2: View {
    @ObservedObject var cellItem: Item
    
    var body: some View {
        ZStack {
            Color.blue
                .ignoresSafeArea()
            VStack {
                TextField("Placeholder", text: $cellItem.title)
                    .background(RoundedRectangle(cornerRadius: 5).foregroundColor(.white))
                    .padding()
                
                Text("item: \(cellItem.title)")
            }
        }
    }
}
  1. WorkAround code. Big Difference is the CellView. I found a way by passing in the cell item, finding the index to the item, and then creating two computed properties to give me the binding item and the item.

struct Item: Identifiable {
    let id = UUID().uuidString
    var title: String
}


struct TableViewContent: View {
    
    @EnvironmentObject private var itemsViewModel: ItemsViewModel
    
    var body: some View {
        VStack {
            List($itemsViewModel.items) { $item in
                TextField("", text: $item.title)
            }
            
            TableViewRepresentable(itemsViewModel.items) { item in
                CellView(cellItem: item)
            }
        }
        
    }
}

struct TableViewRepresentable<Content: View>: UIViewRepresentable {
    
    private var items: [Item]
    private var content: (Item) -> Content
    
    init(_ items: [Item], @ViewBuilder content: @escaping (Item) -> Content) {
        self.items = items
        self.content = content
    }
    
    private let cellID = "CellID"
    
    func makeUIView(context: Context) -> UITableView {
        let tableView = UITableView()
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator
        tableView.register(HostingCell<Content>.self, forCellReuseIdentifier: cellID)
        return tableView
    }
    
    func updateUIView(_ uiView: UITableView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self, content: content)
    }
    
    class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate {
        
        var parent: TableViewRepresentable
        var content: (Item) -> Content
        
        init(_ parent: TableViewRepresentable, content: @escaping (Item) -> Content) {
            self.parent = parent
            self.content = content
        }
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            parent.items.count
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: parent.cellID, for: indexPath) as? HostingCell<Content> ?? UITableViewCell()
            
            let item = parent.items[indexPath.row]
            cell.contentConfiguration = UIHostingConfiguration {
                content(item)
            }
            return cell
        }
        
    }
    
    
}

struct CellView: View {
    
    @EnvironmentObject private var itemsViewModel: ItemsViewModel
    
    var cellItem: Item
    
    private var item$: Binding<Item> {
        let index = itemsViewModel.items.firstIndex(where: { $0.id == cellItem.id })
        return $itemsViewModel.items[index!]
    }
    
    private var item: Item {
        let index = itemsViewModel.items.firstIndex(where: { $0.id == cellItem.id })
        return itemsViewModel.items[index!]
    }
    
    var body: some View {
        ZStack {
            Color.blue
            VStack {
                TextField("Placeholder", text: item$.title)
                    .background(RoundedRectangle(cornerRadius: 5).foregroundColor(.white))
                
                Text("item: \(item.title)")
            }
            .padding()
        }
    }
}
0

There are 0 best solutions below