diff options
Diffstat (limited to 'modules')
8 files changed, 282 insertions, 0 deletions
diff --git a/modules/expo-scroll-forwarder/expo-module.config.json b/modules/expo-scroll-forwarder/expo-module.config.json new file mode 100644 index 000000000..1fd49f79b --- /dev/null +++ b/modules/expo-scroll-forwarder/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["ExpoScrollForwarderModule"] + } +} diff --git a/modules/expo-scroll-forwarder/index.ts b/modules/expo-scroll-forwarder/index.ts new file mode 100644 index 000000000..a4ad4b850 --- /dev/null +++ b/modules/expo-scroll-forwarder/index.ts @@ -0,0 +1 @@ +export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView' diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec new file mode 100644 index 000000000..78ca9812e --- /dev/null +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'ExpoScrollForwarder' + s.version = '1.0.0' + s.summary = 'Forward scroll gesture from UIView to UIScrollView' + s.description = 'Forward scroll gesture from UIView to UIScrollView' + s.author = 'bluesky-social' + s.homepage = 'https://github.com/bluesky-social/social-app' + s.platforms = { :ios => '13.4', :tvos => '13.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift new file mode 100644 index 000000000..c4ecc788e --- /dev/null +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift @@ -0,0 +1,13 @@ +import ExpoModulesCore + +public class ExpoScrollForwarderModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoScrollForwarder") + + View(ExpoScrollForwarderView.self) { + Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in + view.scrollViewTag = prop + } + } + } +} diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift new file mode 100644 index 000000000..9c0e2f872 --- /dev/null +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift @@ -0,0 +1,215 @@ +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() + } + } + + 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 + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + 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 + } + + return true + } + + // 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 + } + + let velocity = gestureRecognizer.velocity(in: self) + return abs(velocity.y) > abs(velocity.x) + } + + // 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. + @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { + self.stopTimer() + } + + @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void { + guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { + return + } + + let translation = sender.translation(in: self).y + + if sender.state == .began { + if sv.contentOffset.y < 0 { + sv.contentOffset.y = 0 + } + + self.initialOffset = sv.contentOffset.y + } + + if sender.state == .changed { + sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset) + + if sv.contentOffset.y <= -130, !didImpact { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + + self.didImpact = true + } + } + + if sender.state == .ended { + let velocity = sender.velocity(in: self).y + self.didImpact = false + + 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 + } + + self.startDecayAnimation(translation, velocity) + } + } + + func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) { + guard let sv = self.rctScrollView?.scrollView else { + return + } + + var velocity = velocity + + self.enableCancelGestureRecognizers() + + if velocity > 0 { + velocity = min(velocity, 5000) + } else { + velocity = max(velocity, -5000) + } + + var animTranslation = -translation + self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in + velocity *= 0.9875 + animTranslation = (-velocity / 120) + animTranslation + + let nextOffset = self.dampenOffset(animTranslation + self.initialOffset) + + if nextOffset <= 0 { + if self.initialOffset <= 1 { + self.scrollToOffset(0) + } else { + sv.contentOffset.y = 0 + } + + self.stopTimer() + return + } else { + sv.contentOffset.y = nextOffset + } + + if abs(velocity) < 5 { + self.stopTimer() + } + } + } + + func dampenOffset(_ offset: CGFloat) -> CGFloat { + if offset < 0 { + return offset - (offset * 0.55) + } + + return offset + } + + func tryFindScrollView() { + guard let scrollViewTag = scrollViewTag else { + return + } + + // 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() + + self.rctScrollView = self.appContext? + .findView(withTag: scrollViewTag, ofType: RCTScrollView.self) + self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl + + self.addCancelGestureRecognizers() + } + + func addCancelGestureRecognizers() { + self.cancelGestureRecognizers?.forEach { r in + self.rctScrollView?.scrollView?.addGestureRecognizer(r) + } + } + + func removeCancelGestureRecognizers() { + self.cancelGestureRecognizers?.forEach { r in + self.rctScrollView?.scrollView?.removeGestureRecognizer(r) + } + } + + + func enableCancelGestureRecognizers() { + self.cancelGestureRecognizers?.forEach { r in + r.isEnabled = true + } + } + + func disableCancelGestureRecognizers() { + self.cancelGestureRecognizers?.forEach { r in + r.isEnabled = false + } + } + + func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void { + self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated) + } + + func stopTimer() -> Void { + self.disableCancelGestureRecognizers() + self.animTimer?.invalidate() + self.animTimer = nil + } +} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts new file mode 100644 index 000000000..26b9e7553 --- /dev/null +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts @@ -0,0 +1,6 @@ +import React from 'react' + +export interface ExpoScrollForwarderViewProps { + scrollViewTag: number | null + children: React.ReactNode +} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx new file mode 100644 index 000000000..a91aebd4d --- /dev/null +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx @@ -0,0 +1,13 @@ +import {requireNativeViewManager} from 'expo-modules-core' +import * as React from 'react' +import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' + +const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> = + requireNativeViewManager('ExpoScrollForwarder') + +export function ExpoScrollForwarderView({ + children, + ...rest +}: ExpoScrollForwarderViewProps) { + return <NativeView {...rest}>{children}</NativeView> +} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx new file mode 100644 index 000000000..93e69333f --- /dev/null +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx @@ -0,0 +1,7 @@ +import React from 'react' +import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' +export function ExpoScrollForwarderView({ + children, +}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) { + return children +} |