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:
- I can't use
DragGesture(minimumDistance: 0)
simultaneously to aTapGesture(count: 2)
because the drag gesture is needed for something else and can't be overridden. - I can't use
onTapGesture { location in
since the target iOS version isiOS 15.0
in my project. - I can't use
Introspect
since the large view I'm talking about is a largeLazyHStack
and there is no knowledge of what kind ofUIView
s 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?