Search code examples
iosswiftswiftuiuikitcombine

How to bind SwiftUI and UIViewController behavior


I have a UIKit project with UIViewControllers, and I'd like to present an action sheet built on SwiftUI from my ViewController. I need to bind the appearance and disappearance of the action sheet back to the view controller, enabling the view controller to be dismissed (and for the display animation to happen only on viewDidAppear, to avoid some weird animation behavior that happens when using .onAppear). Here is a code example of how I expect the binding to work and how it's not doing what I'm expecting:

import UIKit
import SwiftUI

class ViewController: UIViewController {
    let button = UIButton(type: .system)
    var show = true
    lazy var isShowing: Binding<Bool> = .init {
        self.show
    } set: { show in
        // This code gets called
        self.show = show
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        button.setTitle("TAP THIS BUTTON", for: .normal)
        view.addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        button.addTarget(self, action: #selector(tapped), for: .touchUpInside)
    }
    
    @objc private func tapped() {
        let vc = UIHostingController(rootView: BindingProblemView(testBinding: isShowing))
        vc.modalPresentationStyle = .overCurrentContext
        present(vc, animated: false)
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
            isShowing.wrappedValue.toggle()
            isShowing.update()
        }
    }
}

struct BindingProblemView: View {
    @Binding var testBinding: Bool
    @State var state = "ON"
    
    var body: some View {
        ZStack {
            if testBinding {
                Color.red.ignoresSafeArea().padding(0)
            } else {
                Color.green.ignoresSafeArea().padding(0)
            }
            
            Button("Test Binding is \(state)") {
                testBinding.toggle()
            }.onChange(of: testBinding, perform: { value in
                // This code never gets called
                state = testBinding ? "ON" : "OFF"
            })
        }
    }
}

What happens is that onChange never gets called after viewDidAppear when I set the binding value true. Am I just completely misusing the new combine operators?


Solution

  • You can pass the data through ObservableObjects, rather than with Bindings. The idea here is that ViewController has the reference to a PassedData instance, which is passed to the SwiftUI view which receives changes to the object as it's an @ObservedObject.

    This now works, so you can click on the original button to present the SwiftUI view. The button in that view then toggles passedData.isShowing which changes the background color. Since this is a class instance, the ViewController also has access to this value. As an example, isShowing is also toggled within tapped() after 5 seconds to show the value can be changed from ViewController or BindingProblemView.

    Although it is no longer needed, the onChange(of:perform:) still triggers.

    Code:

    class PassedData: ObservableObject {
        @Published var isShowing = true
    }
    
    class ViewController: UIViewController {
        let button = UIButton(type: .system)
        let passedData = PassedData()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .white
            button.setTitle("TAP THIS BUTTON", for: .normal)
            view.addSubview(button)
            button.translatesAutoresizingMaskIntoConstraints = false
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
            button.addTarget(self, action: #selector(tapped), for: .touchUpInside)
        }
    
        @objc private func tapped() {
            let newView = BindingProblemView(passedData: passedData)
            let vc = UIHostingController(rootView: newView)
            vc.modalPresentationStyle = .overCurrentContext
            present(vc, animated: false)
    
            // Example of toggling from in view controller
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
                self.passedData.isShowing.toggle()
            }
        }
    }
    
    struct BindingProblemView: View {
        @ObservedObject var passedData: PassedData
    
        var body: some View {
            ZStack {
                if passedData.isShowing {
                    Color.red.ignoresSafeArea().padding(0)
                } else {
                    Color.green.ignoresSafeArea().padding(0)
                }
    
                Button("Test Binding is \(passedData.isShowing ? "ON" : "OFF")") {
                    passedData.isShowing.toggle()
                }
            }
        }
    }
    

    Result:

    Result