@onekeyfe/react-native-native-list 3.0.114 → 3.0.116

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.
@@ -4,11 +4,14 @@ import android.animation.TimeInterpolator
4
4
  import android.graphics.Canvas
5
5
  import android.graphics.Color
6
6
  import android.graphics.Paint
7
+ import android.graphics.Path
7
8
  import android.graphics.RectF
8
- import android.graphics.drawable.GradientDrawable
9
+ import android.graphics.drawable.ShapeDrawable
10
+ import android.graphics.drawable.shapes.PathShape
9
11
  import android.os.Bundle
10
12
  import android.os.Handler
11
13
  import android.os.Looper
14
+ import android.os.SystemClock
12
15
  import android.view.HapticFeedbackConstants
13
16
  import android.view.Choreographer
14
17
  import android.view.Gravity
@@ -30,6 +33,7 @@ import androidx.recyclerview.widget.LinearSmoothScroller
30
33
  import androidx.recyclerview.widget.RecyclerView
31
34
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
32
35
  import com.facebook.react.uimanager.ThemedReactContext
36
+ import com.margelo.nitro.nativelogger.OneKeyLog
33
37
  import org.json.JSONArray
34
38
  import org.json.JSONObject
35
39
  import java.lang.ref.WeakReference
@@ -87,7 +91,11 @@ class NativeListView(
87
91
  var onVisibleRangeChanged: ((String) -> Unit)? = null
88
92
  private val density = resources.displayMetrics.density
89
93
  private val recyclerView = RecyclerView(context)
94
+ private var configuredTopPaddingPx = 0
95
+ private var configuredBottomPaddingPx = 0
90
96
  private val refreshLayout = SwipeRefreshLayout(context)
97
+ private val refreshIndicatorTravelPx = refreshLayout.progressViewEndOffset
98
+ private var refreshIndicatorOffsetPx = 0
91
99
  private val contentContainer = FrameLayout(context)
92
100
  private val adapter = NativeListAdapter(reactContext)
93
101
  private val layoutManager = GridLayoutManager(context, 1)
@@ -148,6 +156,9 @@ class NativeListView(
148
156
  private var dragFrom = RecyclerView.NO_POSITION
149
157
  private var dragTo = RecyclerView.NO_POSITION
150
158
  private var pendingReorder: List<NativeListItem>? = null
159
+ private var reorderRelayoutActive = false
160
+ private var reorderRelayoutScheduled = false
161
+ private var lastReorderRelayoutLogAtMs = 0L
151
162
  private var sectionIndexEntries: List<NativeListSectionIndexEntry> = emptyList()
152
163
  private var sectionIndexScrubbing = false
153
164
  private var sectionIndexProgrammaticScroll = false
@@ -196,27 +207,28 @@ class NativeListView(
196
207
  contentContainer.addView(
197
208
  sectionIndexView,
198
209
  FrameLayout.LayoutParams(
199
- dp(SECTION_INDEX_RAIL_WIDTH_DP),
210
+ sectionIndexDp(SECTION_INDEX_RAIL_WIDTH_DP),
200
211
  FrameLayout.LayoutParams.MATCH_PARENT,
201
212
  Gravity.END,
202
213
  ),
203
214
  )
204
215
  sectionIndexPreview.apply {
205
216
  gravity = Gravity.CENTER
206
- textSize = NativeListScale.font(resources, 22f)
207
- typeface = NativeListFonts.semibold(context)
208
- visibility = GONE
217
+ textSize = 30f
218
+ typeface = NativeListFonts.regular(context)
219
+ setPadding(0, 0, sectionIndexDp(10), 0)
220
+ visibility = INVISIBLE
209
221
  alpha = 0f
210
222
  importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
211
223
  }
212
224
  contentContainer.addView(
213
225
  sectionIndexPreview,
214
226
  FrameLayout.LayoutParams(
215
- dp(SECTION_INDEX_PREVIEW_SIZE_DP),
216
- dp(SECTION_INDEX_PREVIEW_SIZE_DP),
227
+ sectionIndexDp(SECTION_INDEX_PREVIEW_WIDTH_DP),
228
+ sectionIndexDp(SECTION_INDEX_PREVIEW_HEIGHT_DP),
217
229
  Gravity.CENTER_VERTICAL or Gravity.END,
218
230
  ).apply {
219
- marginEnd = dp(SECTION_INDEX_PREVIEW_END_MARGIN_DP)
231
+ marginEnd = sectionIndexDp(SECTION_INDEX_PREVIEW_END_MARGIN_DP)
220
232
  },
221
233
  )
222
234
  addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
@@ -241,6 +253,11 @@ class NativeListView(
241
253
  JSONObject().put("actionKey", "nativeList.refresh"),
242
254
  )
243
255
  }
256
+ recyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
257
+ // OneKey patch: a collapsible pager owns the extra padding above the rows.
258
+ // Keep the refresh control below that header without moving ordinary lists.
259
+ updateRefreshIndicatorOffset((recyclerView.paddingTop - configuredTopPaddingPx).coerceAtLeast(0))
260
+ }
244
261
 
245
262
  recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
246
263
  override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
@@ -264,6 +281,12 @@ class NativeListView(
264
281
 
265
282
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
266
283
  super.onLayout(changed, left, top, right, bottom)
284
+ val first = config?.items?.firstOrNull()
285
+ if (recyclerView.isLayoutRequested && (first?.type == "market" || first?.json?.optString("presentation") == "market")) {
286
+ // A retained Market page can keep cached parent measurements after its diff.
287
+ // Drain the child's pending layout so its committed rows replace the old cells.
288
+ relayoutRecyclerViewImmediately()
289
+ }
267
290
  val nextWidth = right - left
268
291
  val nextHeight = bottom - top
269
292
  if (
@@ -278,6 +301,28 @@ class NativeListView(
278
301
  performPendingScrollIfNeeded()
279
302
  }
280
303
 
304
+ private fun updateRefreshIndicatorOffset(offset: Int) {
305
+ if (refreshIndicatorOffsetPx == offset) return
306
+ refreshIndicatorOffsetPx = offset
307
+ val refreshing = refreshLayout.isRefreshing
308
+ val start = offset - refreshLayout.progressCircleDiameter
309
+ refreshLayout.setProgressViewOffset(false, start, start + refreshIndicatorTravelPx)
310
+ refreshLayout.isRefreshing = refreshing
311
+ }
312
+
313
+ override fun onAttachedToWindow() {
314
+ super.onAttachedToWindow()
315
+ val first = config?.items?.firstOrNull()
316
+ if (recyclerView.isLayoutRequested && (first?.type == "market" || first?.json?.optString("presentation") == "market")) {
317
+ relayoutContents()
318
+ }
319
+ }
320
+
321
+ override fun onDetachedFromWindow() {
322
+ updateRefreshIndicatorOffset(0)
323
+ super.onDetachedFromWindow()
324
+ }
325
+
281
326
  fun applySnapshot(snapshotJson: String) {
282
327
  val next = try {
283
328
  NativeListConfig.parse(snapshotJson)
@@ -318,6 +363,18 @@ class NativeListView(
318
363
  }
319
364
  return
320
365
  }
366
+ // Keep the first Market row at its current pixel offset when it is
367
+ // reordered. RecyclerView otherwise follows the previous first key.
368
+ val marketStartOffset = if (
369
+ previous?.items?.firstOrNull()?.type == "market" &&
370
+ next.items.firstOrNull()?.type == "market" &&
371
+ previous.items.firstOrNull()?.key != next.items.firstOrNull()?.key &&
372
+ layoutManager.findFirstVisibleItemPosition() == 0
373
+ ) {
374
+ layoutManager.findViewByPosition(0)?.let {
375
+ layoutManager.getDecoratedTop(it) - recyclerView.paddingTop
376
+ }
377
+ } else null
321
378
  config = next
322
379
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
323
380
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
@@ -330,6 +387,9 @@ class NativeListView(
330
387
  configureSectionIndex(next)
331
388
  updateLayout(next)
332
389
  adapter.submitList(next.items) {
390
+ if (marketStartOffset != null) {
391
+ layoutManager.scrollToPositionWithOffset(0, marketStartOffset)
392
+ }
333
393
  relayoutContents()
334
394
  performPendingScrollIfNeeded()
335
395
  syncSectionIndexToVisibleRows()
@@ -500,9 +560,13 @@ class NativeListView(
500
560
  }
501
561
  val nextItems = current.items.toMutableList()
502
562
  val selected = LinkedHashSet(current.selectedKeys)
563
+ var marketQuoteOnly = pending.isNotEmpty()
503
564
  try {
504
565
  pending.forEach { (index, changes) ->
505
566
  val previous = nextItems[index]
567
+ marketQuoteOnly = marketQuoteOnly &&
568
+ previous.type == "market" &&
569
+ changes.keys().asSequence().all(MARKET_QUOTE_FIELDS::contains)
506
570
  val merged = NativeListItem.parse(mergeRow(previous.json, changes))
507
571
  nextItems[index] = merged
508
572
  if (changes.has("selected")) {
@@ -512,7 +576,7 @@ class NativeListView(
512
576
  } catch (_: Exception) {
513
577
  return
514
578
  }
515
- invalidateActionAnchor("snapshot")
579
+ if (!marketQuoteOnly) invalidateActionAnchor("snapshot")
516
580
  val next = current.copy(items = nextItems, selectedKeys = selected)
517
581
  config = next
518
582
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
@@ -843,6 +907,7 @@ class NativeListView(
843
907
 
844
908
  fun dispose() {
845
909
  if (disposed) return
910
+ stopReorderRelayoutLoop()
846
911
  invalidateActionAnchor("destroy")
847
912
  actionAnchor = null
848
913
  disposed = true
@@ -872,11 +937,17 @@ class NativeListView(
872
937
  val bottomPadding = next.contentPaddingBottom ?: defaultPadding
873
938
  // OneKey patch: the section index overlays rows and keeps only an accessory-safe inset.
874
939
  val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else SECTION_INDEX_CONTENT_INSET_DP
940
+ // A native scroll coordinator may add header insets to this RecyclerView.
941
+ // Content/theme snapshots must replace only the padding owned by NativeList.
942
+ val coordinatorTopPadding = (recyclerView.paddingTop - configuredTopPaddingPx).coerceAtLeast(0)
943
+ val coordinatorBottomPadding = (recyclerView.paddingBottom - configuredBottomPaddingPx).coerceAtLeast(0)
944
+ configuredTopPaddingPx = dp(topPadding)
945
+ configuredBottomPaddingPx = dp(bottomPadding)
875
946
  recyclerView.setPaddingRelative(
876
947
  dp(horizontalPadding),
877
- dp(topPadding),
948
+ configuredTopPaddingPx + coordinatorTopPadding,
878
949
  dp(horizontalPadding + indexGutter),
879
- dp(bottomPadding),
950
+ configuredBottomPaddingPx + coordinatorBottomPadding,
880
951
  )
881
952
  recyclerView.clipToPadding = false
882
953
  recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty()
@@ -907,17 +978,14 @@ class NativeListView(
907
978
  sectionIndexHapticsEnabled = next.sectionIndexHapticsEnabled
908
979
  sectionIndexView.configure(
909
980
  sectionIndexEntries.map { it.title },
910
- themeColor(next.theme, "secondaryText", "#646464"),
911
- themeColor(next.theme, "accent", "#108303"),
981
+ themeColor(next.theme, "disabledText", "#8D8D8D"),
982
+ themeColor(next.theme, "positive", "#218358"),
912
983
  themeColor(next.theme, "inverseText", "#FCFCFC"),
913
984
  next.sectionIndexCenteredInWindow,
914
985
  )
915
986
  sectionIndexView.visibility = if (sectionIndexEntries.isEmpty()) GONE else VISIBLE
916
- sectionIndexPreview.setTextColor(themeColor(next.theme, "inverseText", "#FCFCFC"))
917
- sectionIndexPreview.background = GradientDrawable().apply {
918
- setColor(themeColor(next.theme, "inverseBackground", "#202020"))
919
- cornerRadius = dp(14).toFloat()
920
- }
987
+ sectionIndexPreview.setTextColor(Color.WHITE)
988
+ sectionIndexPreview.background = sectionIndexPreviewBackground()
921
989
  sectionIndexView.setActiveIndex(
922
990
  previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } },
923
991
  )
@@ -938,6 +1006,7 @@ class NativeListView(
938
1006
  if (interacting) {
939
1007
  sectionIndexPreview.animate().cancel()
940
1008
  sectionIndexPreview.text = entry.title
1009
+ positionSectionIndexPreview(index)
941
1010
  sectionIndexPreview.visibility = VISIBLE
942
1011
  sectionIndexPreview.alpha = 1f
943
1012
  if (changed && sectionIndexHapticsEnabled) {
@@ -953,16 +1022,41 @@ class NativeListView(
953
1022
  sectionIndexPreview.animate().cancel()
954
1023
  if (immediately) {
955
1024
  sectionIndexPreview.alpha = 0f
956
- sectionIndexPreview.visibility = GONE
1025
+ sectionIndexPreview.visibility = INVISIBLE
957
1026
  } else {
958
1027
  sectionIndexPreview.animate()
959
1028
  .alpha(0f)
960
1029
  .setDuration(150)
961
- .withEndAction { sectionIndexPreview.visibility = GONE }
1030
+ .withEndAction { sectionIndexPreview.visibility = INVISIBLE }
962
1031
  .start()
963
1032
  }
964
1033
  }
965
1034
 
1035
+ private fun positionSectionIndexPreview(index: Int) {
1036
+ val halfHeight = sectionIndexDp(SECTION_INDEX_PREVIEW_HEIGHT_DP) / 2f
1037
+ val maximumY = (contentContainer.height - halfHeight).coerceAtLeast(halfHeight)
1038
+ val targetY = sectionIndexView.top + sectionIndexView.centerYForIndex(index)
1039
+ val clampedY = targetY.coerceIn(halfHeight, maximumY)
1040
+ sectionIndexPreview.translationY = clampedY - contentContainer.height / 2f
1041
+ }
1042
+
1043
+ private fun sectionIndexPreviewBackground(): ShapeDrawable {
1044
+ val path = Path().apply {
1045
+ moveTo(25f, 0f)
1046
+ cubicTo(11f, 0f, 0f, 11f, 0f, 25f)
1047
+ cubicTo(0f, 39f, 11f, 50f, 25f, 50f)
1048
+ cubicTo(36f, 50f, 43f, 44f, 46f, 37.5f)
1049
+ lineTo(60f, 25f)
1050
+ lineTo(46f, 12.5f)
1051
+ cubicTo(43f, 6f, 36f, 0f, 25f, 0f)
1052
+ close()
1053
+ }
1054
+ return ShapeDrawable(PathShape(path, 60f, 50f)).apply {
1055
+ paint.color = Color.rgb(194, 194, 194)
1056
+ paint.isAntiAlias = true
1057
+ }
1058
+ }
1059
+
966
1060
  private fun syncSectionIndexToVisibleRows() {
967
1061
  if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return
968
1062
  val firstVisible = layoutManager.findFirstVisibleItemPosition()
@@ -1004,6 +1098,7 @@ class NativeListView(
1004
1098
  updateSelection(NativeSelectionTarget("row", item.key), item.key)
1005
1099
  } else {
1006
1100
  val actionKey = when {
1101
+ item.type == "market" && item.json.optString("pressActionKey").isNotEmpty() -> item.json.optString("pressActionKey")
1007
1102
  item.type == "action" -> item.json.optString("actionKey")
1008
1103
  item.type == "system" && item.json.optString("variant") == "retry" -> item.json.optString("actionKey")
1009
1104
  else -> "press"
@@ -1061,7 +1156,12 @@ class NativeListView(
1061
1156
  .put("source", origin.source)
1062
1157
  .put("generation", generation)
1063
1158
  .put("layoutDirection", if (origin.sourceView.layoutDirection == LAYOUT_DIRECTION_RTL) "rtl" else "ltr")
1064
- .also { anchor -> origin.slot?.let { anchor.put("slot", it) } }
1159
+ .also { anchor ->
1160
+ origin.slot?.let { anchor.put("slot", it) }
1161
+ origin.windowPointPixels?.let { point ->
1162
+ anchor.put("windowPoint", JSONObject().put("x", point.x / density).put("y", point.y / density))
1163
+ }
1164
+ }
1065
1165
  }
1066
1166
 
1067
1167
  private fun isOriginValid(origin: NativeListActionOrigin): Boolean =
@@ -1211,6 +1311,7 @@ class NativeListView(
1211
1311
  }
1212
1312
 
1213
1313
  private fun updateReordering(next: NativeListConfig) {
1314
+ stopReorderRelayoutLoop()
1214
1315
  reorderTouchHandler?.removeCallbacksAndMessages(null)
1215
1316
  reorderTouchHandler = null
1216
1317
  reorderTouchListener?.let(recyclerView::removeOnItemTouchListener)
@@ -1225,6 +1326,48 @@ class NativeListView(
1225
1326
  ) {
1226
1327
  private var compactWalletGroupDrag = false
1227
1328
  private var compactWalletGroupTop = Float.NaN
1329
+ private var dragType: String? = null
1330
+ private var dragStartedAtMs = 0L
1331
+ private var lastAutoScrollLogAtMs = 0L
1332
+ private var lastDragFrameLogAtMs = 0L
1333
+ private var lastMoveAttemptLogAtMs = 0L
1334
+ private var lastMoveAttemptSignature = ""
1335
+ private var latestDragDx = 0f
1336
+ private var latestDragDy = 0f
1337
+
1338
+ private fun logMoveAttempt(
1339
+ recyclerView: RecyclerView,
1340
+ from: Int,
1341
+ to: Int,
1342
+ fromItem: NativeListItem?,
1343
+ toItem: NativeListItem?,
1344
+ outcome: String,
1345
+ targetTop: Int,
1346
+ targetBottom: Int,
1347
+ ) {
1348
+ val now = SystemClock.uptimeMillis()
1349
+ val signature = "$from:$to:$outcome"
1350
+ if (
1351
+ signature == lastMoveAttemptSignature &&
1352
+ now - lastMoveAttemptLogAtMs < REORDER_MOVE_ATTEMPT_LOG_INTERVAL_MS
1353
+ ) {
1354
+ return
1355
+ }
1356
+ lastMoveAttemptSignature = signature
1357
+ lastMoveAttemptLogAtMs = now
1358
+ OneKeyLog.info(
1359
+ REORDER_LOG_TAG,
1360
+ "moveAttempt outcome=$outcome from=$from to=$to " +
1361
+ "fromType=${fromItem?.type.orEmpty()} toType=${toItem?.type.orEmpty()} " +
1362
+ "fromReorderable=${fromItem?.isReorderable} toReorderable=${toItem?.isReorderable} " +
1363
+ "sameSection=${fromItem != null && toItem != null && fromItem.sectionKey == toItem.sectionKey} " +
1364
+ "elapsedDragMs=${now - dragStartedAtMs} " +
1365
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1366
+ "targetTop=$targetTop targetBottom=$targetBottom " +
1367
+ "viewportTop=${recyclerView.paddingTop} " +
1368
+ "viewportBottom=${recyclerView.height - recyclerView.paddingBottom}",
1369
+ )
1370
+ }
1228
1371
 
1229
1372
  override fun isLongPressDragEnabled(): Boolean = false
1230
1373
 
@@ -1236,6 +1379,23 @@ class NativeListView(
1236
1379
  reorderPlaceholderDecoration.position = position
1237
1380
  reorderPlaceholderDecoration.color = reorderActiveBackground(next.theme)
1238
1381
  compactWalletGroupDrag = item?.type == "walletGroup" && viewHolder != null
1382
+ dragType = item?.type
1383
+ dragStartedAtMs = SystemClock.uptimeMillis()
1384
+ lastAutoScrollLogAtMs = 0L
1385
+ lastDragFrameLogAtMs = 0L
1386
+ lastMoveAttemptLogAtMs = 0L
1387
+ lastMoveAttemptSignature = ""
1388
+ latestDragDx = 0f
1389
+ latestDragDy = 0f
1390
+ startReorderRelayoutLoop()
1391
+ OneKeyLog.info(
1392
+ REORDER_LOG_TAG,
1393
+ "start type=${dragType.orEmpty()} position=$position " +
1394
+ "compact=$compactWalletGroupDrag itemHeight=${viewHolder?.itemView?.height ?: -1} " +
1395
+ "viewport=${recyclerView.width}x${recyclerView.height} " +
1396
+ "padding=${recyclerView.paddingLeft},${recyclerView.paddingTop}," +
1397
+ "${recyclerView.paddingRight},${recyclerView.paddingBottom} density=$density",
1398
+ )
1239
1399
  recyclerView.invalidate()
1240
1400
  (viewHolder as? NativeListViewHolder)?.rowView?.setReorderActive(true)
1241
1401
  if (compactWalletGroupDrag && viewHolder != null) {
@@ -1263,22 +1423,63 @@ class NativeListView(
1263
1423
  val from = source.bindingAdapterPosition
1264
1424
  val to = target.bindingAdapterPosition
1265
1425
  val base = pendingReorder ?: adapter.currentList
1266
- val fromItem = base.getOrNull(from) ?: return false
1267
- val toItem = base.getOrNull(to) ?: return false
1268
- if (!fromItem.isReorderable || !toItem.isReorderable || fromItem.sectionKey != toItem.sectionKey) return false
1426
+ val fromItem = base.getOrNull(from)
1427
+ val toItem = base.getOrNull(to)
1428
+ val targetTop = layoutManager.getDecoratedTop(target.itemView)
1429
+ val targetBottom = layoutManager.getDecoratedBottom(target.itemView)
1430
+ val targetClipped =
1431
+ next.orientation != "horizontal" &&
1432
+ from != RecyclerView.NO_POSITION &&
1433
+ to != RecyclerView.NO_POSITION &&
1434
+ when {
1435
+ to > from -> targetBottom > recyclerView.height - recyclerView.paddingBottom
1436
+ to < from -> targetTop < recyclerView.paddingTop
1437
+ else -> false
1438
+ }
1439
+ val outcome = when {
1440
+ fromItem == null -> "missingSource"
1441
+ toItem == null -> "missingTarget"
1442
+ !fromItem.isReorderable -> "sourceNotReorderable"
1443
+ !toItem.isReorderable -> "targetNotReorderable"
1444
+ fromItem.sectionKey != toItem.sectionKey -> "sectionMismatch"
1445
+ targetClipped -> "targetClipped"
1446
+ else -> "accepted"
1447
+ }
1448
+ logMoveAttempt(recyclerView, from, to, fromItem, toItem, outcome, targetTop, targetBottom)
1449
+ if (outcome != "accepted" || fromItem == null || toItem == null) return false
1269
1450
  val crossedPosition = dragTo != to
1270
1451
  if (dragFrom == RecyclerView.NO_POSITION) dragFrom = from
1271
1452
  dragTo = to
1272
1453
  if (crossedPosition) {
1273
1454
  recyclerView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
1455
+ OneKeyLog.info(
1456
+ REORDER_LOG_TAG,
1457
+ "move type=${dragType.orEmpty()} from=$from to=$to " +
1458
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1459
+ "dragDx=$latestDragDx dragDy=$latestDragDy",
1460
+ )
1274
1461
  }
1275
1462
  val displacedView = target.itemView
1276
1463
  prepareDisplacedReorderAnimation(displacedView)
1277
- val reordered = adapter.moveReordered(from, to) ?: return false
1464
+ val reordered = adapter.moveReordered(from, to)
1465
+ if (reordered == null) {
1466
+ logMoveAttempt(
1467
+ recyclerView,
1468
+ from,
1469
+ to,
1470
+ fromItem,
1471
+ toItem,
1472
+ "adapterRejected",
1473
+ targetTop,
1474
+ targetBottom,
1475
+ )
1476
+ return false
1477
+ }
1278
1478
  pendingReorder = reordered
1279
1479
  reorderPlaceholderDecoration.position = to
1280
1480
  recyclerView.invalidate()
1281
- recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1481
+ // OneKey patch: the drag-scoped loop handles deferred nested RecyclerView layouts.
1482
+ // recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1282
1483
  return true
1283
1484
  }
1284
1485
 
@@ -1294,11 +1495,40 @@ class NativeListView(
1294
1495
  isCurrentlyActive: Boolean,
1295
1496
  ) {
1296
1497
  if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
1498
+ latestDragDx = dX
1499
+ latestDragDy = dY
1297
1500
  if (compactWalletGroupDrag) {
1298
1501
  compactWalletGroupTop = viewHolder.itemView.top + dY
1299
1502
  }
1300
1503
  }
1301
1504
  super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
1505
+ if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
1506
+ val now = SystemClock.uptimeMillis()
1507
+ if (
1508
+ lastDragFrameLogAtMs == 0L ||
1509
+ now - lastDragFrameLogAtMs >= REORDER_DRAG_FRAME_LOG_INTERVAL_MS
1510
+ ) {
1511
+ lastDragFrameLogAtMs = now
1512
+ val itemView = viewHolder.itemView
1513
+ val visualTop = itemView.top + dY
1514
+ val visualBottom = itemView.bottom + dY
1515
+ OneKeyLog.info(
1516
+ REORDER_LOG_TAG,
1517
+ "frame position=${viewHolder.bindingAdapterPosition} active=$isCurrentlyActive " +
1518
+ "dragDx=$dX dragDy=$dY itemTop=${itemView.top} itemBottom=${itemView.bottom} " +
1519
+ "translationY=${itemView.translationY} visualTop=$visualTop visualBottom=$visualBottom " +
1520
+ "viewportTop=${recyclerView.paddingTop} " +
1521
+ "viewportBottom=${recyclerView.height - recyclerView.paddingBottom} " +
1522
+ "firstVisible=${layoutManager.findFirstVisibleItemPosition()} " +
1523
+ "lastVisible=${layoutManager.findLastVisibleItemPosition()} " +
1524
+ "canScrollUp=${recyclerView.canScrollVertically(-1)} " +
1525
+ "canScrollDown=${recyclerView.canScrollVertically(1)} " +
1526
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1527
+ "layoutRequested=${recyclerView.isLayoutRequested} " +
1528
+ "computingLayout=${recyclerView.isComputingLayout}",
1529
+ )
1530
+ }
1531
+ }
1302
1532
  }
1303
1533
 
1304
1534
  override fun interpolateOutOfBoundsScroll(
@@ -1308,35 +1538,60 @@ class NativeListView(
1308
1538
  totalSize: Int,
1309
1539
  msSinceStartScroll: Long,
1310
1540
  ): Int {
1311
- if (!compactWalletGroupDrag) {
1312
- return super.interpolateOutOfBoundsScroll(
1313
- recyclerView,
1314
- viewSize,
1315
- viewSizeOutOfBounds,
1316
- totalSize,
1317
- msSinceStartScroll,
1318
- )
1319
- }
1320
- if (compactWalletGroupTop.isNaN()) return 0
1321
- val compactHeight = dp(68)
1322
- val compactTop = compactWalletGroupTop.roundToInt()
1323
- val compactOutOfBounds = when {
1324
- compactTop < recyclerView.paddingTop -> compactTop - recyclerView.paddingTop
1325
- compactTop + compactHeight > recyclerView.height - recyclerView.paddingBottom ->
1326
- compactTop + compactHeight - (recyclerView.height - recyclerView.paddingBottom)
1327
- else -> 0
1541
+ val effectiveViewSize: Int
1542
+ val effectiveOutOfBounds: Int
1543
+ if (compactWalletGroupDrag) {
1544
+ if (compactWalletGroupTop.isNaN()) return 0
1545
+ effectiveViewSize = dp(68)
1546
+ val compactTop = compactWalletGroupTop.roundToInt()
1547
+ effectiveOutOfBounds = when {
1548
+ compactTop < recyclerView.paddingTop -> compactTop - recyclerView.paddingTop
1549
+ compactTop + effectiveViewSize > recyclerView.height - recyclerView.paddingBottom ->
1550
+ compactTop + effectiveViewSize - (recyclerView.height - recyclerView.paddingBottom)
1551
+ else -> 0
1552
+ }
1553
+ if (effectiveOutOfBounds == 0) return 0
1554
+ } else {
1555
+ effectiveViewSize = viewSize
1556
+ effectiveOutOfBounds = viewSizeOutOfBounds
1328
1557
  }
1329
- if (compactOutOfBounds == 0) return 0
1330
- return super.interpolateOutOfBoundsScroll(
1558
+ // OneKey patch: warm-start AndroidX's two-second quintic edge-scroll ramp.
1559
+ val acceleratedElapsedOutMs =
1560
+ msSinceStartScroll + REORDER_AUTOSCROLL_ACCELERATION_OFFSET_MS
1561
+ val result = super.interpolateOutOfBoundsScroll(
1331
1562
  recyclerView,
1332
- compactHeight,
1333
- compactOutOfBounds,
1563
+ effectiveViewSize,
1564
+ effectiveOutOfBounds,
1334
1565
  totalSize,
1335
- msSinceStartScroll,
1566
+ // msSinceStartScroll,
1567
+ acceleratedElapsedOutMs,
1336
1568
  )
1569
+ val now = SystemClock.uptimeMillis()
1570
+ if (
1571
+ lastAutoScrollLogAtMs == 0L ||
1572
+ now - lastAutoScrollLogAtMs >= REORDER_AUTOSCROLL_LOG_INTERVAL_MS
1573
+ ) {
1574
+ lastAutoScrollLogAtMs = now
1575
+ OneKeyLog.info(
1576
+ REORDER_LOG_TAG,
1577
+ "autoScroll type=${dragType.orEmpty()} " +
1578
+ "compact=$compactWalletGroupDrag rawViewSize=$viewSize " +
1579
+ "effectiveViewSize=$effectiveViewSize rawOut=$viewSizeOutOfBounds " +
1580
+ "effectiveOut=$effectiveOutOfBounds totalSize=$totalSize " +
1581
+ "elapsedOutMs=$msSinceStartScroll " +
1582
+ "acceleratedElapsedOutMs=$acceleratedElapsedOutMs " +
1583
+ "elapsedDragMs=${now - dragStartedAtMs} " +
1584
+ "resultPx=$result scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1585
+ "canScrollUp=${recyclerView.canScrollVertically(-1)} " +
1586
+ "canScrollDown=${recyclerView.canScrollVertically(1)} " +
1587
+ "dragDx=$latestDragDx dragDy=$latestDragDy compactTop=$compactWalletGroupTop",
1588
+ )
1589
+ }
1590
+ return result
1337
1591
  }
1338
1592
 
1339
1593
  override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
1594
+ stopReorderRelayoutLoop()
1340
1595
  val rowView = (viewHolder as? NativeListViewHolder)?.rowView
1341
1596
  val draggedGroupKey = (rowView?.tag as? NativeListItem)?.key
1342
1597
  super.clearView(recyclerView, viewHolder)
@@ -1349,6 +1604,18 @@ class NativeListView(
1349
1604
  val from = dragFrom
1350
1605
  val to = dragTo
1351
1606
  val reordered = pendingReorder
1607
+ OneKeyLog.info(
1608
+ REORDER_LOG_TAG,
1609
+ "end type=${dragType.orEmpty()} from=$from to=$to " +
1610
+ "compact=$wasCompactWalletGroupDrag elapsedDragMs=" +
1611
+ "${SystemClock.uptimeMillis() - dragStartedAtMs} " +
1612
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1613
+ "dragDx=$latestDragDx dragDy=$latestDragDy " +
1614
+ "firstVisible=${layoutManager.findFirstVisibleItemPosition()} " +
1615
+ "lastVisible=${layoutManager.findLastVisibleItemPosition()} " +
1616
+ "canScrollUp=${recyclerView.canScrollVertically(-1)} " +
1617
+ "canScrollDown=${recyclerView.canScrollVertically(1)}",
1618
+ )
1352
1619
  var reorderPayload: JSONObject? = null
1353
1620
  val destinationPosition = if (to != RecyclerView.NO_POSITION) {
1354
1621
  to
@@ -1404,6 +1671,7 @@ class NativeListView(
1404
1671
  dragFrom = RecyclerView.NO_POSITION
1405
1672
  dragTo = RecyclerView.NO_POSITION
1406
1673
  pendingReorder = null
1674
+ dragType = null
1407
1675
  // The gesture is committed now; a later snapshot may supersede its async diff.
1408
1676
  reorderPayload?.let { emit(REORDER, it) }
1409
1677
  }
@@ -1600,6 +1868,53 @@ class NativeListView(
1600
1868
  }
1601
1869
  }
1602
1870
 
1871
+ private fun startReorderRelayoutLoop() {
1872
+ reorderRelayoutActive = true
1873
+ lastReorderRelayoutLogAtMs = 0L
1874
+ scheduleReorderRelayout()
1875
+ }
1876
+
1877
+ private fun stopReorderRelayoutLoop() {
1878
+ reorderRelayoutActive = false
1879
+ }
1880
+
1881
+ private fun scheduleReorderRelayout() {
1882
+ if (!reorderRelayoutActive || reorderRelayoutScheduled) return
1883
+ reorderRelayoutScheduled = true
1884
+ recyclerView.postOnAnimation {
1885
+ reorderRelayoutScheduled = false
1886
+ if (
1887
+ !reorderRelayoutActive ||
1888
+ disposed ||
1889
+ recyclerView.width <= 0 ||
1890
+ recyclerView.height <= 0
1891
+ ) {
1892
+ return@postOnAnimation
1893
+ }
1894
+ val requestedBefore = recyclerView.isLayoutRequested
1895
+ val computingBefore = recyclerView.isComputingLayout
1896
+ if (requestedBefore && !computingBefore) {
1897
+ relayoutRecyclerViewImmediately()
1898
+ }
1899
+ val requestedAfter = recyclerView.isLayoutRequested
1900
+ val now = SystemClock.uptimeMillis()
1901
+ if (
1902
+ (requestedBefore || computingBefore || requestedAfter) &&
1903
+ (lastReorderRelayoutLogAtMs == 0L ||
1904
+ now - lastReorderRelayoutLogAtMs >= REORDER_RELAYOUT_LOG_INTERVAL_MS)
1905
+ ) {
1906
+ lastReorderRelayoutLogAtMs = now
1907
+ OneKeyLog.info(
1908
+ REORDER_LOG_TAG,
1909
+ "relayout requestedBefore=$requestedBefore computingBefore=$computingBefore " +
1910
+ "requestedAfter=$requestedAfter computingAfter=${recyclerView.isComputingLayout} " +
1911
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()}",
1912
+ )
1913
+ }
1914
+ scheduleReorderRelayout()
1915
+ }
1916
+ }
1917
+
1603
1918
  private fun relayoutRecyclerViewImmediately() {
1604
1919
  if (disposed || recyclerView.isComputingLayout || recyclerView.width <= 0 || recyclerView.height <= 0) {
1605
1920
  return
@@ -1619,10 +1934,20 @@ class NativeListView(
1619
1934
 
1620
1935
  private fun dp(value: Int): Int = if (usesSelectorSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value)
1621
1936
 
1937
+ private fun sectionIndexDp(value: Int): Int = (value * density).roundToInt()
1938
+
1622
1939
  companion object {
1940
+ private val MARKET_QUOTE_FIELDS = setOf(
1941
+ "revision",
1942
+ "price",
1943
+ "priceSegments",
1944
+ "change",
1945
+ "accessibilityLabel",
1946
+ )
1623
1947
  private const val SECTION_INDEX_CONTENT_INSET_DP = 16
1624
1948
  private const val SECTION_INDEX_RAIL_WIDTH_DP = 32
1625
- private const val SECTION_INDEX_PREVIEW_SIZE_DP = 48
1949
+ private const val SECTION_INDEX_PREVIEW_WIDTH_DP = 60
1950
+ private const val SECTION_INDEX_PREVIEW_HEIGHT_DP = 50
1626
1951
  private const val SECTION_INDEX_PREVIEW_END_MARGIN_DP = 40
1627
1952
  private const val REORDER_LONG_PRESS_MS = 200L
1628
1953
  private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10
@@ -1632,6 +1957,12 @@ class NativeListView(
1632
1957
  private const val REORDER_SPRING_STIFFNESS = 400.0
1633
1958
  private const val REORDER_SPRING_MASS = 0.4
1634
1959
  private const val REORDER_SPRING_DURATION_MS = 300L
1960
+ private const val REORDER_AUTOSCROLL_ACCELERATION_OFFSET_MS = 1_500L
1961
+ private const val REORDER_AUTOSCROLL_LOG_INTERVAL_MS = 100L
1962
+ private const val REORDER_DRAG_FRAME_LOG_INTERVAL_MS = 100L
1963
+ private const val REORDER_MOVE_ATTEMPT_LOG_INTERVAL_MS = 250L
1964
+ private const val REORDER_RELAYOUT_LOG_INTERVAL_MS = 100L
1965
+ private const val REORDER_LOG_TAG = "NativeListReorder"
1635
1966
  private const val ROW_ACTION = "rowAction"
1636
1967
  private const val ACTION_ANCHOR_INVALIDATED = "actionAnchorInvalidated"
1637
1968
  private const val SELECTION_DELTA = "selectionDelta"
@@ -1715,11 +2046,11 @@ private class NativeListSectionIndexView(
1715
2046
  private val activeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
1716
2047
  private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1717
2048
  textAlign = Paint.Align.CENTER
1718
- typeface = NativeListFonts.medium(context)
2049
+ typeface = NativeListFonts.regular(context)
1719
2050
  }
1720
2051
  private val activePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1721
2052
  textAlign = Paint.Align.CENTER
1722
- typeface = NativeListFonts.semibold(context)
2053
+ typeface = NativeListFonts.medium(context)
1723
2054
  }
1724
2055
 
1725
2056
  init {
@@ -1756,40 +2087,27 @@ private class NativeListSectionIndexView(
1756
2087
  override fun onDraw(canvas: Canvas) {
1757
2088
  super.onDraw(canvas)
1758
2089
  if (titles.isEmpty()) return
1759
- val cellHeight = cellHeight()
1760
- val originY = indexOriginY(cellHeight, titles.size)
1761
- val textSize = NativeListScale.font(resources, 10f) * resources.displayMetrics.scaledDensity
2090
+ val metrics = indexMetrics()
2091
+ val visibleIndices = visibleLabelIndices(metrics.trackHeight)
2092
+ val textSize = 10f * resources.displayMetrics.scaledDensity
2093
+ val centerX = width - dp(10f)
2094
+ val badgeRadius = dp(7f)
1762
2095
  normalPaint.color = normalColor
1763
2096
  normalPaint.textSize = textSize
1764
2097
  activePaint.color = activeColor
1765
2098
  activePaint.textSize = textSize
1766
- titles.forEachIndexed { index, title ->
2099
+ visibleIndices.forEachIndexed { visibleIndex, index ->
2100
+ val title = titles[index]
1767
2101
  val active = index == activeIndex
1768
2102
  val paint = if (active) activePaint else normalPaint
1769
- val centerY = originY + cellHeight * (index + 0.5f)
1770
- if (active && cellHeight >= NativeListScale.dp(resources, 12f)) {
1771
- val badgeWidth = NativeListScale.dp(resources, 20f)
1772
- val badgeHeight = minOf(cellHeight, NativeListScale.dp(resources, 16f))
2103
+ val centerY = visibleLabelCenterY(visibleIndex, visibleIndices.size, metrics)
2104
+ if (active) {
1773
2105
  activeBackgroundPaint.color = activeColor
1774
- canvas.drawRoundRect(
1775
- width / 2f - badgeWidth / 2f,
1776
- centerY - badgeHeight / 2f,
1777
- width / 2f + badgeWidth / 2f,
1778
- centerY + badgeHeight / 2f,
1779
- badgeHeight / 2f,
1780
- badgeHeight / 2f,
1781
- activeBackgroundPaint,
1782
- )
1783
- }
1784
- paint.color = if (active && cellHeight >= NativeListScale.dp(resources, 12f)) {
1785
- activeTextColor
1786
- } else if (active) {
1787
- activeColor
1788
- } else {
1789
- normalColor
2106
+ canvas.drawCircle(centerX, centerY, badgeRadius, activeBackgroundPaint)
1790
2107
  }
2108
+ paint.color = if (active) activeTextColor else normalColor
1791
2109
  val baseline = centerY - (paint.descent() + paint.ascent()) / 2f
1792
- canvas.drawText(title, width / 2f, baseline, paint)
2110
+ canvas.drawText(title, centerX, baseline, paint)
1793
2111
  }
1794
2112
  }
1795
2113
 
@@ -1864,9 +2182,14 @@ private class NativeListSectionIndexView(
1864
2182
  }
1865
2183
 
1866
2184
  private fun selectAt(y: Float, interacting: Boolean) {
1867
- val cellHeight = cellHeight()
1868
- val originY = indexOriginY(cellHeight, titles.size)
1869
- val index = ((y - originY) / cellHeight).toInt().coerceIn(0, titles.lastIndex)
2185
+ val metrics = indexMetrics()
2186
+ if (metrics.trackHeight <= 0f) return
2187
+ val visibleIndices = visibleLabelIndices(metrics.trackHeight).sorted()
2188
+ val visibleTrackHeight = minOf(metrics.trackHeight, dp(16f) * visibleIndices.size)
2189
+ val visibleOriginY = metrics.originY + (metrics.trackHeight - visibleTrackHeight) / 2f
2190
+ val progress = ((y - visibleOriginY) / visibleTrackHeight).coerceIn(0f, 1f)
2191
+ val slot = (progress * visibleIndices.size).toInt().coerceIn(0, visibleIndices.lastIndex)
2192
+ val index = visibleIndices[slot]
1870
2193
  if (interacting && lastTouchIndex == index) return
1871
2194
  lastTouchIndex = index.takeIf { interacting }
1872
2195
  select(index, interacting)
@@ -1877,29 +2200,60 @@ private class NativeListSectionIndexView(
1877
2200
  setActiveIndex(index)
1878
2201
  }
1879
2202
 
1880
- private fun cellHeight(): Float =
1881
- (height.toFloat() / titles.size.coerceAtLeast(1))
1882
- .coerceAtMost(NativeListScale.dp(resources, 16f))
1883
- .coerceAtLeast(1f)
2203
+ fun centerYForIndex(index: Int): Float = entryCenterY(index, indexMetrics())
2204
+
2205
+ private data class Metrics(val originY: Float, val trackHeight: Float)
1884
2206
 
1885
- private fun indexOriginY(cellHeight: Float, count: Int): Float {
1886
- val totalHeight = cellHeight * count
1887
- val centeredOriginY = (height - totalHeight) / 2f
1888
- if (!centeredInWindow || !isAttachedToWindow) return centeredOriginY
2207
+ private fun indexMetrics(): Metrics {
2208
+ if (titles.isEmpty()) return Metrics(height / 2f, 0f)
2209
+ val edgePadding = dp(8f)
2210
+ val labelSpacing = dp(16f)
2211
+ val availableHeight = (height - edgePadding * 2f).coerceAtLeast(0f)
2212
+ val trackHeight = minOf(availableHeight, labelSpacing * titles.size)
2213
+ val centeredOriginY = (height - trackHeight) / 2f
2214
+ if (!centeredInWindow || !isAttachedToWindow) {
2215
+ return Metrics(centeredOriginY, trackHeight)
2216
+ }
1889
2217
  val systemBarInsets = ViewCompat.getRootWindowInsets(rootView)
1890
2218
  ?.getInsetsIgnoringVisibility(
1891
2219
  WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
1892
- ) ?: return centeredOriginY
2220
+ ) ?: return Metrics(centeredOriginY, trackHeight)
1893
2221
  rootView.getLocationOnScreen(rootLocationOnScreen)
1894
2222
  getLocationOnScreen(locationOnScreen)
1895
2223
  val safeTop = rootLocationOnScreen[1] + systemBarInsets.top
1896
2224
  val safeBottom = rootLocationOnScreen[1] + rootView.height - systemBarInsets.bottom
1897
- if (safeBottom <= safeTop) return centeredOriginY
2225
+ if (safeBottom <= safeTop) return Metrics(centeredOriginY, trackHeight)
1898
2226
  val localCenterY = (safeTop + safeBottom) / 2f - locationOnScreen[1]
1899
- return (localCenterY - totalHeight / 2f)
1900
- .coerceIn(0f, (height - totalHeight).coerceAtLeast(0f))
2227
+ val maximumOrigin = (height - edgePadding - trackHeight).coerceAtLeast(edgePadding)
2228
+ return Metrics(
2229
+ (localCenterY - trackHeight / 2f).coerceIn(edgePadding, maximumOrigin),
2230
+ trackHeight,
2231
+ )
2232
+ }
2233
+
2234
+ private fun entryCenterY(index: Int, metrics: Metrics): Float {
2235
+ if (titles.isEmpty()) return height / 2f
2236
+ return metrics.originY + metrics.trackHeight * (index + 0.5f) / titles.size
2237
+ }
2238
+
2239
+ private fun visibleLabelCenterY(visibleIndex: Int, visibleCount: Int, metrics: Metrics): Float {
2240
+ val visibleTrackHeight = minOf(metrics.trackHeight, dp(16f) * visibleCount)
2241
+ val visibleOriginY = metrics.originY + (metrics.trackHeight - visibleTrackHeight) / 2f
2242
+ return visibleOriginY + visibleTrackHeight * (visibleIndex + 0.5f) / visibleCount.coerceAtLeast(1)
1901
2243
  }
1902
2244
 
2245
+ private fun visibleLabelIndices(trackHeight: Float): Set<Int> {
2246
+ if (titles.isEmpty()) return emptySet()
2247
+ val maxVisible = max(1, (trackHeight / dp(16f)).toInt())
2248
+ if (titles.size <= maxVisible) return titles.indices.toSet()
2249
+ if (maxVisible == 1) return setOf(0)
2250
+ return (0 until maxVisible).mapTo(mutableSetOf()) { slot ->
2251
+ (slot.toFloat() * titles.lastIndex / (maxVisible - 1)).roundToInt()
2252
+ }
2253
+ }
2254
+
2255
+ private fun dp(value: Float): Float = value * resources.displayMetrics.density
2256
+
1903
2257
  private fun updateContentDescription() {
1904
2258
  contentDescription = activeIndex?.let { titles.getOrNull(it) }
1905
2259
  ?.let { "$ACCESSIBILITY_LABEL, $it" }