about summary refs log tree commit diff
path: root/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift')
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift215
1 files changed, 215 insertions, 0 deletions
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
+  }
+}