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

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,6 +91,8 @@ 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)
91
97
  private val contentContainer = FrameLayout(context)
92
98
  private val adapter = NativeListAdapter(reactContext)
@@ -148,6 +154,9 @@ class NativeListView(
148
154
  private var dragFrom = RecyclerView.NO_POSITION
149
155
  private var dragTo = RecyclerView.NO_POSITION
150
156
  private var pendingReorder: List<NativeListItem>? = null
157
+ private var reorderRelayoutActive = false
158
+ private var reorderRelayoutScheduled = false
159
+ private var lastReorderRelayoutLogAtMs = 0L
151
160
  private var sectionIndexEntries: List<NativeListSectionIndexEntry> = emptyList()
152
161
  private var sectionIndexScrubbing = false
153
162
  private var sectionIndexProgrammaticScroll = false
@@ -196,27 +205,28 @@ class NativeListView(
196
205
  contentContainer.addView(
197
206
  sectionIndexView,
198
207
  FrameLayout.LayoutParams(
199
- dp(SECTION_INDEX_RAIL_WIDTH_DP),
208
+ sectionIndexDp(SECTION_INDEX_RAIL_WIDTH_DP),
200
209
  FrameLayout.LayoutParams.MATCH_PARENT,
201
210
  Gravity.END,
202
211
  ),
203
212
  )
204
213
  sectionIndexPreview.apply {
205
214
  gravity = Gravity.CENTER
206
- textSize = NativeListScale.font(resources, 22f)
207
- typeface = NativeListFonts.semibold(context)
208
- visibility = GONE
215
+ textSize = 30f
216
+ typeface = NativeListFonts.regular(context)
217
+ setPadding(0, 0, sectionIndexDp(10), 0)
218
+ visibility = INVISIBLE
209
219
  alpha = 0f
210
220
  importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
211
221
  }
212
222
  contentContainer.addView(
213
223
  sectionIndexPreview,
214
224
  FrameLayout.LayoutParams(
215
- dp(SECTION_INDEX_PREVIEW_SIZE_DP),
216
- dp(SECTION_INDEX_PREVIEW_SIZE_DP),
225
+ sectionIndexDp(SECTION_INDEX_PREVIEW_WIDTH_DP),
226
+ sectionIndexDp(SECTION_INDEX_PREVIEW_HEIGHT_DP),
217
227
  Gravity.CENTER_VERTICAL or Gravity.END,
218
228
  ).apply {
219
- marginEnd = dp(SECTION_INDEX_PREVIEW_END_MARGIN_DP)
229
+ marginEnd = sectionIndexDp(SECTION_INDEX_PREVIEW_END_MARGIN_DP)
220
230
  },
221
231
  )
222
232
  addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
@@ -318,6 +328,18 @@ class NativeListView(
318
328
  }
319
329
  return
320
330
  }
331
+ // Keep the first Market row at its current pixel offset when it is
332
+ // reordered. RecyclerView otherwise follows the previous first key.
333
+ val marketStartOffset = if (
334
+ previous?.items?.firstOrNull()?.type == "market" &&
335
+ next.items.firstOrNull()?.type == "market" &&
336
+ previous.items.firstOrNull()?.key != next.items.firstOrNull()?.key &&
337
+ layoutManager.findFirstVisibleItemPosition() == 0
338
+ ) {
339
+ layoutManager.findViewByPosition(0)?.let {
340
+ layoutManager.getDecoratedTop(it) - recyclerView.paddingTop
341
+ }
342
+ } else null
321
343
  config = next
322
344
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
323
345
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
@@ -330,6 +352,9 @@ class NativeListView(
330
352
  configureSectionIndex(next)
331
353
  updateLayout(next)
332
354
  adapter.submitList(next.items) {
355
+ if (marketStartOffset != null) {
356
+ layoutManager.scrollToPositionWithOffset(0, marketStartOffset)
357
+ }
333
358
  relayoutContents()
334
359
  performPendingScrollIfNeeded()
335
360
  syncSectionIndexToVisibleRows()
@@ -500,9 +525,13 @@ class NativeListView(
500
525
  }
501
526
  val nextItems = current.items.toMutableList()
502
527
  val selected = LinkedHashSet(current.selectedKeys)
528
+ var marketQuoteOnly = pending.isNotEmpty()
503
529
  try {
504
530
  pending.forEach { (index, changes) ->
505
531
  val previous = nextItems[index]
532
+ marketQuoteOnly = marketQuoteOnly &&
533
+ previous.type == "market" &&
534
+ changes.keys().asSequence().all(MARKET_QUOTE_FIELDS::contains)
506
535
  val merged = NativeListItem.parse(mergeRow(previous.json, changes))
507
536
  nextItems[index] = merged
508
537
  if (changes.has("selected")) {
@@ -512,7 +541,7 @@ class NativeListView(
512
541
  } catch (_: Exception) {
513
542
  return
514
543
  }
515
- invalidateActionAnchor("snapshot")
544
+ if (!marketQuoteOnly) invalidateActionAnchor("snapshot")
516
545
  val next = current.copy(items = nextItems, selectedKeys = selected)
517
546
  config = next
518
547
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
@@ -843,6 +872,7 @@ class NativeListView(
843
872
 
844
873
  fun dispose() {
845
874
  if (disposed) return
875
+ stopReorderRelayoutLoop()
846
876
  invalidateActionAnchor("destroy")
847
877
  actionAnchor = null
848
878
  disposed = true
@@ -872,11 +902,17 @@ class NativeListView(
872
902
  val bottomPadding = next.contentPaddingBottom ?: defaultPadding
873
903
  // OneKey patch: the section index overlays rows and keeps only an accessory-safe inset.
874
904
  val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else SECTION_INDEX_CONTENT_INSET_DP
905
+ // A native scroll coordinator may add header insets to this RecyclerView.
906
+ // Content/theme snapshots must replace only the padding owned by NativeList.
907
+ val coordinatorTopPadding = (recyclerView.paddingTop - configuredTopPaddingPx).coerceAtLeast(0)
908
+ val coordinatorBottomPadding = (recyclerView.paddingBottom - configuredBottomPaddingPx).coerceAtLeast(0)
909
+ configuredTopPaddingPx = dp(topPadding)
910
+ configuredBottomPaddingPx = dp(bottomPadding)
875
911
  recyclerView.setPaddingRelative(
876
912
  dp(horizontalPadding),
877
- dp(topPadding),
913
+ configuredTopPaddingPx + coordinatorTopPadding,
878
914
  dp(horizontalPadding + indexGutter),
879
- dp(bottomPadding),
915
+ configuredBottomPaddingPx + coordinatorBottomPadding,
880
916
  )
881
917
  recyclerView.clipToPadding = false
882
918
  recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty()
@@ -907,17 +943,14 @@ class NativeListView(
907
943
  sectionIndexHapticsEnabled = next.sectionIndexHapticsEnabled
908
944
  sectionIndexView.configure(
909
945
  sectionIndexEntries.map { it.title },
910
- themeColor(next.theme, "secondaryText", "#646464"),
911
- themeColor(next.theme, "accent", "#108303"),
946
+ themeColor(next.theme, "disabledText", "#8D8D8D"),
947
+ themeColor(next.theme, "positive", "#218358"),
912
948
  themeColor(next.theme, "inverseText", "#FCFCFC"),
913
949
  next.sectionIndexCenteredInWindow,
914
950
  )
915
951
  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
- }
952
+ sectionIndexPreview.setTextColor(Color.WHITE)
953
+ sectionIndexPreview.background = sectionIndexPreviewBackground()
921
954
  sectionIndexView.setActiveIndex(
922
955
  previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } },
923
956
  )
@@ -938,6 +971,7 @@ class NativeListView(
938
971
  if (interacting) {
939
972
  sectionIndexPreview.animate().cancel()
940
973
  sectionIndexPreview.text = entry.title
974
+ positionSectionIndexPreview(index)
941
975
  sectionIndexPreview.visibility = VISIBLE
942
976
  sectionIndexPreview.alpha = 1f
943
977
  if (changed && sectionIndexHapticsEnabled) {
@@ -953,16 +987,41 @@ class NativeListView(
953
987
  sectionIndexPreview.animate().cancel()
954
988
  if (immediately) {
955
989
  sectionIndexPreview.alpha = 0f
956
- sectionIndexPreview.visibility = GONE
990
+ sectionIndexPreview.visibility = INVISIBLE
957
991
  } else {
958
992
  sectionIndexPreview.animate()
959
993
  .alpha(0f)
960
994
  .setDuration(150)
961
- .withEndAction { sectionIndexPreview.visibility = GONE }
995
+ .withEndAction { sectionIndexPreview.visibility = INVISIBLE }
962
996
  .start()
963
997
  }
964
998
  }
965
999
 
1000
+ private fun positionSectionIndexPreview(index: Int) {
1001
+ val halfHeight = sectionIndexDp(SECTION_INDEX_PREVIEW_HEIGHT_DP) / 2f
1002
+ val maximumY = (contentContainer.height - halfHeight).coerceAtLeast(halfHeight)
1003
+ val targetY = sectionIndexView.top + sectionIndexView.centerYForIndex(index)
1004
+ val clampedY = targetY.coerceIn(halfHeight, maximumY)
1005
+ sectionIndexPreview.translationY = clampedY - contentContainer.height / 2f
1006
+ }
1007
+
1008
+ private fun sectionIndexPreviewBackground(): ShapeDrawable {
1009
+ val path = Path().apply {
1010
+ moveTo(25f, 0f)
1011
+ cubicTo(11f, 0f, 0f, 11f, 0f, 25f)
1012
+ cubicTo(0f, 39f, 11f, 50f, 25f, 50f)
1013
+ cubicTo(36f, 50f, 43f, 44f, 46f, 37.5f)
1014
+ lineTo(60f, 25f)
1015
+ lineTo(46f, 12.5f)
1016
+ cubicTo(43f, 6f, 36f, 0f, 25f, 0f)
1017
+ close()
1018
+ }
1019
+ return ShapeDrawable(PathShape(path, 60f, 50f)).apply {
1020
+ paint.color = Color.rgb(194, 194, 194)
1021
+ paint.isAntiAlias = true
1022
+ }
1023
+ }
1024
+
966
1025
  private fun syncSectionIndexToVisibleRows() {
967
1026
  if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return
968
1027
  val firstVisible = layoutManager.findFirstVisibleItemPosition()
@@ -1004,6 +1063,7 @@ class NativeListView(
1004
1063
  updateSelection(NativeSelectionTarget("row", item.key), item.key)
1005
1064
  } else {
1006
1065
  val actionKey = when {
1066
+ item.type == "market" && item.json.optString("pressActionKey").isNotEmpty() -> item.json.optString("pressActionKey")
1007
1067
  item.type == "action" -> item.json.optString("actionKey")
1008
1068
  item.type == "system" && item.json.optString("variant") == "retry" -> item.json.optString("actionKey")
1009
1069
  else -> "press"
@@ -1211,6 +1271,7 @@ class NativeListView(
1211
1271
  }
1212
1272
 
1213
1273
  private fun updateReordering(next: NativeListConfig) {
1274
+ stopReorderRelayoutLoop()
1214
1275
  reorderTouchHandler?.removeCallbacksAndMessages(null)
1215
1276
  reorderTouchHandler = null
1216
1277
  reorderTouchListener?.let(recyclerView::removeOnItemTouchListener)
@@ -1225,6 +1286,48 @@ class NativeListView(
1225
1286
  ) {
1226
1287
  private var compactWalletGroupDrag = false
1227
1288
  private var compactWalletGroupTop = Float.NaN
1289
+ private var dragType: String? = null
1290
+ private var dragStartedAtMs = 0L
1291
+ private var lastAutoScrollLogAtMs = 0L
1292
+ private var lastDragFrameLogAtMs = 0L
1293
+ private var lastMoveAttemptLogAtMs = 0L
1294
+ private var lastMoveAttemptSignature = ""
1295
+ private var latestDragDx = 0f
1296
+ private var latestDragDy = 0f
1297
+
1298
+ private fun logMoveAttempt(
1299
+ recyclerView: RecyclerView,
1300
+ from: Int,
1301
+ to: Int,
1302
+ fromItem: NativeListItem?,
1303
+ toItem: NativeListItem?,
1304
+ outcome: String,
1305
+ targetTop: Int,
1306
+ targetBottom: Int,
1307
+ ) {
1308
+ val now = SystemClock.uptimeMillis()
1309
+ val signature = "$from:$to:$outcome"
1310
+ if (
1311
+ signature == lastMoveAttemptSignature &&
1312
+ now - lastMoveAttemptLogAtMs < REORDER_MOVE_ATTEMPT_LOG_INTERVAL_MS
1313
+ ) {
1314
+ return
1315
+ }
1316
+ lastMoveAttemptSignature = signature
1317
+ lastMoveAttemptLogAtMs = now
1318
+ OneKeyLog.info(
1319
+ REORDER_LOG_TAG,
1320
+ "moveAttempt outcome=$outcome from=$from to=$to " +
1321
+ "fromType=${fromItem?.type.orEmpty()} toType=${toItem?.type.orEmpty()} " +
1322
+ "fromReorderable=${fromItem?.isReorderable} toReorderable=${toItem?.isReorderable} " +
1323
+ "sameSection=${fromItem != null && toItem != null && fromItem.sectionKey == toItem.sectionKey} " +
1324
+ "elapsedDragMs=${now - dragStartedAtMs} " +
1325
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1326
+ "targetTop=$targetTop targetBottom=$targetBottom " +
1327
+ "viewportTop=${recyclerView.paddingTop} " +
1328
+ "viewportBottom=${recyclerView.height - recyclerView.paddingBottom}",
1329
+ )
1330
+ }
1228
1331
 
1229
1332
  override fun isLongPressDragEnabled(): Boolean = false
1230
1333
 
@@ -1236,6 +1339,23 @@ class NativeListView(
1236
1339
  reorderPlaceholderDecoration.position = position
1237
1340
  reorderPlaceholderDecoration.color = reorderActiveBackground(next.theme)
1238
1341
  compactWalletGroupDrag = item?.type == "walletGroup" && viewHolder != null
1342
+ dragType = item?.type
1343
+ dragStartedAtMs = SystemClock.uptimeMillis()
1344
+ lastAutoScrollLogAtMs = 0L
1345
+ lastDragFrameLogAtMs = 0L
1346
+ lastMoveAttemptLogAtMs = 0L
1347
+ lastMoveAttemptSignature = ""
1348
+ latestDragDx = 0f
1349
+ latestDragDy = 0f
1350
+ startReorderRelayoutLoop()
1351
+ OneKeyLog.info(
1352
+ REORDER_LOG_TAG,
1353
+ "start type=${dragType.orEmpty()} position=$position " +
1354
+ "compact=$compactWalletGroupDrag itemHeight=${viewHolder?.itemView?.height ?: -1} " +
1355
+ "viewport=${recyclerView.width}x${recyclerView.height} " +
1356
+ "padding=${recyclerView.paddingLeft},${recyclerView.paddingTop}," +
1357
+ "${recyclerView.paddingRight},${recyclerView.paddingBottom} density=$density",
1358
+ )
1239
1359
  recyclerView.invalidate()
1240
1360
  (viewHolder as? NativeListViewHolder)?.rowView?.setReorderActive(true)
1241
1361
  if (compactWalletGroupDrag && viewHolder != null) {
@@ -1263,22 +1383,63 @@ class NativeListView(
1263
1383
  val from = source.bindingAdapterPosition
1264
1384
  val to = target.bindingAdapterPosition
1265
1385
  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
1386
+ val fromItem = base.getOrNull(from)
1387
+ val toItem = base.getOrNull(to)
1388
+ val targetTop = layoutManager.getDecoratedTop(target.itemView)
1389
+ val targetBottom = layoutManager.getDecoratedBottom(target.itemView)
1390
+ val targetClipped =
1391
+ next.orientation != "horizontal" &&
1392
+ from != RecyclerView.NO_POSITION &&
1393
+ to != RecyclerView.NO_POSITION &&
1394
+ when {
1395
+ to > from -> targetBottom > recyclerView.height - recyclerView.paddingBottom
1396
+ to < from -> targetTop < recyclerView.paddingTop
1397
+ else -> false
1398
+ }
1399
+ val outcome = when {
1400
+ fromItem == null -> "missingSource"
1401
+ toItem == null -> "missingTarget"
1402
+ !fromItem.isReorderable -> "sourceNotReorderable"
1403
+ !toItem.isReorderable -> "targetNotReorderable"
1404
+ fromItem.sectionKey != toItem.sectionKey -> "sectionMismatch"
1405
+ targetClipped -> "targetClipped"
1406
+ else -> "accepted"
1407
+ }
1408
+ logMoveAttempt(recyclerView, from, to, fromItem, toItem, outcome, targetTop, targetBottom)
1409
+ if (outcome != "accepted" || fromItem == null || toItem == null) return false
1269
1410
  val crossedPosition = dragTo != to
1270
1411
  if (dragFrom == RecyclerView.NO_POSITION) dragFrom = from
1271
1412
  dragTo = to
1272
1413
  if (crossedPosition) {
1273
1414
  recyclerView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
1415
+ OneKeyLog.info(
1416
+ REORDER_LOG_TAG,
1417
+ "move type=${dragType.orEmpty()} from=$from to=$to " +
1418
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1419
+ "dragDx=$latestDragDx dragDy=$latestDragDy",
1420
+ )
1274
1421
  }
1275
1422
  val displacedView = target.itemView
1276
1423
  prepareDisplacedReorderAnimation(displacedView)
1277
- val reordered = adapter.moveReordered(from, to) ?: return false
1424
+ val reordered = adapter.moveReordered(from, to)
1425
+ if (reordered == null) {
1426
+ logMoveAttempt(
1427
+ recyclerView,
1428
+ from,
1429
+ to,
1430
+ fromItem,
1431
+ toItem,
1432
+ "adapterRejected",
1433
+ targetTop,
1434
+ targetBottom,
1435
+ )
1436
+ return false
1437
+ }
1278
1438
  pendingReorder = reordered
1279
1439
  reorderPlaceholderDecoration.position = to
1280
1440
  recyclerView.invalidate()
1281
- recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1441
+ // OneKey patch: the drag-scoped loop handles deferred nested RecyclerView layouts.
1442
+ // recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1282
1443
  return true
1283
1444
  }
1284
1445
 
@@ -1294,11 +1455,40 @@ class NativeListView(
1294
1455
  isCurrentlyActive: Boolean,
1295
1456
  ) {
1296
1457
  if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
1458
+ latestDragDx = dX
1459
+ latestDragDy = dY
1297
1460
  if (compactWalletGroupDrag) {
1298
1461
  compactWalletGroupTop = viewHolder.itemView.top + dY
1299
1462
  }
1300
1463
  }
1301
1464
  super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
1465
+ if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
1466
+ val now = SystemClock.uptimeMillis()
1467
+ if (
1468
+ lastDragFrameLogAtMs == 0L ||
1469
+ now - lastDragFrameLogAtMs >= REORDER_DRAG_FRAME_LOG_INTERVAL_MS
1470
+ ) {
1471
+ lastDragFrameLogAtMs = now
1472
+ val itemView = viewHolder.itemView
1473
+ val visualTop = itemView.top + dY
1474
+ val visualBottom = itemView.bottom + dY
1475
+ OneKeyLog.info(
1476
+ REORDER_LOG_TAG,
1477
+ "frame position=${viewHolder.bindingAdapterPosition} active=$isCurrentlyActive " +
1478
+ "dragDx=$dX dragDy=$dY itemTop=${itemView.top} itemBottom=${itemView.bottom} " +
1479
+ "translationY=${itemView.translationY} visualTop=$visualTop visualBottom=$visualBottom " +
1480
+ "viewportTop=${recyclerView.paddingTop} " +
1481
+ "viewportBottom=${recyclerView.height - recyclerView.paddingBottom} " +
1482
+ "firstVisible=${layoutManager.findFirstVisibleItemPosition()} " +
1483
+ "lastVisible=${layoutManager.findLastVisibleItemPosition()} " +
1484
+ "canScrollUp=${recyclerView.canScrollVertically(-1)} " +
1485
+ "canScrollDown=${recyclerView.canScrollVertically(1)} " +
1486
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1487
+ "layoutRequested=${recyclerView.isLayoutRequested} " +
1488
+ "computingLayout=${recyclerView.isComputingLayout}",
1489
+ )
1490
+ }
1491
+ }
1302
1492
  }
1303
1493
 
1304
1494
  override fun interpolateOutOfBoundsScroll(
@@ -1308,35 +1498,60 @@ class NativeListView(
1308
1498
  totalSize: Int,
1309
1499
  msSinceStartScroll: Long,
1310
1500
  ): 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
1501
+ val effectiveViewSize: Int
1502
+ val effectiveOutOfBounds: Int
1503
+ if (compactWalletGroupDrag) {
1504
+ if (compactWalletGroupTop.isNaN()) return 0
1505
+ effectiveViewSize = dp(68)
1506
+ val compactTop = compactWalletGroupTop.roundToInt()
1507
+ effectiveOutOfBounds = when {
1508
+ compactTop < recyclerView.paddingTop -> compactTop - recyclerView.paddingTop
1509
+ compactTop + effectiveViewSize > recyclerView.height - recyclerView.paddingBottom ->
1510
+ compactTop + effectiveViewSize - (recyclerView.height - recyclerView.paddingBottom)
1511
+ else -> 0
1512
+ }
1513
+ if (effectiveOutOfBounds == 0) return 0
1514
+ } else {
1515
+ effectiveViewSize = viewSize
1516
+ effectiveOutOfBounds = viewSizeOutOfBounds
1328
1517
  }
1329
- if (compactOutOfBounds == 0) return 0
1330
- return super.interpolateOutOfBoundsScroll(
1518
+ // OneKey patch: warm-start AndroidX's two-second quintic edge-scroll ramp.
1519
+ val acceleratedElapsedOutMs =
1520
+ msSinceStartScroll + REORDER_AUTOSCROLL_ACCELERATION_OFFSET_MS
1521
+ val result = super.interpolateOutOfBoundsScroll(
1331
1522
  recyclerView,
1332
- compactHeight,
1333
- compactOutOfBounds,
1523
+ effectiveViewSize,
1524
+ effectiveOutOfBounds,
1334
1525
  totalSize,
1335
- msSinceStartScroll,
1526
+ // msSinceStartScroll,
1527
+ acceleratedElapsedOutMs,
1336
1528
  )
1529
+ val now = SystemClock.uptimeMillis()
1530
+ if (
1531
+ lastAutoScrollLogAtMs == 0L ||
1532
+ now - lastAutoScrollLogAtMs >= REORDER_AUTOSCROLL_LOG_INTERVAL_MS
1533
+ ) {
1534
+ lastAutoScrollLogAtMs = now
1535
+ OneKeyLog.info(
1536
+ REORDER_LOG_TAG,
1537
+ "autoScroll type=${dragType.orEmpty()} " +
1538
+ "compact=$compactWalletGroupDrag rawViewSize=$viewSize " +
1539
+ "effectiveViewSize=$effectiveViewSize rawOut=$viewSizeOutOfBounds " +
1540
+ "effectiveOut=$effectiveOutOfBounds totalSize=$totalSize " +
1541
+ "elapsedOutMs=$msSinceStartScroll " +
1542
+ "acceleratedElapsedOutMs=$acceleratedElapsedOutMs " +
1543
+ "elapsedDragMs=${now - dragStartedAtMs} " +
1544
+ "resultPx=$result scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1545
+ "canScrollUp=${recyclerView.canScrollVertically(-1)} " +
1546
+ "canScrollDown=${recyclerView.canScrollVertically(1)} " +
1547
+ "dragDx=$latestDragDx dragDy=$latestDragDy compactTop=$compactWalletGroupTop",
1548
+ )
1549
+ }
1550
+ return result
1337
1551
  }
1338
1552
 
1339
1553
  override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
1554
+ stopReorderRelayoutLoop()
1340
1555
  val rowView = (viewHolder as? NativeListViewHolder)?.rowView
1341
1556
  val draggedGroupKey = (rowView?.tag as? NativeListItem)?.key
1342
1557
  super.clearView(recyclerView, viewHolder)
@@ -1349,6 +1564,18 @@ class NativeListView(
1349
1564
  val from = dragFrom
1350
1565
  val to = dragTo
1351
1566
  val reordered = pendingReorder
1567
+ OneKeyLog.info(
1568
+ REORDER_LOG_TAG,
1569
+ "end type=${dragType.orEmpty()} from=$from to=$to " +
1570
+ "compact=$wasCompactWalletGroupDrag elapsedDragMs=" +
1571
+ "${SystemClock.uptimeMillis() - dragStartedAtMs} " +
1572
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()} " +
1573
+ "dragDx=$latestDragDx dragDy=$latestDragDy " +
1574
+ "firstVisible=${layoutManager.findFirstVisibleItemPosition()} " +
1575
+ "lastVisible=${layoutManager.findLastVisibleItemPosition()} " +
1576
+ "canScrollUp=${recyclerView.canScrollVertically(-1)} " +
1577
+ "canScrollDown=${recyclerView.canScrollVertically(1)}",
1578
+ )
1352
1579
  var reorderPayload: JSONObject? = null
1353
1580
  val destinationPosition = if (to != RecyclerView.NO_POSITION) {
1354
1581
  to
@@ -1404,6 +1631,7 @@ class NativeListView(
1404
1631
  dragFrom = RecyclerView.NO_POSITION
1405
1632
  dragTo = RecyclerView.NO_POSITION
1406
1633
  pendingReorder = null
1634
+ dragType = null
1407
1635
  // The gesture is committed now; a later snapshot may supersede its async diff.
1408
1636
  reorderPayload?.let { emit(REORDER, it) }
1409
1637
  }
@@ -1600,6 +1828,53 @@ class NativeListView(
1600
1828
  }
1601
1829
  }
1602
1830
 
1831
+ private fun startReorderRelayoutLoop() {
1832
+ reorderRelayoutActive = true
1833
+ lastReorderRelayoutLogAtMs = 0L
1834
+ scheduleReorderRelayout()
1835
+ }
1836
+
1837
+ private fun stopReorderRelayoutLoop() {
1838
+ reorderRelayoutActive = false
1839
+ }
1840
+
1841
+ private fun scheduleReorderRelayout() {
1842
+ if (!reorderRelayoutActive || reorderRelayoutScheduled) return
1843
+ reorderRelayoutScheduled = true
1844
+ recyclerView.postOnAnimation {
1845
+ reorderRelayoutScheduled = false
1846
+ if (
1847
+ !reorderRelayoutActive ||
1848
+ disposed ||
1849
+ recyclerView.width <= 0 ||
1850
+ recyclerView.height <= 0
1851
+ ) {
1852
+ return@postOnAnimation
1853
+ }
1854
+ val requestedBefore = recyclerView.isLayoutRequested
1855
+ val computingBefore = recyclerView.isComputingLayout
1856
+ if (requestedBefore && !computingBefore) {
1857
+ relayoutRecyclerViewImmediately()
1858
+ }
1859
+ val requestedAfter = recyclerView.isLayoutRequested
1860
+ val now = SystemClock.uptimeMillis()
1861
+ if (
1862
+ (requestedBefore || computingBefore || requestedAfter) &&
1863
+ (lastReorderRelayoutLogAtMs == 0L ||
1864
+ now - lastReorderRelayoutLogAtMs >= REORDER_RELAYOUT_LOG_INTERVAL_MS)
1865
+ ) {
1866
+ lastReorderRelayoutLogAtMs = now
1867
+ OneKeyLog.info(
1868
+ REORDER_LOG_TAG,
1869
+ "relayout requestedBefore=$requestedBefore computingBefore=$computingBefore " +
1870
+ "requestedAfter=$requestedAfter computingAfter=${recyclerView.isComputingLayout} " +
1871
+ "scrollOffset=${recyclerView.computeVerticalScrollOffset()}",
1872
+ )
1873
+ }
1874
+ scheduleReorderRelayout()
1875
+ }
1876
+ }
1877
+
1603
1878
  private fun relayoutRecyclerViewImmediately() {
1604
1879
  if (disposed || recyclerView.isComputingLayout || recyclerView.width <= 0 || recyclerView.height <= 0) {
1605
1880
  return
@@ -1619,10 +1894,20 @@ class NativeListView(
1619
1894
 
1620
1895
  private fun dp(value: Int): Int = if (usesSelectorSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value)
1621
1896
 
1897
+ private fun sectionIndexDp(value: Int): Int = (value * density).roundToInt()
1898
+
1622
1899
  companion object {
1900
+ private val MARKET_QUOTE_FIELDS = setOf(
1901
+ "revision",
1902
+ "price",
1903
+ "priceSegments",
1904
+ "change",
1905
+ "accessibilityLabel",
1906
+ )
1623
1907
  private const val SECTION_INDEX_CONTENT_INSET_DP = 16
1624
1908
  private const val SECTION_INDEX_RAIL_WIDTH_DP = 32
1625
- private const val SECTION_INDEX_PREVIEW_SIZE_DP = 48
1909
+ private const val SECTION_INDEX_PREVIEW_WIDTH_DP = 60
1910
+ private const val SECTION_INDEX_PREVIEW_HEIGHT_DP = 50
1626
1911
  private const val SECTION_INDEX_PREVIEW_END_MARGIN_DP = 40
1627
1912
  private const val REORDER_LONG_PRESS_MS = 200L
1628
1913
  private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10
@@ -1632,6 +1917,12 @@ class NativeListView(
1632
1917
  private const val REORDER_SPRING_STIFFNESS = 400.0
1633
1918
  private const val REORDER_SPRING_MASS = 0.4
1634
1919
  private const val REORDER_SPRING_DURATION_MS = 300L
1920
+ private const val REORDER_AUTOSCROLL_ACCELERATION_OFFSET_MS = 1_500L
1921
+ private const val REORDER_AUTOSCROLL_LOG_INTERVAL_MS = 100L
1922
+ private const val REORDER_DRAG_FRAME_LOG_INTERVAL_MS = 100L
1923
+ private const val REORDER_MOVE_ATTEMPT_LOG_INTERVAL_MS = 250L
1924
+ private const val REORDER_RELAYOUT_LOG_INTERVAL_MS = 100L
1925
+ private const val REORDER_LOG_TAG = "NativeListReorder"
1635
1926
  private const val ROW_ACTION = "rowAction"
1636
1927
  private const val ACTION_ANCHOR_INVALIDATED = "actionAnchorInvalidated"
1637
1928
  private const val SELECTION_DELTA = "selectionDelta"
@@ -1715,11 +2006,11 @@ private class NativeListSectionIndexView(
1715
2006
  private val activeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
1716
2007
  private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1717
2008
  textAlign = Paint.Align.CENTER
1718
- typeface = NativeListFonts.medium(context)
2009
+ typeface = NativeListFonts.regular(context)
1719
2010
  }
1720
2011
  private val activePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1721
2012
  textAlign = Paint.Align.CENTER
1722
- typeface = NativeListFonts.semibold(context)
2013
+ typeface = NativeListFonts.medium(context)
1723
2014
  }
1724
2015
 
1725
2016
  init {
@@ -1756,40 +2047,27 @@ private class NativeListSectionIndexView(
1756
2047
  override fun onDraw(canvas: Canvas) {
1757
2048
  super.onDraw(canvas)
1758
2049
  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
2050
+ val metrics = indexMetrics()
2051
+ val visibleIndices = visibleLabelIndices(metrics.trackHeight)
2052
+ val textSize = 10f * resources.displayMetrics.scaledDensity
2053
+ val centerX = width - dp(10f)
2054
+ val badgeRadius = dp(7f)
1762
2055
  normalPaint.color = normalColor
1763
2056
  normalPaint.textSize = textSize
1764
2057
  activePaint.color = activeColor
1765
2058
  activePaint.textSize = textSize
1766
- titles.forEachIndexed { index, title ->
2059
+ visibleIndices.forEachIndexed { visibleIndex, index ->
2060
+ val title = titles[index]
1767
2061
  val active = index == activeIndex
1768
2062
  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))
2063
+ val centerY = visibleLabelCenterY(visibleIndex, visibleIndices.size, metrics)
2064
+ if (active) {
1773
2065
  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
2066
+ canvas.drawCircle(centerX, centerY, badgeRadius, activeBackgroundPaint)
1790
2067
  }
2068
+ paint.color = if (active) activeTextColor else normalColor
1791
2069
  val baseline = centerY - (paint.descent() + paint.ascent()) / 2f
1792
- canvas.drawText(title, width / 2f, baseline, paint)
2070
+ canvas.drawText(title, centerX, baseline, paint)
1793
2071
  }
1794
2072
  }
1795
2073
 
@@ -1864,9 +2142,14 @@ private class NativeListSectionIndexView(
1864
2142
  }
1865
2143
 
1866
2144
  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)
2145
+ val metrics = indexMetrics()
2146
+ if (metrics.trackHeight <= 0f) return
2147
+ val visibleIndices = visibleLabelIndices(metrics.trackHeight).sorted()
2148
+ val visibleTrackHeight = minOf(metrics.trackHeight, dp(16f) * visibleIndices.size)
2149
+ val visibleOriginY = metrics.originY + (metrics.trackHeight - visibleTrackHeight) / 2f
2150
+ val progress = ((y - visibleOriginY) / visibleTrackHeight).coerceIn(0f, 1f)
2151
+ val slot = (progress * visibleIndices.size).toInt().coerceIn(0, visibleIndices.lastIndex)
2152
+ val index = visibleIndices[slot]
1870
2153
  if (interacting && lastTouchIndex == index) return
1871
2154
  lastTouchIndex = index.takeIf { interacting }
1872
2155
  select(index, interacting)
@@ -1877,29 +2160,60 @@ private class NativeListSectionIndexView(
1877
2160
  setActiveIndex(index)
1878
2161
  }
1879
2162
 
1880
- private fun cellHeight(): Float =
1881
- (height.toFloat() / titles.size.coerceAtLeast(1))
1882
- .coerceAtMost(NativeListScale.dp(resources, 16f))
1883
- .coerceAtLeast(1f)
2163
+ fun centerYForIndex(index: Int): Float = entryCenterY(index, indexMetrics())
2164
+
2165
+ private data class Metrics(val originY: Float, val trackHeight: Float)
1884
2166
 
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
2167
+ private fun indexMetrics(): Metrics {
2168
+ if (titles.isEmpty()) return Metrics(height / 2f, 0f)
2169
+ val edgePadding = dp(8f)
2170
+ val labelSpacing = dp(16f)
2171
+ val availableHeight = (height - edgePadding * 2f).coerceAtLeast(0f)
2172
+ val trackHeight = minOf(availableHeight, labelSpacing * titles.size)
2173
+ val centeredOriginY = (height - trackHeight) / 2f
2174
+ if (!centeredInWindow || !isAttachedToWindow) {
2175
+ return Metrics(centeredOriginY, trackHeight)
2176
+ }
1889
2177
  val systemBarInsets = ViewCompat.getRootWindowInsets(rootView)
1890
2178
  ?.getInsetsIgnoringVisibility(
1891
2179
  WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
1892
- ) ?: return centeredOriginY
2180
+ ) ?: return Metrics(centeredOriginY, trackHeight)
1893
2181
  rootView.getLocationOnScreen(rootLocationOnScreen)
1894
2182
  getLocationOnScreen(locationOnScreen)
1895
2183
  val safeTop = rootLocationOnScreen[1] + systemBarInsets.top
1896
2184
  val safeBottom = rootLocationOnScreen[1] + rootView.height - systemBarInsets.bottom
1897
- if (safeBottom <= safeTop) return centeredOriginY
2185
+ if (safeBottom <= safeTop) return Metrics(centeredOriginY, trackHeight)
1898
2186
  val localCenterY = (safeTop + safeBottom) / 2f - locationOnScreen[1]
1899
- return (localCenterY - totalHeight / 2f)
1900
- .coerceIn(0f, (height - totalHeight).coerceAtLeast(0f))
2187
+ val maximumOrigin = (height - edgePadding - trackHeight).coerceAtLeast(edgePadding)
2188
+ return Metrics(
2189
+ (localCenterY - trackHeight / 2f).coerceIn(edgePadding, maximumOrigin),
2190
+ trackHeight,
2191
+ )
1901
2192
  }
1902
2193
 
2194
+ private fun entryCenterY(index: Int, metrics: Metrics): Float {
2195
+ if (titles.isEmpty()) return height / 2f
2196
+ return metrics.originY + metrics.trackHeight * (index + 0.5f) / titles.size
2197
+ }
2198
+
2199
+ private fun visibleLabelCenterY(visibleIndex: Int, visibleCount: Int, metrics: Metrics): Float {
2200
+ val visibleTrackHeight = minOf(metrics.trackHeight, dp(16f) * visibleCount)
2201
+ val visibleOriginY = metrics.originY + (metrics.trackHeight - visibleTrackHeight) / 2f
2202
+ return visibleOriginY + visibleTrackHeight * (visibleIndex + 0.5f) / visibleCount.coerceAtLeast(1)
2203
+ }
2204
+
2205
+ private fun visibleLabelIndices(trackHeight: Float): Set<Int> {
2206
+ if (titles.isEmpty()) return emptySet()
2207
+ val maxVisible = max(1, (trackHeight / dp(16f)).toInt())
2208
+ if (titles.size <= maxVisible) return titles.indices.toSet()
2209
+ if (maxVisible == 1) return setOf(0)
2210
+ return (0 until maxVisible).mapTo(mutableSetOf()) { slot ->
2211
+ (slot.toFloat() * titles.lastIndex / (maxVisible - 1)).roundToInt()
2212
+ }
2213
+ }
2214
+
2215
+ private fun dp(value: Float): Float = value * resources.displayMetrics.density
2216
+
1903
2217
  private fun updateContentDescription() {
1904
2218
  contentDescription = activeIndex?.let { titles.getOrNull(it) }
1905
2219
  ?.let { "$ACCESSIBILITY_LABEL, $it" }