about summary refs log tree commit diff
path: root/modules/expo-scroll-forwarder
diff options
context:
space:
mode:
Diffstat (limited to 'modules/expo-scroll-forwarder')
-rw-r--r--modules/expo-scroll-forwarder/expo-module.config.json6
-rw-r--r--modules/expo-scroll-forwarder/index.ts1
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec21
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift13
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift215
-rw-r--r--modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts6
-rw-r--r--modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx13
-rw-r--r--modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx7
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
+}