@onekeyfe/react-native-pager-view 3.0.158 → 3.0.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +57 -4
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +379 -31
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt +15 -0
  4. package/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt +26 -0
  5. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +41 -0
  6. package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +600 -0
  7. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +145 -0
  8. package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
  9. package/android/src/main/jni/CMakeLists.txt +19 -0
  10. package/android/src/main/jni/pagerview.h +11 -0
  11. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -0
  12. package/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt +26 -0
  13. package/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt +31 -0
  14. package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +75 -0
  15. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h +8 -0
  16. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp +15 -0
  17. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h +23 -0
  18. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h +33 -0
  19. package/docs/SPEC.md +450 -0
  20. package/ios/RNCCollapsiblePagerReleasedStatePolicy.h +8 -0
  21. package/ios/RNCCollapsiblePagerReleasedStatePolicy.m +13 -0
  22. package/ios/RNCCollapsiblePagerViewComponentView.mm +414 -1
  23. package/ios/RNCNativeScrollerComponentView.h +17 -0
  24. package/ios/RNCNativeScrollerComponentView.mm +479 -0
  25. package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +80 -0
  26. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +788 -0
  27. package/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm +109 -0
  28. package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +218 -0
  29. package/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm +23 -0
  30. package/ios/tests/RNCNativeScrollerTests.mm +252 -0
  31. package/lib/module/CollapsiblePagerView.js +10 -1
  32. package/lib/module/NativeScroller.js +171 -11
  33. package/lib/module/NativeScrollerNativeComponent.ts +101 -0
  34. package/lib/typescript/src/CollapsiblePagerView.d.ts +3 -3
  35. package/lib/typescript/src/NativeScroller.d.ts +15 -7
  36. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +79 -0
  37. package/package.json +8 -4
  38. package/react-native-pager-view.podspec +2 -1
  39. package/react-native.config.js +15 -0
  40. package/src/CollapsiblePagerView.tsx +14 -4
  41. package/src/NativeScroller.tsx +291 -14
  42. package/src/NativeScrollerNativeComponent.ts +101 -0
@@ -0,0 +1,15 @@
1
+ package com.reactnativepagerview
2
+
3
+ import java.util.WeakHashMap
4
+
5
+ /** Native tag payload; leaf observers can read it as Map without a pager dependency. */
6
+ internal class NativeScrollCoordinatorContributions : WeakHashMap<Any, Int>() {
7
+ fun update(owner: Any, pixels: Int): Boolean {
8
+ val value = pixels.coerceAtLeast(0)
9
+ if (get(owner) == value) return false
10
+ put(owner, value)
11
+ return true
12
+ }
13
+
14
+ fun clearOwner(owner: Any): Boolean = remove(owner) != null
15
+ }
@@ -0,0 +1,26 @@
1
+ package com.reactnativepagerview
2
+
3
+ /**
4
+ * A pager-owned offset restoration that NestedScrollView clamped because the
5
+ * React content height had not reached native yet. It is reapplied on content
6
+ * layout until reached or cancelled by user/command/content replacement.
7
+ */
8
+ internal class NativeScrollerPendingRestore {
9
+ var target: Int? = null
10
+ private set
11
+
12
+ fun request(target: Int, reached: Int) {
13
+ this.target = if (reached == target) null else target
14
+ }
15
+
16
+ /** The offset to reapply after a content layout pass. */
17
+ fun anchor(current: Int): Int = target ?: current
18
+
19
+ fun settle(reached: Int) {
20
+ if (target == reached) target = null
21
+ }
22
+
23
+ fun cancel() {
24
+ target = null
25
+ }
26
+ }
@@ -0,0 +1,41 @@
1
+ package com.reactnativepagerview
2
+
3
+ import java.util.WeakHashMap
4
+
5
+ // Replace only this pager's contribution; another native owner may share the scroller.
6
+ internal fun nativeScrollerScrollAwayTop(currentTop: Int, previousInset: Int, nextInset: Int): Int =
7
+ (currentTop - previousInset).coerceAtLeast(0) + nextInset
8
+
9
+ // ReactScrollView's ScrollAway setter always writes this exact padding shape.
10
+ internal fun nativeScrollerPaddingWasResetByScrollAway(
11
+ appliedInset: Int,
12
+ left: Int,
13
+ top: Int,
14
+ right: Int,
15
+ bottom: Int,
16
+ scrollAwayTop: Int,
17
+ scrollAwayBottom: Int,
18
+ ): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 &&
19
+ bottom == scrollAwayTop + scrollAwayBottom
20
+
21
+ // The refresh controller publishes its actual combined ancestor compensation.
22
+ internal fun nativeRefreshForegroundTop(indicatorTop: Int, appliedInset: Float): Float =
23
+ indicatorTop - appliedInset
24
+
25
+ internal class NativeRefreshForegroundAlpha(initialAlpha: Float) {
26
+ private val owners = WeakHashMap<Any, Unit>()
27
+ private var baseline = initialAlpha
28
+ private var lastApplied = initialAlpha
29
+
30
+ val isEmpty: Boolean
31
+ get() = owners.isEmpty()
32
+ val drawingAlpha: Float
33
+ get() = baseline
34
+
35
+ fun update(currentAlpha: Float, owner: Any, acquire: Boolean): Float {
36
+ if (currentAlpha != lastApplied) baseline = currentAlpha
37
+ if (acquire) owners[owner] = Unit else owners.remove(owner)
38
+ lastApplied = if (owners.isEmpty()) baseline else 0f
39
+ return lastApplied
40
+ }
41
+ }
@@ -0,0 +1,600 @@
1
+ package com.reactnativepagerview
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.os.SystemClock
6
+ import android.view.MotionEvent
7
+ import android.view.View
8
+ import android.view.VelocityTracker
9
+ import android.view.ViewConfiguration
10
+ import android.view.ViewGroup
11
+ import android.view.inputmethod.InputMethodManager
12
+ import android.widget.FrameLayout
13
+ import androidx.core.view.ViewCompat
14
+ import androidx.core.widget.NestedScrollView
15
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
16
+ import com.facebook.react.bridge.Arguments
17
+ import com.facebook.react.bridge.ReactContext
18
+ import com.facebook.react.bridge.WritableMap
19
+ import com.facebook.react.uimanager.StateWrapper
20
+ import com.facebook.react.uimanager.PixelUtil
21
+ import com.facebook.react.uimanager.UIManagerHelper
22
+ import com.facebook.react.uimanager.events.Event
23
+ import com.facebook.react.uimanager.events.NativeGestureUtil
24
+ import java.util.WeakHashMap
25
+ import kotlin.math.abs
26
+ import kotlin.math.max
27
+ import kotlin.math.roundToInt
28
+
29
+ /** Fabric owns the React child; this host owns only its native viewport and extent. */
30
+ class NativeScrollerView(context: Context) : FrameLayout(context) {
31
+ internal val viewport = NativeScrollerViewport(context, this)
32
+ private val refresh = NativeScrollerRefreshLayout(context)
33
+ private val contentOriginFrame = object : FrameLayout(context) {
34
+ override fun onMeasure(widthSpec: Int, heightSpec: Int) {
35
+ setMeasuredDimension(MeasureSpec.getSize(widthSpec), MeasureSpec.getSize(heightSpec))
36
+ }
37
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) = Unit
38
+ }
39
+ private val contentFrame = object : FrameLayout(context) {
40
+ override fun onMeasure(widthSpec: Int, heightSpec: Int) {
41
+ setMeasuredDimension(
42
+ max(MeasureSpec.getSize(widthSpec), contentWidthPx + insetLeft + insetRight),
43
+ contentHeightPx + contentTop + insetBottom,
44
+ )
45
+ }
46
+
47
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
48
+ // Only the native origin layer moves. Fabric owns its React child's layout/transform.
49
+ contentOriginFrame.measure(
50
+ MeasureSpec.makeMeasureSpec(contentWidthPx, MeasureSpec.EXACTLY),
51
+ MeasureSpec.makeMeasureSpec(contentHeightPx, MeasureSpec.EXACTLY),
52
+ )
53
+ contentOriginFrame.layout(insetLeft, contentTop, insetLeft + contentWidthPx, contentTop + contentHeightPx)
54
+ }
55
+ }
56
+ private val pagerInsets = WeakHashMap<View, Int>()
57
+ private var reactChild: View? = null
58
+ private var fabricState: StateWrapper? = null
59
+ private var lastOriginX: Int? = null
60
+ private var lastOriginY: Int? = null
61
+ private var lastAncestorY: Int? = null
62
+ private var lastContentViewportHeight: Int? = null
63
+ private val consumedOffsetListener = Runnable { publishContentOrigin() }
64
+ private var lastScrollEvent = 0L
65
+ private var laidOut = false
66
+ private var requestedRefresh = false
67
+ private var refreshStatePending = false
68
+ private val pendingRestore = NativeScrollerPendingRestore()
69
+ private var refreshOffset = 0
70
+ private var appliedRefreshOffset: Int? = null
71
+ private var appliedRefreshSize = SwipeRefreshLayout.DEFAULT
72
+ private var layoutPosted = false
73
+ internal var contentWidthPx = 0
74
+ internal var contentHeightPx = 0
75
+ internal var insetTop = 0
76
+ internal var insetRight = 0
77
+ internal var insetBottom = 0
78
+ internal var insetLeft = 0
79
+ internal var scrollEventThrottle = 0.0
80
+ internal var keyboardDismissMode = "none"
81
+ internal var keyboardShouldPersistTaps = "never"
82
+ internal var refreshEnabled = false
83
+ set(value) {
84
+ field = value
85
+ refresh.isEnabled = value && viewport.scrollEnabled
86
+ }
87
+ internal val contentTop: Int
88
+ get() = insetTop + pagerInsets.values.sum()
89
+
90
+ init {
91
+ clipChildren = true
92
+ contentFrame.clipChildren = false
93
+ contentOriginFrame.clipChildren = false
94
+ contentFrame.addView(contentOriginFrame)
95
+ viewport.isFillViewport = false
96
+ viewport.isSmoothScrollingEnabled = false
97
+ viewport.clipToPadding = false
98
+ viewport.addView(contentFrame, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
99
+ refresh.addView(viewport, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
100
+ super.addView(refresh, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
101
+ refresh.isEnabled = false
102
+ refresh.setColorSchemeColors(Color.GRAY)
103
+ refresh.setOnChildScrollUpCallback { _, _ -> viewport.canScrollVertically(-1) }
104
+ refresh.setOnRefreshListener { emit("topRefresh", Arguments.createMap()) }
105
+ refresh.onTouchStream = { event -> viewport.recordTouchEvent(event) }
106
+ refresh.onTouchStreamFinished = { viewport.releaseTouchTracking() }
107
+ refresh.onGestureStart = { event -> viewport.beginDrag(event) }
108
+ refresh.onGestureEnd = { event -> viewport.endDrag(event) }
109
+ }
110
+
111
+ internal fun setFabricState(wrapper: StateWrapper?) {
112
+ fabricState = wrapper
113
+ if (wrapper == null) {
114
+ lastOriginX = null
115
+ lastOriginY = null
116
+ lastAncestorY = null
117
+ lastContentViewportHeight = null
118
+ } else {
119
+ val state = wrapper.stateData
120
+ if (state == null || !state.hasKey("contentOriginX") || !state.hasKey("contentOriginY") ||
121
+ !state.hasKey("ancestorOffsetX") || !state.hasKey("ancestorOffsetY") ||
122
+ lastOriginX == null || lastOriginY == null || lastAncestorY == null ||
123
+ abs(state.getDouble("contentOriginX") - px(lastOriginX!!)) > 0.001 ||
124
+ abs(state.getDouble("contentOriginY") - px(lastOriginY!!)) > 0.001 ||
125
+ abs(state.getDouble("ancestorOffsetX")) > 0.001 ||
126
+ abs(state.getDouble("ancestorOffsetY") - px(lastAncestorY!!)) > 0.001) {
127
+ lastOriginX = null
128
+ lastOriginY = null
129
+ lastAncestorY = null
130
+ }
131
+ publishContentOrigin()
132
+ }
133
+ }
134
+
135
+ internal fun publishContentOrigin() {
136
+ val wrapper = fabricState ?: return
137
+ val ancestors = viewport.getTag(R.id.onekey_native_scroll_coordinator_consumed_offsets)
138
+ as? NativeScrollCoordinatorContributions
139
+ val x = insetLeft - viewport.scrollX
140
+ val y = contentTop - viewport.scrollY
141
+ val ancestorY = -(ancestors?.values?.sum() ?: 0)
142
+ if (lastOriginX == x && lastOriginY == y && lastAncestorY == ancestorY) return
143
+ lastOriginX = x
144
+ lastOriginY = y
145
+ lastAncestorY = ancestorY
146
+ wrapper.updateState(Arguments.createMap().apply {
147
+ putDouble("contentOriginX", px(x))
148
+ putDouble("contentOriginY", px(y))
149
+ putDouble("ancestorOffsetX", 0.0)
150
+ putDouble("ancestorOffsetY", px(ancestorY))
151
+ })
152
+ }
153
+
154
+ internal fun setContentSize(width: Int? = null, height: Int? = null) {
155
+ val nextWidth = width?.coerceAtLeast(0) ?: contentWidthPx
156
+ val nextHeight = height?.coerceAtLeast(0) ?: contentHeightPx
157
+ if (nextWidth == contentWidthPx && nextHeight == contentHeightPx) return
158
+ contentWidthPx = nextWidth
159
+ contentHeightPx = nextHeight
160
+ scheduleLayout()
161
+ }
162
+
163
+ internal fun setPagerInset(owner: View, inset: Int?) {
164
+ val next = inset?.coerceAtLeast(0)
165
+ if (pagerInsets[owner] == next) return
166
+ if (next == null) pagerInsets.remove(owner) else pagerInsets[owner] = next
167
+ scheduleLayout()
168
+ }
169
+
170
+ internal fun addReactChild(child: View, index: Int) {
171
+ require(index == 0 && reactChild == null) { "NativeScroller requires one React content view" }
172
+ viewport.stopMotion()
173
+ reactChild = child
174
+ contentOriginFrame.addView(child)
175
+ scheduleLayout()
176
+ }
177
+
178
+ internal fun removeReactChild() {
179
+ viewport.stopMotion()
180
+ pendingRestore.cancel()
181
+ reactChild?.let(contentOriginFrame::removeView)
182
+ reactChild = null
183
+ contentWidthPx = 0
184
+ contentHeightPx = 0
185
+ scheduleLayout()
186
+ }
187
+
188
+ internal fun reactChildCount() = if (reactChild == null) 0 else 1
189
+ internal fun reactChildAt(index: Int): View {
190
+ require(index == 0)
191
+ return requireNotNull(reactChild)
192
+ }
193
+
194
+ internal fun scheduleLayout() {
195
+ if (layoutPosted) return
196
+ layoutPosted = true
197
+ post {
198
+ layoutPosted = false
199
+ if (isAttachedToWindow && width > 0 && height > 0) layoutContents()
200
+ }
201
+ }
202
+
203
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
204
+ setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec))
205
+ refresh.measure(
206
+ MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
207
+ MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY),
208
+ )
209
+ }
210
+
211
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
212
+ layoutContents()
213
+ }
214
+
215
+ private fun layoutContents() {
216
+ val x = viewport.scrollX
217
+ val y = viewport.scrollY
218
+ contentFrame.forceLayout()
219
+ viewport.forceLayout()
220
+ refresh.forceLayout()
221
+ refresh.measure(
222
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
223
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
224
+ )
225
+ refresh.layout(0, 0, width, height)
226
+ // Reapply the current anchor after content shrink; NestedScrollView clamps it.
227
+ // A pager restoration clamped before content reached native resumes as it grows.
228
+ viewport.scrollTo(x, pendingRestore.anchor(y))
229
+ pendingRestore.settle(viewport.scrollY)
230
+ laidOut = true
231
+ publishContentViewport()
232
+ publishContentOrigin()
233
+ applyRefreshOffset()
234
+ // Apply only a prop received before the first layout. Reapplying it on later
235
+ // layouts would hide a gesture-started spinner before JS commits refreshing.
236
+ if (refreshStatePending) {
237
+ refreshStatePending = false
238
+ if (refresh.isRefreshing != requestedRefresh) refresh.isRefreshing = requestedRefresh
239
+ }
240
+ }
241
+
242
+ internal fun setRefreshing(value: Boolean) {
243
+ requestedRefresh = value
244
+ if (laidOut) refresh.isRefreshing = value else refreshStatePending = true
245
+ }
246
+
247
+ /** Scrolls to a pager-owned saved offset, retrying while content is too short to reach it. */
248
+ internal fun restoreScrollOffset(y: Int) {
249
+ val target = y.coerceAtLeast(0)
250
+ viewport.scrollTo(0, target)
251
+ pendingRestore.request(target, viewport.scrollY)
252
+ }
253
+
254
+ internal fun cancelPendingScrollRestore() = pendingRestore.cancel()
255
+
256
+ internal fun setRefreshOffset(value: Int) {
257
+ refreshOffset = value
258
+ applyRefreshOffset()
259
+ }
260
+
261
+ internal fun setRefreshSize(value: String?) {
262
+ val size = if (value == "large") SwipeRefreshLayout.LARGE else SwipeRefreshLayout.DEFAULT
263
+ if (appliedRefreshSize == size) return
264
+ appliedRefreshSize = size
265
+ refresh.setSize(size)
266
+ appliedRefreshOffset = null
267
+ applyRefreshOffset()
268
+ }
269
+
270
+ private fun applyRefreshOffset() {
271
+ if (!laidOut || appliedRefreshOffset == refreshOffset) return
272
+ val diameter = refresh.progressCircleDiameter
273
+ val wasRefreshing = refresh.isRefreshing
274
+ refresh.setProgressViewOffset(
275
+ false, refreshOffset - diameter, refreshOffset + dip(64.0) - diameter,
276
+ )
277
+ appliedRefreshOffset = refreshOffset
278
+ // This component reserves content insets, not refresh-control insets.
279
+ refresh.setTag(R.id.onekey_native_scroll_coordinator_refresh_inset, 0)
280
+ if (wasRefreshing) refresh.isRefreshing = true
281
+ }
282
+
283
+ internal fun setRefreshColors(colors: IntArray) {
284
+ refresh.setColorSchemeColors(*(if (colors.isEmpty()) intArrayOf(Color.GRAY) else colors))
285
+ }
286
+ internal fun setRefreshBackground(color: Int) = refresh.setProgressBackgroundColorSchemeColor(color)
287
+ internal fun setScrollEnabled(value: Boolean) {
288
+ viewport.scrollEnabled = value
289
+ refresh.isEnabled = refreshEnabled && value
290
+ if (!value) viewport.stopMotion()
291
+ }
292
+
293
+ internal fun scrollToPosition(x: Int, y: Int, animated: Boolean) {
294
+ viewport.stopMotion()
295
+ pendingRestore.cancel()
296
+ if (animated) viewport.scrollToAnimated(y)
297
+ else viewport.scrollTo(0, y.coerceAtLeast(0))
298
+ emitScroll(force = true)
299
+ }
300
+
301
+ internal fun scrollToEnd(animated: Boolean) = scrollToPosition(
302
+ 0, (contentHeightPx + contentTop + insetBottom - viewport.height).coerceAtLeast(0), animated,
303
+ )
304
+
305
+ internal fun emitScroll(force: Boolean = false) {
306
+ if (!isAttachedToWindow) return
307
+ val now = SystemClock.uptimeMillis()
308
+ if (!force && scrollEventThrottle > 16 && now - lastScrollEvent < scrollEventThrottle) return
309
+ lastScrollEvent = now
310
+ emit("topScroll", scrollPayload())
311
+ }
312
+
313
+ internal fun emitLifecycle(name: String, velocity: Double = 0.0) {
314
+ if (!isAttachedToWindow) return
315
+ emit(name, scrollPayload(velocity))
316
+ }
317
+
318
+ private fun scrollPayload(velocity: Double = 0.0): WritableMap = Arguments.createMap().apply {
319
+ fun pair(first: String, firstValue: Double, second: String, secondValue: Double) =
320
+ Arguments.createMap().apply { putDouble(first, firstValue); putDouble(second, secondValue) }
321
+ putMap("contentOffset", pair("x", px(viewport.scrollX), "y", px(viewport.scrollY)))
322
+ putMap("contentSize", pair("width", px(contentWidthPx), "height", px(contentHeightPx)))
323
+ putMap("layoutMeasurement", pair("width", px(width), "height", px(height)))
324
+ putMap("velocity", pair("x", 0.0, "y", velocity))
325
+ putMap("targetContentOffset", pair("x", px(viewport.scrollX), "y", px(viewport.scrollY)))
326
+ putMap("contentInset", Arguments.createMap().apply {
327
+ putDouble("top", px(insetTop)); putDouble("right", px(insetRight))
328
+ putDouble("bottom", px(insetBottom)); putDouble("left", px(insetLeft))
329
+ })
330
+ putDouble("zoomScale", 1.0)
331
+ }
332
+
333
+ private fun publishContentViewport() {
334
+ val availableHeight = (viewport.height - contentTop - insetBottom).coerceAtLeast(0)
335
+ if (lastContentViewportHeight == availableHeight) return
336
+ if (emit("topContentViewportChange", Arguments.createMap().apply {
337
+ putDouble("height", px(availableHeight))
338
+ })) {
339
+ lastContentViewportHeight = availableHeight
340
+ }
341
+ }
342
+
343
+ private fun emit(name: String, payload: WritableMap): Boolean {
344
+ if (!isAttachedToWindow || id == View.NO_ID) return false
345
+ val reactContext = context as? ReactContext ?: return false
346
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return false
347
+ dispatcher.dispatchEvent(
348
+ NativeScrollerEvent(UIManagerHelper.getSurfaceId(this), id, name, payload),
349
+ )
350
+ return true
351
+ }
352
+
353
+ internal fun dismissKeyboard() {
354
+ (context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
355
+ ?.hideSoftInputFromWindow(windowToken, 0)
356
+ }
357
+
358
+ override fun onAttachedToWindow() {
359
+ super.onAttachedToWindow()
360
+ viewport.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, consumedOffsetListener)
361
+ publishContentOrigin()
362
+ scheduleLayout()
363
+ }
364
+
365
+ override fun onDetachedFromWindow() {
366
+ lastContentViewportHeight = null
367
+ viewport.stopMotion()
368
+ viewport.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null)
369
+ super.onDetachedFromWindow()
370
+ }
371
+
372
+ internal fun dip(value: Double) = PixelUtil.toPixelFromDIP(value).roundToInt()
373
+ private fun px(value: Int) = PixelUtil.toDIPFromPixel(value.toFloat()).toDouble()
374
+ }
375
+
376
+ internal class NativeScrollerViewport(context: Context, internal val host: NativeScrollerView) : NestedScrollView(context) {
377
+ var scrollEnabled = true
378
+ private var dragging = false
379
+ private var momentum = false
380
+ private var stopping = false
381
+ private var startingProgrammaticScroll = false
382
+ private var downY = 0f
383
+ private var velocityTracker: VelocityTracker? = null
384
+ private var activePointerId = MotionEvent.INVALID_POINTER_ID
385
+ private val maximumVelocity = ViewConfiguration.get(context).scaledMaximumFlingVelocity
386
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
387
+
388
+ init {
389
+ isNestedScrollingEnabled = true
390
+ isVerticalScrollBarEnabled = true
391
+ overScrollMode = OVER_SCROLL_NEVER
392
+ }
393
+
394
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
395
+ if (!scrollEnabled) return false
396
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) downY = event.y
397
+ val intercepted = super.onInterceptTouchEvent(event)
398
+ if (intercepted && event.actionMasked == MotionEvent.ACTION_MOVE) beginDrag(event)
399
+ return intercepted
400
+ }
401
+
402
+ override fun onTouchEvent(event: MotionEvent): Boolean {
403
+ if (!scrollEnabled) return false
404
+ when (event.actionMasked) {
405
+ MotionEvent.ACTION_DOWN -> downY = event.y
406
+ MotionEvent.ACTION_MOVE -> {
407
+ val pointerIndex = event.findPointerIndex(activePointerId)
408
+ if (pointerIndex >= 0 && abs(event.getY(pointerIndex) - downY) > touchSlop) beginDrag(event)
409
+ }
410
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> endDrag(event)
411
+ }
412
+ return super.onTouchEvent(event)
413
+ }
414
+
415
+ // Record at the refresh boundary so interception does not truncate the pointer history.
416
+ internal fun recordTouchEvent(event: MotionEvent) {
417
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
418
+ host.cancelPendingScrollRestore()
419
+ stopMotion()
420
+ velocityTracker = VelocityTracker.obtain()
421
+ activePointerId = event.getPointerId(0)
422
+ downY = event.y
423
+ } else if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) {
424
+ activePointerId = event.getPointerId(event.actionIndex)
425
+ downY = event.getY(event.actionIndex)
426
+ } else if (event.actionMasked == MotionEvent.ACTION_POINTER_UP &&
427
+ event.getPointerId(event.actionIndex) == activePointerId) {
428
+ val replacement = if (event.actionIndex == 0) 1 else 0
429
+ activePointerId = if (replacement < event.pointerCount) event.getPointerId(replacement)
430
+ else MotionEvent.INVALID_POINTER_ID
431
+ if (replacement < event.pointerCount) downY = event.getY(replacement)
432
+ velocityTracker?.clear()
433
+ }
434
+ velocityTracker?.addMovement(event)
435
+ }
436
+
437
+ internal fun releaseTouchTracking() {
438
+ velocityTracker?.recycle()
439
+ velocityTracker = null
440
+ activePointerId = MotionEvent.INVALID_POINTER_ID
441
+ }
442
+
443
+ internal fun beginDrag(event: MotionEvent) {
444
+ if (dragging) return
445
+ dragging = true
446
+ // Header-forwarded touches bypass the refresh boundary's touch stream.
447
+ host.cancelPendingScrollRestore()
448
+ if (host.keyboardDismissMode != "none") host.dismissKeyboard()
449
+ NativeGestureUtil.notifyNativeGestureStarted(host, event)
450
+ host.emitLifecycle("topScrollBeginDrag")
451
+ }
452
+
453
+ internal fun endDrag(event: MotionEvent) {
454
+ if (!dragging) return
455
+ dragging = false
456
+ host.emitScroll(force = true)
457
+ val tracker = velocityTracker
458
+ val velocity = if (event.actionMasked == MotionEvent.ACTION_UP && tracker != null &&
459
+ activePointerId != MotionEvent.INVALID_POINTER_ID) {
460
+ tracker.computeCurrentVelocity(1000, maximumVelocity.toFloat())
461
+ tracker.getYVelocity(activePointerId) / 1000.0 / resources.displayMetrics.density
462
+ } else 0.0
463
+ host.emitLifecycle("topScrollEndDrag", velocity)
464
+ NativeGestureUtil.notifyNativeGestureEnded(host, event)
465
+ }
466
+
467
+ override fun fling(velocityY: Int) {
468
+ finishMomentum()
469
+ momentum = true
470
+ host.emitLifecycle("topMomentumScrollBegin", velocityY / 1000.0 / resources.displayMetrics.density)
471
+ // AndroidX uses one unbounded trajectory with pre/content/post nested consumption.
472
+ super.fling(velocityY)
473
+ }
474
+
475
+ internal fun scrollToAnimated(y: Int) {
476
+ val child = getChildAt(0) ?: return
477
+ val margins = child.layoutParams as FrameLayout.LayoutParams
478
+ val range = (child.height + margins.topMargin + margins.bottomMargin -
479
+ height + paddingTop + paddingBottom).coerceAtLeast(0)
480
+ val target = y.coerceIn(0, range)
481
+ if (target == scrollY) return
482
+ momentum = true
483
+ host.emitLifecycle("topMomentumScrollBegin")
484
+ startingProgrammaticScroll = true
485
+ try {
486
+ // AndroidX stops nested scrolling while starting its programmatic animation.
487
+ super.smoothScrollTo(0, target)
488
+ } finally {
489
+ startingProgrammaticScroll = false
490
+ }
491
+ // Commands within AndroidX's animation gap execute immediately instead of animating.
492
+ if (scrollY == target) finishMomentum()
493
+ }
494
+
495
+ override fun stopNestedScroll(type: Int) {
496
+ super.stopNestedScroll(type)
497
+ if (type == ViewCompat.TYPE_NON_TOUCH && !stopping && !startingProgrammaticScroll) finishMomentum()
498
+ }
499
+
500
+ internal fun stopMotion() {
501
+ if (dragging) {
502
+ val now = SystemClock.uptimeMillis()
503
+ val cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
504
+ try { endDrag(cancel) } finally { cancel.recycle() }
505
+ }
506
+ releaseTouchTracking()
507
+ // An idle view has nothing to stop. A zero fling would still start and stop a
508
+ // non-touch nested scroll, which ancestors observe as a finished gesture.
509
+ if (momentum || hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
510
+ stopping = true
511
+ try {
512
+ // Replace the same AndroidX trajectory with zero velocity without touching its smooth-scroll clock.
513
+ super.fling(0)
514
+ super.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
515
+ } finally {
516
+ stopping = false
517
+ }
518
+ }
519
+ finishMomentum()
520
+ }
521
+
522
+ private fun finishMomentum() {
523
+ if (!momentum) return
524
+ momentum = false
525
+ host.emitScroll(force = true)
526
+ host.emitLifecycle("topMomentumScrollEnd")
527
+ }
528
+
529
+ override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) {
530
+ super.onScrollChanged(x, y, oldX, oldY)
531
+ host.publishContentOrigin()
532
+ host.emitScroll()
533
+ }
534
+
535
+ internal fun flashIndicators() = awakenScrollBars()
536
+ }
537
+
538
+ private class NativeScrollerRefreshLayout(context: Context) : SwipeRefreshLayout(context) {
539
+ var onTouchStream: ((MotionEvent) -> Unit)? = null
540
+ var onTouchStreamFinished: (() -> Unit)? = null
541
+ var onGestureStart: ((MotionEvent) -> Unit)? = null
542
+ var onGestureEnd: ((MotionEvent) -> Unit)? = null
543
+ private var downX = 0f
544
+ private var downY = 0f
545
+ private var horizontal = false
546
+ private var intercepted = false
547
+ private val slop = ViewConfiguration.get(context).scaledTouchSlop
548
+
549
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
550
+ onTouchStream?.invoke(event)
551
+ try {
552
+ return super.dispatchTouchEvent(event)
553
+ } finally {
554
+ if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
555
+ onTouchStreamFinished?.invoke()
556
+ }
557
+ }
558
+ }
559
+
560
+ override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
561
+ super.requestDisallowInterceptTouchEvent(disallowIntercept)
562
+ parent?.requestDisallowInterceptTouchEvent(disallowIntercept)
563
+ }
564
+
565
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
566
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
567
+ downX = event.x
568
+ downY = event.y
569
+ horizontal = false
570
+ intercepted = false
571
+ } else if (event.actionMasked == MotionEvent.ACTION_MOVE && abs(event.x - downX) > slop &&
572
+ abs(event.x - downX) > abs(event.y - downY)) {
573
+ horizontal = true
574
+ }
575
+ if (horizontal) return false
576
+ val result = super.onInterceptTouchEvent(event)
577
+ if (result && !intercepted) {
578
+ intercepted = true
579
+ onGestureStart?.invoke(event)
580
+ }
581
+ return result
582
+ }
583
+
584
+ override fun onTouchEvent(event: MotionEvent): Boolean {
585
+ val result = super.onTouchEvent(event)
586
+ if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
587
+ if (intercepted) onGestureEnd?.invoke(event)
588
+ intercepted = false
589
+ }
590
+ return result
591
+ }
592
+ }
593
+
594
+ private class NativeScrollerEvent(
595
+ surface: Int, tag: Int, private val name: String, private val payload: WritableMap,
596
+ ) : Event<NativeScrollerEvent>(surface, tag) {
597
+ override fun getEventName() = name
598
+ override fun canCoalesce() = name == "topScroll"
599
+ override fun getEventData() = payload
600
+ }