Skip to content

Instantly share code, notes, and snippets.

@mac-cain13
Last active July 21, 2020 20:22
Show Gist options
  • Save mac-cain13/a9b5a15eceedf66db6d44659549f5005 to your computer and use it in GitHub Desktop.
Save mac-cain13/a9b5a15eceedf66db6d44659549f5005 to your computer and use it in GitHub Desktop.
SwiftUI - ScrollViewSpy

ScrollViewLongPressSpy

Unleash the power of UIScrollView when just using SwiftUI ScrollView.

What is it?

A dummy "spy" view you can add inside your ScrollView that will attach a UILongPressGestureRecognizer to it and gives you a binding to the point where the finger is in the direct superview of the spy.

Why do I need it?

SwiftUI ScrollView is getting better every release, but currently (iOS 14) you can't add extra gesture recognizers. This is often needed for more complex interactions, this little spy gets that job done.

Is this the recommended way to add complex ScrollView behaviour?

Works great cases I encountered so far, no issues with state or reimplementing ScrollView yourself. You only need something like a ZStack to make the spy not take op screen realestate.

Just give it a try! :)

Alternatives

  1. To be less dependent on Apples SwiftUI ScrollView implementation; Fully wrap UIScrollView in a UIViewControllerRepresentable. (Note: You do a lot of work Apple already did.)
  2. For a nicer API; Make a wrapper that you can apply like a view modifier like this. (Note: This has issues with passing down state.)

Am I free to use this?

Yes, I've put this in the public domain!

//
// ScrollViewLongPressSpy.swift
//
// Created by Mathijs Kadijk on 12/07/2020.
//
import SwiftUI
import os
private let logger = Logger(subsystem: loggerSubsystem, category: "UI.ScrollViewLongPressSpy")
/// Searches for the first parent UIScrollView and attaches a UILongPressGestureRecognizer to track long presses
struct ScrollViewLongPressSpy: UIViewControllerRepresentable {
typealias UIViewControllerType = Controller
let longPressPosition: Binding<CGPoint?>
func makeUIViewController(context: UIViewControllerRepresentableContext<ScrollViewLongPressSpy>) -> Controller {
return Controller(longPressPosition: longPressPosition)
}
func updateUIViewController(_ uiViewController: Controller, context: UIViewControllerRepresentableContext<ScrollViewLongPressSpy>) {
// No-op
}
final class Controller: UIViewController {
private enum ScrollViewLookup {
case pending
case failed
case succeeded(UIScrollView)
}
private let longPressGestureRecognizer: UILongPressGestureRecognizer
private let longPressPosition: Binding<CGPoint?>
private var scrollViewLookup: ScrollViewLookup = .pending {
didSet {
if case .succeeded(let scrollView) = scrollViewLookup {
logger.notice("Recognizer added to UIScrollView")
scrollView.addGestureRecognizer(longPressGestureRecognizer)
}
}
}
fileprivate init(longPressPosition: Binding<CGPoint?>) {
self.longPressGestureRecognizer = UILongPressGestureRecognizer()
self.longPressPosition = longPressPosition
super.init(nibName: nil, bundle: nil)
longPressGestureRecognizer.addTarget(self, action: #selector(longPressHandler))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear(_ animated: Bool) {
scrollViewLookup = perform(scrollViewLookup: findUIScrollView, whenPending: scrollViewLookup)
super.viewDidAppear(animated)
}
@objc private func longPressHandler(_ recognizer: UILongPressGestureRecognizer) {
switch recognizer.state {
case .began:
logger.debug("Long press detected")
fallthrough
case .changed:
longPressPosition.wrappedValue = recognizer.location(in: view.superview)
case .cancelled, .ended, .failed, .possible:
logger.debug("Long press stopped")
longPressPosition.wrappedValue = nil
@unknown default:
logger.warning("Recognizer changed to unknown state")
longPressPosition.wrappedValue = nil
}
}
private func perform(scrollViewLookup: (UIView) -> UIScrollView?, whenPending currentState: ScrollViewLookup) -> ScrollViewLookup {
guard case .pending = currentState else {
logger.info("UIScrollView lookup already performed")
return currentState
}
if let scrollView = scrollViewLookup(view) {
return .succeeded(scrollView)
} else {
logger.error("UIScrollView not found")
return .failed
}
}
private func findUIScrollView(view: UIView) -> UIScrollView? {
if view.isKind(of: UIScrollView.self) {
if let scrollView = view as? UIScrollView {
return scrollView
} else {
logger.error("UIScrollView cast failed")
return nil
}
}
if let superview = view.superview {
return findUIScrollView(view: superview)
}
return nil
}
}
}

This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.

In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to https://unlicense.org

import SwiftUI
struct UsageExample: View {
@State var longPressPositionValue: CGPoint?
var body: some View {
ScrollView {
ZStack {
ScrollViewLongPressSpy(longPressPosition: $longPressPositionValue)
Rectangle()
.frame(width: 50, height: 50)
.position(x: longPressPositionValue?.x ?? 0, y: longPressPositionValue?.y ?? 0)
}
}
}
}
struct UsageExample_Previews: PreviewProvider {
static var previews: some View {
Example()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment