2024-04-12 00:20:38 +02:00
|
|
|
import ExpoModulesCore
|
|
|
|
|
|
|
|
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
|
|
|
// to apply the proper styling (e.g. border radius and shadows).
|
|
|
|
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
|
|
|
|
var scrollViewTag: Int? {
|
|
|
|
didSet {
|
|
|
|
self.tryFindScrollView()
|
|
|
|
}
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
private var rctScrollView: RCTScrollView?
|
|
|
|
private var rctRefreshCtrl: RCTRefreshControl?
|
|
|
|
private var cancelGestureRecognizers: [UIGestureRecognizer]?
|
|
|
|
private var animTimer: Timer?
|
|
|
|
private var initialOffset: CGFloat = 0.0
|
|
|
|
private var didImpact: Bool = false
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
required init(appContext: AppContext? = nil) {
|
|
|
|
super.init(appContext: appContext)
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
|
|
|
|
pg.delegate = self
|
|
|
|
self.addGestureRecognizer(pg)
|
|
|
|
|
|
|
|
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
|
|
|
tg.isEnabled = false
|
|
|
|
tg.delegate = self
|
|
|
|
|
|
|
|
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
|
|
|
lpg.minimumPressDuration = 0.01
|
|
|
|
lpg.isEnabled = false
|
|
|
|
lpg.delegate = self
|
|
|
|
|
|
|
|
self.cancelGestureRecognizers = [lpg, tg]
|
|
|
|
}
|
|
|
|
|
|
|
|
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
|
|
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
|
|
|
|
return false
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
return true
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
|
|
|
|
// interfere with the native swipe back gesture.
|
|
|
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
|
|
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
|
|
|
|
return true
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
let velocity = gestureRecognizer.velocity(in: self)
|
|
|
|
return abs(velocity.y) > abs(velocity.x)
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
|
|
|
|
// recognizer for this one.
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
|
|
self.stopTimer()
|
|
|
|
}
|
|
|
|
|
|
|
|
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
|
|
|
|
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
|
2024-07-12 03:15:35 +02:00
|
|
|
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
|
2024-04-12 00:20:38 +02:00
|
|
|
self.stopTimer()
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
|
|
|
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
|
2024-04-12 00:20:38 +02:00
|
|
|
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let translation = sender.translation(in: self).y
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
if sender.state == .began {
|
|
|
|
if sv.contentOffset.y < 0 {
|
|
|
|
sv.contentOffset.y = 0
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.initialOffset = sv.contentOffset.y
|
|
|
|
}
|
|
|
|
|
|
|
|
if sender.state == .changed {
|
|
|
|
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
if sv.contentOffset.y <= -130, !didImpact {
|
|
|
|
let generator = UIImpactFeedbackGenerator(style: .light)
|
|
|
|
generator.impactOccurred()
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.didImpact = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if sender.state == .ended {
|
|
|
|
let velocity = sender.velocity(in: self).y
|
|
|
|
self.didImpact = false
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
if sv.contentOffset.y <= -130 {
|
|
|
|
self.rctRefreshCtrl?.forwarderBeginRefreshing()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
|
|
|
|
// scroll view
|
|
|
|
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
|
|
|
|
return
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.startDecayAnimation(translation, velocity)
|
|
|
|
}
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
|
|
|
|
guard let sv = self.rctScrollView?.scrollView else {
|
|
|
|
return
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
var velocity = velocity
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.enableCancelGestureRecognizers()
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
if velocity > 0 {
|
|
|
|
velocity = min(velocity, 5000)
|
|
|
|
} else {
|
|
|
|
velocity = max(velocity, -5000)
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
var animTranslation = -translation
|
2024-07-12 03:15:35 +02:00
|
|
|
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
|
2024-04-12 00:20:38 +02:00
|
|
|
velocity *= 0.9875
|
|
|
|
animTranslation = (-velocity / 120) + animTranslation
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
if nextOffset <= 0 {
|
|
|
|
if self.initialOffset <= 1 {
|
|
|
|
self.scrollToOffset(0)
|
|
|
|
} else {
|
|
|
|
sv.contentOffset.y = 0
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.stopTimer()
|
|
|
|
return
|
|
|
|
} else {
|
|
|
|
sv.contentOffset.y = nextOffset
|
|
|
|
}
|
|
|
|
|
|
|
|
if abs(velocity) < 5 {
|
|
|
|
self.stopTimer()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
func dampenOffset(_ offset: CGFloat) -> CGFloat {
|
|
|
|
if offset < 0 {
|
|
|
|
return offset - (offset * 0.55)
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
return offset
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
func tryFindScrollView() {
|
|
|
|
guard let scrollViewTag = scrollViewTag else {
|
|
|
|
return
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
|
|
|
|
// Otherwise we might end up with duplicates when we switch back to that scrollview.
|
|
|
|
self.removeCancelGestureRecognizers()
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.rctScrollView = self.appContext?
|
|
|
|
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
|
|
|
|
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
self.addCancelGestureRecognizers()
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
func addCancelGestureRecognizers() {
|
|
|
|
self.cancelGestureRecognizers?.forEach { r in
|
|
|
|
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
|
|
|
|
}
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
func removeCancelGestureRecognizers() {
|
|
|
|
self.cancelGestureRecognizers?.forEach { r in
|
|
|
|
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func enableCancelGestureRecognizers() {
|
|
|
|
self.cancelGestureRecognizers?.forEach { r in
|
|
|
|
r.isEnabled = true
|
|
|
|
}
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
2024-04-12 00:20:38 +02:00
|
|
|
func disableCancelGestureRecognizers() {
|
|
|
|
self.cancelGestureRecognizers?.forEach { r in
|
|
|
|
r.isEnabled = false
|
|
|
|
}
|
|
|
|
}
|
2024-07-12 03:15:35 +02:00
|
|
|
|
|
|
|
func scrollToOffset(_ offset: Int, animated: Bool = true) {
|
2024-04-12 00:20:38 +02:00
|
|
|
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
|
|
|
|
}
|
|
|
|
|
2024-07-12 03:15:35 +02:00
|
|
|
func stopTimer() {
|
2024-04-12 00:20:38 +02:00
|
|
|
self.disableCancelGestureRecognizers()
|
|
|
|
self.animTimer?.invalidate()
|
|
|
|
self.animTimer = nil
|
|
|
|
}
|
|
|
|
}
|