about summary refs log tree commit diff
path: root/modules/expo-scroll-forwarder/ios
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-04-11 15:20:38 -0700
committerGitHub <noreply@github.com>2024-04-11 15:20:38 -0700
commit4e517720030184ef8c003ffad9b3ca5100619d2e (patch)
tree81f5467bcb7c0a5eae02fe9c23158bb2e1ad59c6 /modules/expo-scroll-forwarder/ios
parent740cd029d7162a936d16b427201eb8972e365b94 (diff)
downloadvoidsky-4e517720030184ef8c003ffad9b3ca5100619d2e.tar.zst
Make bio area scrollable on iOS (#2931)
* fix dampen logic

prevent ghost presses

handle refreshes, animations, and clamps

handle most cases for cancelling the scroll animation

handle animations

save point

simplify

remove unnecessary context

readme

apply offset on pan

find the RCTScrollView

send props, add native gesture recognizer

get the react tag

wrap the profile in context

create module

* fix swiping to go back

* remove debug

* use `findNodeHandle`

* create an expo module view

* port most of it to expo modules

* finish most of expomodules impl

* experiments

* remove refresh ability for now

* remove rn module

* changes

* cleanup a few issues

allow swipe back gesture

clean up types

always run animation if the final offset is < 0

separate logic

update patch readme

get the `RCTRefreshControl` working nicely

* gate new header

* organize
Diffstat (limited to 'modules/expo-scroll-forwarder/ios')
-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
3 files changed, 249 insertions, 0 deletions
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
+  }
+}