How do you call a method on a UIView from outside the UIViewRepresentable in SwiftUI?
Asked Answered
C

5

17

I want to be able to pass a reference to a method on the UIViewRespresentable (or perhaps it’s Coordinator) to a parent View. The only way I can think to do this is by creating a field on the parent View struct with a class that I then pass to the child, which acts as a delegate for this behaviour. But it seems pretty verbose.

The use case here is to be a able to call a method from a standard SwiftUI Button that will zoom the the current location in a MKMapView that’s buried in a UIViewRepresentable elsewhere in the tree. I don’t want the current location to be a Binding as I want this action to be a one off and not reflected constantly in the UI.

TL;DR is there a standard way of having a parent get a reference to a child in SwiftUI, at least for UIViewRepresentables? (I understand this is probably not desirable in most cases and largely runs against the SwiftUI pattern).

Cuirbouilli answered 21/9, 2019 at 10:14 Comment(0)
J
18

I struggled with that myself, here's what worked using Combine and PassthroughSubject:

struct OuterView: View {
  private var didChange = PassthroughSubject<String, Never>()

  var body: some View {
    VStack {
      // send the PassthroughSubject over
      Wrapper(didChange: didChange)
      Button(action: {
        self.didChange.send("customString")
      })
    }
  }
}

// This is representable struct that acts as the bridge between UIKit <> SwiftUI
struct Wrapper: UIViewRepresentable {
  var didChange: PassthroughSubject<String, Never>
  @State var cancellable: AnyCancellable? = nil

  func makeUIView(context: Context) → SomeView {
    let someView = SomeView()
    // ... perform some initializations here

    // doing it in `main` thread is required to avoid the state being modified during
    // a view update
    DispatchQueue.main.async {
      // very important to capture it as a variable, otherwise it'll be short lived.
      self.cancellable = didChange.sink { (value) in
        print("Received: \(value)")
        
        // here you can do a switch case to know which method to call
        // on your UIKit class, example:
        if (value == "customString") {
          // call your function!
          someView.customFunction()
        }
      }
    }

    return someView
  }
}

// This is your usual UIKit View
class SomeView: UIView {
  func customFunction() {
    // ...
  }
}
Jillianjillie answered 29/3, 2021 at 19:42 Comment(2)
This is great, thanks! I've been struggling with a clean solution for some time. In my case, I was trying to provide forward/back/reload functions for a WKWebView.Beal
Good one, it really helped and I would say perfect solution using combine.Granddaughter
F
2

I'm sure there are better ways, including using Combine and a PassthroughSubject. (But I never got that to work.) That said, if you're willing to "run against the SwiftUI pattern", why not just send a Notification? (That's what I do.)

In my model:

extension Notification.Name {
    static let executeUIKitFunction = Notification.Name("ExecuteUIKitFunction")
}

final class Model : ObservableObject {
    @Published var executeFuntionInUIKit = false {
        willSet {
            NotificationCenter.default.post(name: .executeUIKitFunction, object: nil, userInfo: nil)
        }
    }
}

And in my UIKit representable:

NotificationCenter.default.addObserver(self, selector: #selector(myUIKitFunction), name: .executeUIKitFunction, object: nil)

Place that in your init or viewDidLoad, depending on what kind of representable.

Again, this is not "pure" SwiftUI or Combine, but someone better than me can probably give you that - and you sound willing to get something that works. And trust me, this works.

EDIT: Of note, you need to do nothing extra in your representable - this simply works between your model and your UIKit view or view controller.

Fino answered 21/9, 2019 at 12:10 Comment(3)
Thanks! While this would seemingly work, it would be nice for this to be handled in a pure way as most of the work I'm doing here is academic so I'm hoping to find the idiomatic solution.Cuirbouilli
@RichieAHB, I agree. I'm not sure how long you've been using Swift, but if it's been since Swift 1.0, think back. the slowness, the syntax, the evolution... and remember, SwiftUI is in 1.0 also. I'm pretty amazed the things that do work outnumber the things that don't. This linkage between SwiftUI <> UIKit i likely something that will improve next September, when The WWDC announcements turn gold.Fino
Yeah I don't disagree at all and I'm sure things will improve, I guess I just wanted to know if I was missing a part of the API surface area that would make this easier. But I'm sure it will come!Cuirbouilli
V
2

I was coming here to find a better answer, then the one I came up myself with, but maybe this does actually help someone?

It's pretty verbose though nevertheless and doesn't quite feel like the most idiomatic solution, so probably not exactly what the question author was looking for. But it does avoid polluting the global namespace and allows synchronous (and repeated) execution and returning values, unlike the NotificationCenter-based solution posted before.

An alternative considered was using a @StateObject instead, but I need to support iOS 13 currently where this is not available yet.

Excursion: Why would I want that? I need to handle a touch event, but I'm competing with another gesture defined in the SwiftUI world, which would take precedence over my UITapGestureRecognizer. (I hope this helps by giving some context for the brief sample code below.)

So what I came up with, was the following:

  • Add an optional closure as state (on FooView),
  • Pass it as a binding into the view representable (BarViewRepresentable),
  • Fill this from makeUIView,
  • So that this can call a method on BazUIView.

Note: It causes an undesired / unnecessary subsequent update of BarViewRepresentable, because setting the binding changes the state of the view representable though, but this is not really a problem in my case.

struct FooView: View {
    @State private var closure: ((CGPoint) -> ())?

    var body: some View {
        BarViewRepresentable(closure: $closure)
           .dragGesture(
               DragGesture(minimumDistance: 0, coordinateSpace: .local)
                   .onEnded { value in
                       self.closure?(value.location)
                   })
           )
    }
}

class BarViewRepresentable: UIViewRepresentable {
    @Binding var closure: ((CGPoint) -> ())?

    func makeUIView(context: UIViewRepresentableContext<BarViewRepresentable>) -> BazUIView {
        let view = BazUIView(frame: .zero)
        updateUIView(view: view, context: context)
        return view
    }

    func updateUIView(view: BazUIView, context: UIViewRepresentableContext<BarViewRepresentable>) {
        DispatchQueue.main.async { [weak self] in
            guard let strongSelf = self else { return }
            strongSelf.closure = { [weak view] point in
                guard let strongView = view? else {
                    return
                }
                strongView.handleTap(at: point)
            }
        }
    }
}

class BazUIView: UIView {  /*...*/ }
Vyner answered 28/9, 2020 at 15:52 Comment(0)
F
2

Swift 5 and Combine

Tweaking KBog's solution because it didn't work for me.

import SwiftUI
import MapKit
import Combine

struct MapView: UIViewRepresentable {
    enum Action {
        case zoomToCurrentLocation
    }
    
    let actionPublisher: any Publisher<Action, Never>
    
    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        context.coordinator.actionSubscriber = actionPublisher.sink { action in
            switch action {
            case .zoomToCurrentLocation:
                // call the required methods for setting current location
            }
        }
        return mapView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject {
        var actionSubscriber: any Cancellable?
    }
}

and then use it anywhere else like this

struct SomeOtherView: View {
    let actionPublisher = PassthroughSubject<MapView.Action, Never>()
    
    var body: some View {
        VStack {
            MapView(actionPublisher: actionPublisher)
            Button("Zoom") {
                actionPublisher.send(.zoomToCurrentLocation)
            }
        }
    }
}
Fore answered 27/2, 2023 at 9:10 Comment(0)
R
1

This is how I accomplished it succesfully. I create the UIView as a constant property in the SwiftUI View. Then I pass that reference into the UIViewRepresentable initializer which I use inside the makeUI method. Then I can call any method (maybe in an extension to the UIView) from the SwiftUI View (for instance, when tapping a button). In code is something like:

SwiftUI View

struct MySwiftUIView: View {
    let myUIView = MKMapView(...) // Whatever initializer you use
    
    var body: some View {
        VStack {
            MyUIView(myUIView: myUIView)
            Button(action: { myUIView.buttonTapped() }) {
                Text("Call buttonTapped")
            }
        }
    }
}

UIView

struct MyUIView: UIViewRepresentable {
    let myUIView: MKMapView
    
    func makeUIView(context: UIViewRepresentableContext<MyUIView>) -> MKMapView {
        // Configure myUIView
        return myUIView
    }
    
    func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MyUIView>) {

    }
}

extension MKMapView {
    func buttonTapped() {
        print("The button was tapped!")
    }
}
Reagent answered 2/1, 2022 at 21:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.