Skip to content

Instantly share code, notes, and snippets.

@danhalliday
Last active June 21, 2024 16:59
Show Gist options
  • Save danhalliday/79b003d1cdbb84069c5c9f24fe069827 to your computer and use it in GitHub Desktop.
Save danhalliday/79b003d1cdbb84069c5c9f24fe069827 to your computer and use it in GitHub Desktop.
SwiftUI solution for responsive, instantly-tappable tiles in a scroll view.
import SwiftUI
/// SwiftUI implementation of a responsive-feeling scrollview with tappable tiles.
/// We use a custom UIScrollView via UIViewRepresentable and a UIView tap overlay to
/// get around current SwiftUI issues with simultaneous gesture recognition and allow
/// the tiles to respond instantly to presses while scrolling.
struct ContentView: View {
@State private var showSheet = false
var body: some View {
TappableScrollView {
VStack(spacing: 10) {
ForEach(1..<50) { number in
ContentTile(title: String(number)) { self.showSheet = true }
}
}
}
.sheet(isPresented: $showSheet, content: { EmptyView() })
}
}
struct ContentTile: View {
let title: String
let onTap: () -> Void
@State private var isPressed: Bool = false
var body: some View {
Text(self.title)
.frame(width: 400, height: 50)
.background(isPressed ? Color.blue : Color.gray.opacity(0.15))
.mask(RoundedRectangle(cornerRadius: 15, style: .continuous))
.scaleEffect(isPressed ? 0.95 : 1)
.overlay(TappableView(onTap: onTap, onPress: onPress))
}
private func onPress(isPressed: Bool) {
withAnimation(Animation.easeInOut.speed(2)) {
self.isPressed = isPressed
}
}
}
struct TappableScrollView<Content:View>: UIViewRepresentable {
private let content: UIView
private let scrollView = TappableUIScrollView()
init(@ViewBuilder content: () -> Content) {
self.content = UIHostingController(rootView: content()).view
self.content.backgroundColor = .clear
}
func makeUIView(context: Context) -> UIView {
scrollView.addSubview(content)
content.translatesAutoresizingMaskIntoConstraints = false
content.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
content.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
content.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
content.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
return scrollView
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
class TappableUIScrollView: UIScrollView {
init() {
super.init(frame: .zero)
delaysContentTouches = false
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
override func touchesShouldBegin(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) -> Bool {
if view is TappableUIView {
return false
}
return super.touchesShouldBegin(touches, with: event, in: view)
}
}
struct TappableView: UIViewRepresentable {
let onTap: () -> Void
let onPress: (Bool) -> Void
private let view = TappableUIView()
func makeUIView(context: Context) -> UIView {
addTapRecognizer(to: view, with: context)
addPressRecognizer(to: view, with: context)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
private func addTapRecognizer(to view: UIView, with context: Context) {
let recognizer = UITapGestureRecognizer()
recognizer.delegate = context.coordinator
recognizer.addTarget(context.coordinator, action: #selector(Coordinator.handleTap))
view.addGestureRecognizer(recognizer)
}
private func addPressRecognizer(to view: UIView, with context: Context) {
let recognizer = UILongPressGestureRecognizer()
recognizer.minimumPressDuration = 0
recognizer.delegate = context.coordinator
recognizer.addTarget(context.coordinator, action: #selector(Coordinator.handlePress))
view.addGestureRecognizer(recognizer)
}
class Coordinator: NSObject, UIGestureRecognizerDelegate {
private var parent: TappableView
init(parent: TappableView) {
self.parent = parent
}
@objc fileprivate func handlePress(_ sender: UIGestureRecognizer) {
switch sender.state {
case .began, .changed: parent.onPress(true)
case .possible, .ended, .cancelled, .failed: parent.onPress(false)
@unknown default: parent.onPress(false)
}
}
@objc fileprivate func handleTap(_ sender: UIGestureRecognizer) {
if case .ended = sender.state {
parent.onTap()
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
}
class TappableUIView: UIView {
init() {
super.init(frame: .zero)
backgroundColor = .clear
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
}
@je3f0o
Copy link

je3f0o commented Feb 2, 2023

Wow. I cannot believe this code has not even single comment yet...
Really great job. That is what I want to learn how to get around simultaneous gesture with scrollview.
Thank you.

@cifilter
Copy link

SwiftUI needs to expose more of UIScrollView's configuration parameters for stuff like delaying content touches.

@louwers
Copy link

louwers commented May 11, 2024

This will initialize the UIView every time the TappableView is created...

UIViews are not cheap I think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment