0

I need to locate the position of the last double tap within a large view, and I'm limited in what I can do to achieve this result as follows:

  1. I can't use DragGesture(minimumDistance: 0) simultaneously to a TapGesture(count: 2) because the drag gesture is needed for something else and can't be overridden.
  2. I can't use onTapGesture { location in since the target iOS version is iOS 15.0 in my project.
  3. I can't use Introspect since the large view I'm talking about is a large LazyHStack and there is no knowledge of what kind of UIViews are being used under the hood to implement it. Also way too fragile, would like to avoid.

So apparently I'm kinda forced to use an UIViewControllerRepresentable hosting another SwiftUI view through an UIHostingController. Problem is, I'm unfamiliar with UIKit and integrating UIKit with SwiftUI and the other way around. Kinda never done it before.

Nevertheless, I came up with a solution that kinda does the job for small views, but has terrible performances (CPU spikes to 100%) when the inner view is large and updates, making my solution not viable at all in its current state (scales terribly).

This is what I came up with:

TapReadingView:

import SwiftUI

struct TapReadingView<Content>: View where Content: View {
    var lastTapLocation: CGPoint = CGPoint(x: Double.nan, y: Double.nan)
    private var content: (CGPoint) -> Content
    var intrinsicSizeDidChange: () -> Void
    
    init(intrinsicSizeDidChange: @escaping () -> Void, content: @escaping (CGPoint) -> Content) {
        self.intrinsicSizeDidChange = intrinsicSizeDidChange
        self.content = content
    }
    
    var body: some View {
        self.content(lastTapLocation)
            .background {
                GeometryReader { proxy in
                    Color.clear
                        .onAppear {
                            self.intrinsicSizeDidChange()
                        }
                        .onChange(of: proxy.size) { _ in
                            self.intrinsicSizeDidChange()
                        }
                }
            }
    }
}

TapReadingVC:

import Foundation
import SwiftUI

class TapReadingVC<Content>: UIViewController where Content : View {
    var hostingController: UIHostingController<TapReadingView<Content>>
    var numberOfTaps: Int
    
    init(hostingController: UIHostingController<TapReadingView<Content>>, numberOfTaps: Int = 1) {
        self.numberOfTaps = numberOfTaps
        self.hostingController = hostingController
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    ///1. Add the hosted controller as a child to this VC
    ///2. Clear the background view of this VC and of the SwiftUI hosted view
    ///3. Specify constraints
    override func viewDidLoad() {
        self.hostingController.rootView.intrinsicSizeDidChange = { [weak self] in
            guard let self = self else { return }
            self.hostingController.view.invalidateIntrinsicContentSize()
        }
        
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)

        view.backgroundColor = .clear
        hostingController.view.backgroundColor = .clear
        
        view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        hostingController.view.backgroundColor = .clear

        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            hostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
        ])
    }
    
    override func viewDidLayoutSubviews() {
        preferredContentSize = hostingController.view.intrinsicContentSize
        super.viewDidLayoutSubviews()
    }
    
    ///1. Remove all the gesture recognizers to the hosted view before detaching it
    ///2. Remove the hosted view's associated VC from this VC
    ///3. Replace the new hosted view's `intrinsicSizeDidChange` callback
    ///4. Attach a new `UITapGestureRecognizer` to the new SwiftUI hosted view
    ///5. Attach the new SwiftUI hosted view as a child to this VC
    ///6. Constraint the new SwiftUI view to the bounds of this VC's associated view
    ///
    ///**Parameters:**
    /// - `with`: The `UIHostingController` hosting the view that the current one needs to be replaced with
    /// - `target`: The object that will be notified of tap gestures
    /// - `action`: A selector to an `@objc` method that contains the logic to respond to taps
    func replaceHostedVC(with: UIHostingController<TapReadingView<Content>>, target: Any? = nil, action: Selector? = nil) {
        
        self.hostingController.view.gestureRecognizers?.forEach(
            self.hostingController.view.removeGestureRecognizer
        )
        
        self.hostingController.willMove(toParent: nil)
        self.hostingController.view.removeFromSuperview()
        self.hostingController.removeFromParent()
                
        with.view.backgroundColor = .clear
        with.view.translatesAutoresizingMaskIntoConstraints = false
        
        with.rootView.intrinsicSizeDidChange = { [weak self] in
            guard let self = self else { return }
            self.hostingController.view.invalidateIntrinsicContentSize()
        }
            
       let newGestureRecognizer = UITapGestureRecognizer(
            target: target,
            action: action
       )
    
       newGestureRecognizer.numberOfTapsRequired = self.numberOfTaps
        
       with.view.addGestureRecognizer(newGestureRecognizer)
        
       self.addChild(with)
       self.view.addSubview(with.view)
       with.didMove(toParent: self)
                
       self.hostingController = with
       self.hostingController.view.translatesAutoresizingMaskIntoConstraints = false

       NSLayoutConstraint.activate([
           hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
           hostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
           hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
           hostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
       ])

    }
}

SpatialGestureCoordinator:

import Foundation
import SwiftUI

class SpatialGestureCoordinator<Content> where Content : View {
    private var hostedVC: UIHostingController<TapReadingView<Content>>? = nil
    private var content: (CGPoint) -> Content
    
    private var lastTapLocation: CGPoint = .zero {
        didSet {
            guard let hostedVC = self.hostedVC else { return }
            hostedVC.rootView.lastTapLocation = self.lastTapLocation
        }
    }
    
    init(content: @escaping (CGPoint) -> Content) {
        self.content = content
        self.hostedVC =
            UIHostingController(
                rootView:
                    TapReadingView { //IntrinsicSizeDiDChange
                    
                    } content: { lastTapLocation in
                        content(lastTapLocation)
                    }
            )
    }
    
    @objc func respondToTap(_ sender: UITapGestureRecognizer) {
        guard let hostedVC = self.hostedVC else { return }
        let location = sender.location(in: hostedVC.view)
        
        let state = sender.state
        
        switch state {
            case .ended:
                self.lastTapLocation = location
            default:
                break
        }
        
    }
    
    func getHostingController() -> UIHostingController<TapReadingView<Content>>? {
        return self.hostedVC
    }
    
    func setContainedView(_ content: @escaping (CGPoint) -> Content) {
        self.content = content
        self.hostedVC = UIHostingController(
            rootView:
                TapReadingView { //IntrinsicSizeDiDChange
                
                } content: { lastTapLocation in
                    content(lastTapLocation)
                }
        )
        
    }
}

SpatialTapGestureView:

import Foundation
import SwiftUI

///A view that allows the detection and location of tap gestures within the `Content` view.
///
///If not explicitly specified otherwise, this view will automatically expand its size to full width and height
///similarly to a `GeometryReader`. To prevent this from happening, specify `.fixedSize()`
///as a modifier for this view. This will cause the view to wrap its content. If the frame of the `Content` view changes
///the underlying `UIViewController` invalidates the `UIHostingController`'s `intrinsicContentSize` and
///resizes accordingly to allow a correct tap detection within all the new frame.
///
///In this implementation, wrapping a `ScrollView` within a `SpatialTapGestureView` will break scroll in
///the `ScrollView`, therefore if you want to use these two `View`s in association, it is recommended to let `ScrollView`
///be the outer, wrapper `View`, and `SpatialTapGestureView` as the inner `View`. Here is an example of usage:
///
///```
///    ScrollView(.horizontal) {
///         SpatialTapGestureView { tl in
///             LazyHStack(spacing: 0) {
///                 ForEach(0..<10) { _ in
///                     Rectangle()
///                         .fill(Color.random())
///                         .frame(width: 100, height: 100)
///                         .overlay {
///                             Text("\(tl.x, specifier: "%.1f"), \(tl.y, specifier: "%.1f")")
///                                 .font(.headline)
///                         }
///                 }
///             }
///         }
///         .fixedSize()
///     }
///```
///
/// **Parameters**
/// - `numberOfTaps`: the number of taps required to fire the gesture event
/// - `Content`: the SwiftUI view that needs to track the tap gesture
///
struct SpatialTapGestureView<Content>: UIViewControllerRepresentable where Content : View {
    
    private var content: (CGPoint) -> Content
    private var numberOfTaps: Int
    
    init(numberOfTaps: Int = 1, content: @escaping (CGPoint) -> Content) {
        self.content = content
        self.numberOfTaps = numberOfTaps
    }
    
    
    func makeCoordinator() -> SpatialGestureCoordinator<Content> {
        return SpatialGestureCoordinator<Content>(content: self.content)
    }
    
    ///1. Initialize the view controller with the hosted view
    ///2. Attach an initial gesture recognizer to the SwiftUI hosted view
    func makeUIViewController(context: Context) -> UIViewController {
        guard let hostedVC = context.coordinator.getHostingController() else {
            fatalError("Could not initialize hosted view")
        }
                        
        let wrappingViewController = TapReadingVC<Content>(
            hostingController: hostedVC,
            numberOfTaps: self.numberOfTaps
        )
        
        let tapGestureRecognizer = UITapGestureRecognizer(
            target: self, action: #selector(context.coordinator.respondToTap)
        )
        
        tapGestureRecognizer.numberOfTapsRequired = self.numberOfTaps
        hostedVC.view.addGestureRecognizer(tapGestureRecognizer)
        
        return wrappingViewController
    }
    
    ///1. Update the coordinator's contained view
    ///2. Call the ViewController's content update method
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {

        context.coordinator.setContainedView(self.content)
        
        guard let hostedVC = context.coordinator.getHostingController() else { return }
        guard let vc = (uiViewController as? TapReadingVC<Content>) else {
            fatalError("Could not convert VC to TapReadingVC")
        }
                
        vc.replaceHostedVC(
            with: hostedVC,
            target: context.coordinator,
            action: #selector(context.coordinator.respondToTap(_:))
        )
    }

}

This is an usage example that reproduces the bad performance issues:

import SwiftUI

struct ContentView: View {
    @State private var rectanglesHeight: CGFloat = 100.0
    @State private var lastTapLocation: CGPoint = CGPoint(x: Double.nan, y: Double.nan)
    
    var body: some View {
        VStack {
            Spacer()
            
            ScrollView(.horizontal) {
                SpatialTapGestureView { tl in
                    LazyHStack(spacing: 0) {
                        ForEach(0..<100) { _ in
                            Rectangle()
                                .fill(Color.random())
                                .frame(width: 100, height: self.rectanglesHeight)
                                .overlay {
                                    Text("\(self.lastTapLocation.x, specifier: "%.1f"), \(self.lastTapLocation.y, specifier: "%.1f")")
                                        .font(.headline)
                                }
                                .onChange(of: tl, perform: { nextTL in
                                    guard nextTL.isFinite() else { return }
                                    self.lastTapLocation = nextTL
                                })
                        }
                    }
                }
                .fixedSize()
            }
            
            Slider(
                value: self.$rectanglesHeight,
                in: 50...UIScreen.main.bounds.height/3,
                step: 1
            )
            
            Spacer()
        }
    }
}


extension CGPoint {
    func isFinite() -> Bool {
        return self.x.isFinite && self.y.isFinite
    }
}

Can the performance issues be solved or is it inherent to the integration I'm trying? What alternatives do I have or how can I improve?

Baffo rasta
  • 320
  • 1
  • 4
  • 17
  • Does this answer your question? [How to detect a tap gesture location in SwiftUI?](https://stackoverflow.com/questions/56513942/how-to-detect-a-tap-gesture-location-in-swiftui) – Yrb May 16 '23 at 15:27
  • @Yrp: I already found that post and Yiannis' answer helped me solving. At the same time, I'd like to understand what was wrong with my approach for learning purposes. – Baffo rasta May 18 '23 at 08:42

0 Answers0