iosswiftswiftui

How to access/use @Binding in UIViewControllerRepresentable Coordinator


How to access / use a @Binding var someValue of an UIViewControllerRepresentable within its Coordinator?

I found several examples which simply pass the Binding to the Coordinator. While this seems to work, I wonder if it is correct or might break in the future. As far as I know Bindings should only be used within Views.

Have a look at the following demo code. Is the Binding uses/accessed correctly? Or is there a better way?

struct PickerView: UIViewControllerRepresentable {
    @Binding var selectedIndex: Int

    init(selectedIndex: Binding<Int>) {
        self._selectedIndex = selectedIndex
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(selectedIndex: $selectedIndex)  // <- correct?
    }

    func makeUIViewController(context: Context) -> UIViewController {
        let pickerVC = PickerViewController()
        pickerVC.delegate = context.coordinator
        return pickerVC
    }

    func updateUIViewController(_ viewController: UIViewController, context: Context) {
        // ...
    }

    class Coordinator: NSObject, PickerDelegate {
        var selectedIndex: Binding<Int>  // <- correct?
        
        init(selectedIndex: Binding<Int>) {
            self.selectedIndex = selectedIndex
        }
        
        func didSelectItem(at index: Int) {
            selectedIndex.wrappedValue = index  // <- correct?
        }
    }
}


// UIKit ViewController
protocol PickerDelegate: AnyObject {
    func didSelectItem(at index: Int)
}

class PickerViewController: UIViewController {
    var delegate: PickerDelegate?
    
    func selectItem(at index: Int) {
        delegate?.didSelectItem(at: index)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        let button = UIButton(type: .system)
        button.setTitle("Pick Random Index", for: .normal)
        button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)

        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)

        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    @objc private func buttonTapped() {
        let randomIndex = Int.random(in: 0...10)
        print("Button tapped → random index: \(randomIndex)")
        selectItem(at: randomIndex)
    }
}


// Demo View
struct PickerDemoView: View {
    @State private var selectedIndex = 0

    var body: some View {
        VStack(spacing: 20) {
            Text("Selected Index: \(selectedIndex)")
                .font(.largeTitle)
            Divider()
            PickerView(selectedIndex: $selectedIndex)
                .frame(height: 200)
                .border(Color.blue)
        }
        .padding()
    }
}

Solution

  • I don't think it is inherently a problem to have a Binding inside the coordinator, but I don't like it in terms of style. The important thing you need to do is to update the binding in updateUIViewController.

    func updateUIViewController(_ viewController: UIViewController, context: Context) {
        context.coordinator.selectedIndex = $selectedIndex
    }
    
    class Coordinator: NSObject, PickerDelegate {
        var selectedIndex: Binding<Int>?
        
        // I recommend just using the parameterless initialiser generated by the compiler.
        // Passing the binding through the initialiser makes it easy to forget to update the binding property in updateUIViewController.
        // init(selectedIndex: Binding<Int>) {
        //    self.selectedIndex = selectedIndex
        // }
        
        func didSelectItem(at index: Int) {
            selectedIndex?.wrappedValue = index
        }
    }
    

    This is necessary because the SwiftUI side might happen to be using a "different" Binding after a view update. This keeps the UIKit side "in sync" with the SwiftUI side.

    I personally don't like to see SwiftUI property wrappers in the coordinator. I would typically do:

    func updateUIViewController(_ viewController: UIViewController, context: Context) {
    
        context.coordinator.selectedIndexDidChange = { selectedIndex = $0 }
    }
    
    class Coordinator: NSObject, PickerDelegate {
        var selectedIndexDidChange: ((Int) -> Void)?
        
        func didSelectItem(at index: Int) {
            selectedIndexDidChange?(index)
        }
    }
    

    The line in updateUIViewController is effectively doing the same thing as updating the Binding property. It's just there is no Binding property - the updated Binding is captured in a closure.