Last active
March 20, 2025 07:39
-
-
Save danhalliday/79b003d1cdbb84069c5c9f24fe069827 to your computer and use it in GitHub Desktop.
SwiftUI solution for responsive, instantly-tappable tiles in a scroll view.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
SwiftUI needs to expose more of UIScrollView
's configuration parameters for stuff like delaying content touches.
This will initialize the UIView every time the TappableView is created...
UIViews are not cheap I think?
Can’t believe we need this trick to make things inside scroll view tappable in SwiftUI. Thank you!🙏
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.