@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.
- package/README.md +57 -4
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +379 -31
- package/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt +15 -0
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt +26 -0
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +41 -0
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +600 -0
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +145 -0
- package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
- package/android/src/main/jni/CMakeLists.txt +19 -0
- package/android/src/main/jni/pagerview.h +11 -0
- package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -0
- package/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt +26 -0
- package/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt +31 -0
- package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +75 -0
- package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h +8 -0
- package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp +15 -0
- package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h +23 -0
- package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h +33 -0
- package/docs/SPEC.md +450 -0
- package/ios/RNCCollapsiblePagerReleasedStatePolicy.h +8 -0
- package/ios/RNCCollapsiblePagerReleasedStatePolicy.m +13 -0
- package/ios/RNCCollapsiblePagerViewComponentView.mm +414 -1
- package/ios/RNCNativeScrollerComponentView.h +17 -0
- package/ios/RNCNativeScrollerComponentView.mm +479 -0
- package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +80 -0
- package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +788 -0
- package/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm +109 -0
- package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +218 -0
- package/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm +23 -0
- package/ios/tests/RNCNativeScrollerTests.mm +252 -0
- package/lib/module/CollapsiblePagerView.js +10 -1
- package/lib/module/NativeScroller.js +171 -11
- package/lib/module/NativeScrollerNativeComponent.ts +101 -0
- package/lib/typescript/src/CollapsiblePagerView.d.ts +3 -3
- package/lib/typescript/src/NativeScroller.d.ts +15 -7
- package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +79 -0
- package/package.json +8 -4
- package/react-native-pager-view.podspec +2 -1
- package/react-native.config.js +15 -0
- package/src/CollapsiblePagerView.tsx +14 -4
- package/src/NativeScroller.tsx +291 -14
- package/src/NativeScrollerNativeComponent.ts +101 -0
package/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt
ADDED
|
@@ -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
|
+
}
|