about summary refs log tree commit diff
path: root/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift
blob: 9c0e2f87287f600001b76ab53533b6274671d92e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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
  }
}