20

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")
                }
            }
        }
    }
}
Marwen Doukh
  • 1,946
  • 17
  • 26
8HP8
  • 1,720
  • 3
  • 16
  • 15

6 Answers6

13

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
  • 18,350
  • 14
  • 66
  • 71
  • Can't believe it's so simple. Don't know why I thought it would be different. Thanks a lot – 8HP8 Jun 13 '19 at 17:41
  • DAMN! I've spent today trying to just this - push or trigger something in `UIKit` from `SwiftUI` and actually created a question a few minutes ago. It's now deleted. I just wish I could give you more than one upvote. –  Jun 17 '19 at 01:17
  • Haha that simple eh? – Sudara Jun 22 '19 at 04:28
  • 6
    keeping a reference to uiview give me some weird effects we it redraws the uiview – alizx Sep 27 '19 at 17:24
  • 3
    It is working but adding this instance variable caused Update requests to keep being sent forever. When I remove it, it is not called. My UIView is MKMapView which may be different. – Tony Jun 27 '20 at 06:14
  • Not a perfect solution. What if SomeViewRepresentable need some parameters to initialize that are globally available in you SwiftUI view struct. It will not work then – Jamil Mar 24 '22 at 19:02
6

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
    }
  }
}
ada10086
  • 292
  • 5
  • 8
  • 2
    This should be the more correct answer since this is the correct way to do it in SwiftUI .... the mindset of doing things in SwiftUI is much more different than the traditional UIKit. – Huy.Vu Jan 30 '21 at 06:04
  • 6
    This causes -> [SwiftUI] Modifying state during view update, this will cause undefined behavior. This is because you set the buttonPressed to false in your update block again. – orschaef Feb 27 '21 at 11:38
  • Wrap `buttonPressed = false` in `DispatchQueue.main.async` to avoid the warning. It's important to update view state only on the main thread. – McKinley Jun 12 '22 at 09:59
3

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
    }
}
McKinley
  • 1,123
  • 1
  • 8
  • 18
2

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")
  }
}
Clifton Labrum
  • 13,053
  • 9
  • 65
  • 128
  • This is a great idea, but technically the question is asking about `UIViewRepresentable` and not `UIHostingController`. I've taken this idea and adjusted it to work with `UIViewRepresentable` in my answer [here](https://stackoverflow.com/a/72591441/15959847). – McKinley Jun 12 '22 at 10:43
2

@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.

McKinley
  • 1,123
  • 1
  • 8
  • 18
0

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.

vin047
  • 260
  • 3
  • 12