Send tapAction from SwiftUI button action to UIView function

Viewed 12751

I'm trying to find a way to trigger an action that will call a function in my UIView when a button gets tapped inside swiftUI.

Here's my setup:

foo()(UIView) needs to run when Button(SwiftUI) gets tapped

My custom UIView class making use of AVFoundation frameworks

class SomeView: UIView {

    func foo() {}
}

To use my UIView inside swiftUI I have to wrap it in UIViewRepresentable

struct SomeViewRepresentable: UIViewRepresentable {

    func makeUIView(context: Context) -> CaptureView {
        SomeView()
    }

    func updateUIView(_ uiView: CaptureView, context: Context) {        
    }
}

SwiftUI View that hosts my UIView()

struct ContentView : View {

    var body: some View {
        VStack(alignment: .center, spacing: 24) {
            SomeViewRepresentable()
                .background(Color.gray)
            HStack {
                Button(action: {
                    print("SwiftUI: Button tapped")
                   // Call func in SomeView()
                }) {
                    Text("Tap Here")
                }
            }
        }
    }
}
6 Answers

You can store an instance of your custom UIView in your representable struct (SomeViewRepresentable here) and call its methods on tap actions:

struct SomeViewRepresentable: UIViewRepresentable {

  let someView = SomeView() // add this instance

  func makeUIView(context: Context) -> SomeView { // changed your CaptureView to SomeView to make it compile
    someView
  }

  func updateUIView(_ uiView: SomeView, context: Context) {

  }

  func callFoo() {
    someView.foo()
  }
}

And your view body will look like this:

  let someView = SomeViewRepresentable()

  var body: some View {
    VStack(alignment: .center, spacing: 24) {
      someView
        .background(Color.gray)
      HStack {
        Button(action: {
          print("SwiftUI: Button tapped")
          // Call func in SomeView()
          self.someView.callFoo()
        }) {
          Text("Tap Here")
        }
      }
    }
  }

To test it I added a print to the foo() method:

class SomeView: UIView {

  func foo() {
    print("foo called!")
  }
}

Now tapping on your button will trigger foo() and the print statement will be shown.

M Reza's solution works for simple situations, however if your parent SwiftUI view has state changes, every time when it refreshes, it will cause your UIViewRepresentable to create new instance of UIView because of this: let someView = SomeView() // add this instance. Therefore someView.foo() is calling the action on the previous instance of SomeView you created, which is already outdated upon refreshing, so you might not see any updates of your UIViewRepresentable appear on your parent view. See: https://medium.com/zendesk-engineering/swiftui-uiview-a-simple-mistake-b794bd8c5678

A better practice would be to avoid creating and referencing that instance of UIView when calling its function.

My adaption to M Reza's solution would be calling the function indirectly through parent view's state change, which triggers updateUIView :

  var body: some View {
    @State var buttonPressed: Bool = false
    VStack(alignment: .center, spacing: 24) {

      //pass in the @State variable which triggers actions in updateUIVIew
      SomeViewRepresentable(buttonPressed: $buttonPressed)
        .background(Color.gray)
      HStack {
        Button(action: {
          buttonPressed = true
        }) {
          Text("Tap Here")
        }
      }
    }
  }

struct SomeViewRepresentable: UIViewRepresentable {
  @Binding var buttonPressed: Bool 

  func makeUIView(context: Context) -> SomeView {
    return SomeView()
  }

  //called every time buttonPressed is updated
  func updateUIView(_ uiView: SomeView, context: Context) {
    if buttonPressed {
        //called on that instance of SomeView that you see in the parent view
        uiView.foo()
        buttonPressed = false
    }
  }
}

Here's another way to do it using a bridging class.

//SwiftUI
struct SomeView: View{
  var bridge: BridgeStuff?

  var body: some View{
    Button("Click Me"){
      bridge?.yo()
    }
  }
}

//UIKit or AppKit (use NS instead of UI)
class BridgeStuff{
  var yo:() -> Void = {}
}

class YourViewController: UIViewController{

  override func viewDidLoad(){
    let bridge = BridgeStuff()
    let view = UIHostingController(rootView: SomeView(bridge: bridge))
    bridge.yo = { [weak self] in
      print("Yo")
      self?.howdy()
    }
  }

  func howdy(){
    print("Howdy")
  }
}

@ada10086 has a great answer. Just thought I'd provide an alternative solution that would be more convenient if you want to send many different actions to your UIView.

The key is to use PassthroughSubject from Combine to send messages from the superview to the UIViewRepresentable.

struct ContentView: View {
    
    /// This will act as a messenger to our subview
    private var messenger = PassthroughSubject<String, Never>()
    
    var body: some View {
        SomeViewRepresentable(messenger: messenger)  // Pass the messenger to our subview
        Button("Tap here!") {
            // Send a message
            messenger.send("button-tapped")
        }
    }
}

Then we monitor the PassthroughSubject in our subview:

struct SomeViewRepresentable: UIViewRepresentable {
    
    let messenger = PassthroughSubject<String, Never>()
    @State private var subscriptions: Set<AnyCancellable> = []
    
    func makeUIView(context: Context) -> UIView {
        
        let uiView = UIView()
        
        // This must be run on the main thread
        DispatchQueue.main.async {
            // Subscribe to messages
            messenger.sink { message in
                switch message {
                    // Call funcs in `uiView` depending on which message we received
                }
            }
            .store(in: &subscriptions)
        }
        return uiView
    }
}

This approach is nice because you can send any string to the subview, so you can design a whole messaging scheme.

Here is yet another solution! Communicate between the superview and the UIViewRepresentable using a closure:

struct ContentView: View {
    
    /// This closure will be initialized in our subview
    @State var closure: (() -> Void)?
    
    var body: some View {
        SomeViewRepresentable(closure: $closure)
        Button("Tap here!") {
            closure?()
        }
    }
}

Then initialize the closure in the UIViewRepresentable:

struct SomeViewRepresentable: UIViewRepresentable {
    
    // This is the same closure that our superview will call
    @Binding var closure: (() -> Void)?
    
    func makeUIView(context: Context) -> UIView {
        
        let uiView = UIView()
        
        // Since `closure` is part of our state, we can only set it on the main thread
        DispatchQueue.main.async {
            closure = {
                // Perform some action on our UIView
            }
        }
        return uiView
    }
}

My solution is to create an intermediary SomeViewModel object. The object stores an optional closure, which is assigned an action when SomeView is created.

struct ContentView: View {
    // parent view holds the state object
    @StateObject var someViewModel = SomeViewModel()

    var body: some View {
        VStack(alignment: .center, spacing: 24) {
            SomeViewRepresentable(model: someViewModel)
                .background(Color.gray)
            HStack {
                Button {
                    someViewModel.foo?()
                } label: {
                    Text("Tap Here")
                }
            }
        }
    }
}

struct SomeViewRepresentable: UIViewRepresentable {
    @ObservedObject var model: SomeViewModel

    func makeUIView(context: Context) -> SomeView {
        let someView = SomeView()
        // we don't want the model to hold on to a reference to 'someView', so we capture it with the 'weak' keyword
        model.foo = { [weak someView] in
            someView?.foo()
        }
        return someView
    }

    func updateUIView(_ uiView: SomeView, context: Context) {

    }
}

class SomeViewModel: ObservableObject {
    var foo: (() -> Void)? = nil
}

Three benefits doing it this way:

  1. We avoid the original problem that @ada10086 identified with @m-reza's solution; creating the view only within the makeUIView function, as per the guidance from Apple Docs, which state that we "must implement this method and use it to create your view object."

  2. We avoid the problem that @orschaef identified with @ada10086's alternative solution; we're not modifying state during a view update.

  3. By using ObservableObject for the model, we can add @Published properties to the model and communicate state changes from the UIView object. For instance, if SomeView uses KVO for some of its properties, we can create an observer that will update some @Published properties, which will be propagated to any interested SwiftUI views.

Related