@onekeyfe/react-native-native-list 3.0.122 → 3.0.124

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 CHANGED
@@ -155,6 +155,8 @@ order, skips headers without `indexTitle`, and scrolls to the header's existing
155
155
  row `key`. Titles must be unique after Unicode NFC normalization, contain no
156
156
  leading/trailing whitespace, and be at most eight Unicode code points. Set
157
157
  `hapticsEnabled: false` to follow an application-level reduced-haptics setting.
158
+ Set `centeredInWindow: true` to render the interactive index rail against the
159
+ full window and center it on the window's geometric midpoint.
158
160
  The web implementation renders the same explicit index entries as a DOM
159
161
  overlay, with click and pointer-drag navigation.
160
162
 
@@ -18,14 +18,13 @@ import android.view.Gravity
18
18
  import android.view.MotionEvent
19
19
  import android.view.View
20
20
  import android.view.ViewConfiguration
21
+ import android.view.ViewGroup
21
22
  import android.view.accessibility.AccessibilityEvent
22
23
  import android.view.accessibility.AccessibilityNodeInfo
23
24
  import android.widget.FrameLayout
24
25
  import android.widget.LinearLayout
25
26
  import android.widget.SeekBar
26
27
  import android.widget.TextView
27
- import androidx.core.view.ViewCompat
28
- import androidx.core.view.WindowInsetsCompat
29
28
  import androidx.recyclerview.widget.GridLayoutManager
30
29
  import androidx.recyclerview.widget.ItemTouchHelper
31
30
  import androidx.recyclerview.widget.LinearLayoutManager
@@ -51,6 +50,11 @@ import kotlin.math.sqrt
51
50
  class NativeListView(
52
51
  private val reactContext: ThemedReactContext,
53
52
  ) : LinearLayout(reactContext) {
53
+ private data class VisibleMarketAnchor(
54
+ val key: String,
55
+ val offset: Int,
56
+ )
57
+
54
58
  private class ActionAnchorRecord(
55
59
  val token: String,
56
60
  origin: NativeListActionOrigin,
@@ -163,6 +167,8 @@ class NativeListView(
163
167
  private var sectionIndexScrubbing = false
164
168
  private var sectionIndexProgrammaticScroll = false
165
169
  private var sectionIndexHapticsEnabled = true
170
+ private val sectionIndexLocationOnScreen = IntArray(2)
171
+ private val sectionIndexHostLocationOnScreen = IntArray(2)
166
172
  private var pendingScrollRequest: ScrollRequest? = null
167
173
  private val actionAnchorInstanceId = UUID.randomUUID().toString()
168
174
  private var actionAnchorCounter = 0L
@@ -170,6 +176,7 @@ class NativeListView(
170
176
  private var lastLayoutWidth = -1
171
177
  private var lastLayoutHeight = -1
172
178
  private var lastLayoutDirection = layoutDirection
179
+ private var lastMarketPaginationAnchorLogAtMs = 0L
173
180
  private var disposed = false
174
181
 
175
182
  init {
@@ -298,6 +305,7 @@ class NativeListView(
298
305
  lastLayoutWidth = nextWidth
299
306
  lastLayoutHeight = nextHeight
300
307
  lastLayoutDirection = layoutDirection
308
+ updateSectionIndexAttachment()
301
309
  performPendingScrollIfNeeded()
302
310
  }
303
311
 
@@ -312,6 +320,7 @@ class NativeListView(
312
320
 
313
321
  override fun onAttachedToWindow() {
314
322
  super.onAttachedToWindow()
323
+ updateSectionIndexAttachment()
315
324
  val first = config?.items?.firstOrNull()
316
325
  if (recyclerView.isLayoutRequested && (first?.type == "market" || first?.json?.optString("presentation") == "market")) {
317
326
  relayoutContents()
@@ -319,6 +328,7 @@ class NativeListView(
319
328
  }
320
329
 
321
330
  override fun onDetachedFromWindow() {
331
+ attachSectionIndexToList()
322
332
  updateRefreshIndicatorOffset(0)
323
333
  super.onDetachedFromWindow()
324
334
  }
@@ -363,6 +373,9 @@ class NativeListView(
363
373
  }
364
374
  return
365
375
  }
376
+ val preserveMarketPaginationAnchor = previous?.let {
377
+ isMarketPaginationUpdate(it, next)
378
+ } ?: false
366
379
  // Keep the first Market row at its current pixel offset when it is
367
380
  // reordered. RecyclerView otherwise follows the previous first key.
368
381
  val marketStartOffset = if (
@@ -390,7 +403,7 @@ class NativeListView(
390
403
  if (marketStartOffset != null) {
391
404
  layoutManager.scrollToPositionWithOffset(0, marketStartOffset)
392
405
  }
393
- relayoutContents()
406
+ relayoutContents(preserveMarketPaginationAnchor)
394
407
  performPendingScrollIfNeeded()
395
408
  syncSectionIndexToVisibleRows()
396
409
  scheduleVisibleEvent()
@@ -402,6 +415,40 @@ class NativeListView(
402
415
  updateReordering(next)
403
416
  }
404
417
 
418
+ private fun captureVisibleMarketAnchor(): VisibleMarketAnchor? {
419
+ val position = layoutManager.findFirstVisibleItemPosition()
420
+ if (position == RecyclerView.NO_POSITION) return null
421
+ val item = adapter.itemAt(position) ?: return null
422
+ val view = layoutManager.findViewByPosition(position) ?: return null
423
+ return VisibleMarketAnchor(
424
+ key = item.key,
425
+ offset = layoutManager.getDecoratedTop(view) - recyclerView.paddingTop,
426
+ )
427
+ }
428
+
429
+ private fun isMarketPaginationUpdate(
430
+ previous: NativeListConfig,
431
+ next: NativeListConfig,
432
+ ): Boolean {
433
+ if (!previous.loadMore && !next.loadMore) return false
434
+ val previousRows = previous.items.dropLastWhile {
435
+ it.key in MARKET_PAGINATION_KEYS
436
+ }
437
+ val nextRows = next.items.dropLastWhile {
438
+ it.key in MARKET_PAGINATION_KEYS
439
+ }
440
+ if (previousRows.isEmpty() || previousRows.size > nextRows.size) return false
441
+ if (
442
+ previousRows.firstOrNull()?.type != "market" ||
443
+ nextRows.firstOrNull()?.type != "market"
444
+ ) {
445
+ return false
446
+ }
447
+ return previousRows.indices.all { index ->
448
+ previousRows[index].key == nextRows[index].key
449
+ }
450
+ }
451
+
405
452
  /**
406
453
  * A controlled selection update sends the snapshot back after the native
407
454
  * selection delta. Its structure is unchanged; only selectedKeys and the
@@ -907,6 +954,7 @@ class NativeListView(
907
954
 
908
955
  fun dispose() {
909
956
  if (disposed) return
957
+ attachSectionIndexToList()
910
958
  stopReorderRelayoutLoop()
911
959
  invalidateActionAnchor("destroy")
912
960
  actionAnchor = null
@@ -981,9 +1029,9 @@ class NativeListView(
981
1029
  themeColor(next.theme, "disabledText", "#8D8D8D"),
982
1030
  themeColor(next.theme, "positive", "#218358"),
983
1031
  themeColor(next.theme, "inverseText", "#FCFCFC"),
984
- next.sectionIndexCenteredInWindow,
985
1032
  )
986
1033
  sectionIndexView.visibility = if (sectionIndexEntries.isEmpty()) GONE else VISIBLE
1034
+ updateSectionIndexAttachment()
987
1035
  sectionIndexPreview.setTextColor(Color.WHITE)
988
1036
  sectionIndexPreview.background = sectionIndexPreviewBackground()
989
1037
  sectionIndexView.setActiveIndex(
@@ -1035,11 +1083,75 @@ class NativeListView(
1035
1083
  private fun positionSectionIndexPreview(index: Int) {
1036
1084
  val halfHeight = sectionIndexDp(SECTION_INDEX_PREVIEW_HEIGHT_DP) / 2f
1037
1085
  val maximumY = (contentContainer.height - halfHeight).coerceAtLeast(halfHeight)
1038
- val targetY = sectionIndexView.top + sectionIndexView.centerYForIndex(index)
1086
+ sectionIndexView.getLocationOnScreen(sectionIndexLocationOnScreen)
1087
+ contentContainer.getLocationOnScreen(sectionIndexHostLocationOnScreen)
1088
+ val targetY = sectionIndexLocationOnScreen[1] - sectionIndexHostLocationOnScreen[1] +
1089
+ sectionIndexView.centerYForIndex(index)
1039
1090
  val clampedY = targetY.coerceIn(halfHeight, maximumY)
1040
1091
  sectionIndexPreview.translationY = clampedY - contentContainer.height / 2f
1041
1092
  }
1042
1093
 
1094
+ private fun updateSectionIndexAttachment() {
1095
+ val windowHost = rootView as? FrameLayout
1096
+ val useWindowHost = config?.sectionIndexCenteredInWindow == true &&
1097
+ sectionIndexEntries.isNotEmpty() &&
1098
+ isAttachedToWindow &&
1099
+ isShown &&
1100
+ width > 0 &&
1101
+ height > 0 &&
1102
+ windowHost != null &&
1103
+ windowHost.width > 0 &&
1104
+ windowHost.height > 0 &&
1105
+ windowHost !== contentContainer
1106
+ if (useWindowHost) {
1107
+ attachSectionIndexToWindow(windowHost)
1108
+ } else {
1109
+ attachSectionIndexToList()
1110
+ }
1111
+ }
1112
+
1113
+ private fun attachSectionIndexToList() {
1114
+ if (sectionIndexView.parent === contentContainer) return
1115
+ (sectionIndexView.parent as? ViewGroup)?.removeView(sectionIndexView)
1116
+ contentContainer.addView(
1117
+ sectionIndexView,
1118
+ FrameLayout.LayoutParams(
1119
+ sectionIndexDp(SECTION_INDEX_RAIL_WIDTH_DP),
1120
+ FrameLayout.LayoutParams.MATCH_PARENT,
1121
+ Gravity.END,
1122
+ ),
1123
+ )
1124
+ }
1125
+
1126
+ private fun attachSectionIndexToWindow(windowHost: FrameLayout) {
1127
+ val railWidth = sectionIndexDp(SECTION_INDEX_RAIL_WIDTH_DP)
1128
+ val railHeight = sectionIndexView.preferredHeight(windowHost.height)
1129
+ windowHost.getLocationOnScreen(sectionIndexHostLocationOnScreen)
1130
+ getLocationOnScreen(sectionIndexLocationOnScreen)
1131
+ val listLeft = sectionIndexLocationOnScreen[0] - sectionIndexHostLocationOnScreen[0]
1132
+ val maximumEndMargin = (windowHost.width - railWidth).coerceAtLeast(0)
1133
+ val endMargin = if (layoutDirection == LAYOUT_DIRECTION_RTL) {
1134
+ listLeft
1135
+ } else {
1136
+ windowHost.width - listLeft - width
1137
+ }.coerceIn(0, maximumEndMargin)
1138
+ val layoutParams = FrameLayout.LayoutParams(
1139
+ railWidth,
1140
+ railHeight,
1141
+ Gravity.TOP or Gravity.END,
1142
+ ).apply {
1143
+ marginEnd = endMargin
1144
+ topMargin = (windowHost.height - railHeight) / 2
1145
+ }
1146
+ if (sectionIndexView.parent !== windowHost) {
1147
+ (sectionIndexView.parent as? ViewGroup)?.removeView(sectionIndexView)
1148
+ windowHost.addView(sectionIndexView, layoutParams)
1149
+ sectionIndexView.bringToFront()
1150
+ } else {
1151
+ sectionIndexView.layoutParams = layoutParams
1152
+ }
1153
+ }
1154
+
1043
1155
  private fun sectionIndexPreviewBackground(): ShapeDrawable {
1044
1156
  val path = Path().apply {
1045
1157
  moveTo(25f, 0f)
@@ -1839,15 +1951,36 @@ class NativeListView(
1839
1951
  * already-sized host once after a committed data update so the native rows
1840
1952
  * are measured and rebound without rebuilding the adapter.
1841
1953
  */
1842
- private fun relayoutContents() {
1954
+ private fun relayoutContents(preserveMarketPaginationAnchor: Boolean = false) {
1843
1955
  post {
1844
1956
  if (disposed || width <= 0 || height <= 0) return@post
1957
+ val anchor = if (preserveMarketPaginationAnchor) {
1958
+ captureVisibleMarketAnchor()
1959
+ } else {
1960
+ null
1961
+ }
1962
+ val anchorIndex = anchor?.let { currentAnchor ->
1963
+ adapter.currentList.indexOfFirst { it.key == currentAnchor.key }
1964
+ } ?: RecyclerView.NO_POSITION
1965
+ if (anchor != null && anchorIndex >= 0) {
1966
+ layoutManager.scrollToPositionWithOffset(anchorIndex, anchor.offset)
1967
+ }
1845
1968
  forceLayout()
1846
1969
  measure(
1847
1970
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
1848
1971
  MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
1849
1972
  )
1850
1973
  layout(left, top, right, bottom)
1974
+ if (anchorIndex >= 0) {
1975
+ val now = SystemClock.uptimeMillis()
1976
+ if (now - lastMarketPaginationAnchorLogAtMs >= 1_000L) {
1977
+ lastMarketPaginationAnchorLogAtMs = now
1978
+ OneKeyLog.debug(
1979
+ "NativeList",
1980
+ "market-pagination-anchor-restored generation=${config?.generation} index=$anchorIndex",
1981
+ )
1982
+ }
1983
+ }
1851
1984
  }
1852
1985
  }
1853
1986
 
@@ -1937,6 +2070,11 @@ class NativeListView(
1937
2070
  private fun sectionIndexDp(value: Int): Int = (value * density).roundToInt()
1938
2071
 
1939
2072
  companion object {
2073
+ private val MARKET_PAGINATION_KEYS = setOf(
2074
+ "market-loading-more",
2075
+ "market-load-more-retry",
2076
+ "market-end",
2077
+ )
1940
2078
  private val MARKET_QUOTE_FIELDS = setOf(
1941
2079
  "revision",
1942
2080
  "price",
@@ -2039,10 +2177,7 @@ private class NativeListSectionIndexView(
2039
2177
  private var normalColor = Color.GRAY
2040
2178
  private var activeColor = Color.BLACK
2041
2179
  private var activeTextColor = Color.WHITE
2042
- private var centeredInWindow = false
2043
2180
  private var lastTouchIndex: Int? = null
2044
- private val rootLocationOnScreen = IntArray(2)
2045
- private val locationOnScreen = IntArray(2)
2046
2181
  private val activeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
2047
2182
  private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
2048
2183
  textAlign = Paint.Align.CENTER
@@ -2065,13 +2200,11 @@ private class NativeListSectionIndexView(
2065
2200
  normalColor: Int,
2066
2201
  activeColor: Int,
2067
2202
  activeTextColor: Int,
2068
- centeredInWindow: Boolean,
2069
2203
  ) {
2070
2204
  this.titles = titles
2071
2205
  this.normalColor = normalColor
2072
2206
  this.activeColor = activeColor
2073
2207
  this.activeTextColor = activeTextColor
2074
- this.centeredInWindow = centeredInWindow
2075
2208
  activeIndex = null
2076
2209
  updateContentDescription()
2077
2210
  invalidate()
@@ -2202,6 +2335,11 @@ private class NativeListSectionIndexView(
2202
2335
 
2203
2336
  fun centerYForIndex(index: Int): Float = entryCenterY(index, indexMetrics())
2204
2337
 
2338
+ fun preferredHeight(maximumHeight: Int): Int = minOf(
2339
+ maximumHeight,
2340
+ (dp(8f) * 2f + dp(16f) * titles.size).roundToInt(),
2341
+ )
2342
+
2205
2343
  private data class Metrics(val originY: Float, val trackHeight: Float)
2206
2344
 
2207
2345
  private fun indexMetrics(): Metrics {
@@ -2210,25 +2348,7 @@ private class NativeListSectionIndexView(
2210
2348
  val labelSpacing = dp(16f)
2211
2349
  val availableHeight = (height - edgePadding * 2f).coerceAtLeast(0f)
2212
2350
  val trackHeight = minOf(availableHeight, labelSpacing * titles.size)
2213
- val centeredOriginY = (height - trackHeight) / 2f
2214
- if (!centeredInWindow || !isAttachedToWindow) {
2215
- return Metrics(centeredOriginY, trackHeight)
2216
- }
2217
- val systemBarInsets = ViewCompat.getRootWindowInsets(rootView)
2218
- ?.getInsetsIgnoringVisibility(
2219
- WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
2220
- ) ?: return Metrics(centeredOriginY, trackHeight)
2221
- rootView.getLocationOnScreen(rootLocationOnScreen)
2222
- getLocationOnScreen(locationOnScreen)
2223
- val safeTop = rootLocationOnScreen[1] + systemBarInsets.top
2224
- val safeBottom = rootLocationOnScreen[1] + rootView.height - systemBarInsets.bottom
2225
- if (safeBottom <= safeTop) return Metrics(centeredOriginY, trackHeight)
2226
- val localCenterY = (safeTop + safeBottom) / 2f - locationOnScreen[1]
2227
- val maximumOrigin = (height - edgePadding - trackHeight).coerceAtLeast(edgePadding)
2228
- return Metrics(
2229
- (localCenterY - trackHeight / 2f).coerceIn(edgePadding, maximumOrigin),
2230
- trackHeight,
2231
- )
2351
+ return Metrics((height - trackHeight) / 2f, trackHeight)
2232
2352
  }
2233
2353
 
2234
2354
  private fun entryCenterY(index: Int, metrics: Metrics): Float {
@@ -100,6 +100,15 @@ final class NativeListActionOrigin {
100
100
 
101
101
  // OneKey patch: explicit summary actions use the source text's physical-pixel line box.
102
102
  private final class NativeListAccessoryButton: UIButton {
103
+ var pressedBackgroundColor: UIColor?
104
+
105
+ override var isHighlighted: Bool {
106
+ didSet {
107
+ guard let pressedBackgroundColor else { return }
108
+ backgroundColor = isHighlighted ? pressedBackgroundColor : .clear
109
+ }
110
+ }
111
+
103
112
  var selectorSummaryLineHeight: CGFloat? {
104
113
  didSet { invalidateIntrinsicContentSize(); setNeedsLayout() }
105
114
  }
@@ -1373,6 +1382,7 @@ final class NativeListCell: UICollectionViewCell {
1373
1382
  button.setImage(nil, for: .disabled)
1374
1383
  button.isEnabled = true
1375
1384
  button.alpha = 1
1385
+ button.pressedBackgroundColor = nil
1376
1386
  button.titleLabel?.numberOfLines = 1
1377
1387
  button.contentHorizontalAlignment = .center
1378
1388
  button.backgroundColor = .clear
@@ -1924,6 +1934,15 @@ final class NativeListCell: UICollectionViewCell {
1924
1934
  trailingStack.spacing = 10
1925
1935
  }
1926
1936
  bindAccessories(item, accessories, theme, checkboxState)
1937
+ if item.data.string("presentation") == "accountSelector",
1938
+ accessories.count == 1,
1939
+ let accessory = accessoryButtons.first(where: { !$0.isHidden }) {
1940
+ // The vertical trailing stack has no stable intrinsic width, so keep its
1941
+ // flexible space in the account title and subtitle column.
1942
+ let width = trailingStack.widthAnchor.constraint(equalTo: accessory.widthAnchor)
1943
+ width.isActive = true
1944
+ selectorConstraints.append(width)
1945
+ }
1927
1946
  }
1928
1947
 
1929
1948
  private func bindRail(_ item: NativeListItem, theme: [String: Any]?) {
@@ -3965,6 +3984,10 @@ final class NativeListCell: UICollectionViewCell {
3965
3984
  let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" && !isDrillIn
3966
3985
  let size: CGFloat = isDrillIn ? 24 : isAccountIcon && !isAccountCreate ? 38 : 36
3967
3986
  if isAccountCreate { button.layer.cornerRadius = 8 }
3987
+ if isAccountIcon && !isAccountCreate && !data.string("actionKey").isEmpty {
3988
+ button.pressedBackgroundColor = nativeListColor(currentTheme, "rowPressedBackground", "#E8E8E8")
3989
+ button.layer.cornerRadius = size / 2
3990
+ }
3968
3991
  if isAccountIcon { rootStack.setCustomSpacing(5, after: mainStack) }
3969
3992
  button.accessibilityIdentifier = data["testID"] as? String
3970
3993
  button.accessibilityLabel = data["accessibilityLabel"] as? String
@@ -65,6 +65,8 @@ final class NativeListView: UIView {
65
65
  private let footerCell = NativeListCell(frame: .zero)
66
66
  private let sectionIndexView = NativeListSectionIndexView()
67
67
  private let sectionIndexPreview = NativeListSectionIndexPreviewView()
68
+ private var sectionIndexLayoutConstraints: [NSLayoutConstraint] = []
69
+ private var sectionIndexWindowHeightConstraint: NSLayoutConstraint?
68
70
  private var footerHeightConstraint: NSLayoutConstraint!
69
71
  private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
70
72
  private var config: NativeListConfig?
@@ -161,10 +163,6 @@ final class NativeListView: UIView {
161
163
  footerContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
162
164
  footerContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
163
165
  footerHeightConstraint,
164
- sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
165
- sectionIndexView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
166
- sectionIndexView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
167
- sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexRailWidth),
168
166
  sectionIndexPreview.trailingAnchor.constraint(
169
167
  equalTo: safeAreaLayoutGuide.trailingAnchor,
170
168
  constant: -Self.sectionIndexPreviewEndMargin
@@ -173,6 +171,7 @@ final class NativeListView: UIView {
173
171
  sectionIndexPreview.widthAnchor.constraint(equalToConstant: Self.sectionIndexPreviewWidth),
174
172
  sectionIndexPreview.heightAnchor.constraint(equalToConstant: Self.sectionIndexPreviewHeight),
175
173
  ])
174
+ attachSectionIndexToList()
176
175
 
177
176
  sectionIndexView.isHidden = true
178
177
  sectionIndexView.onSelect = { [weak self] index, interacting in
@@ -233,8 +232,19 @@ final class NativeListView: UIView {
233
232
  fatalError("init(coder:) has not been implemented")
234
233
  }
235
234
 
235
+ override func willMove(toWindow newWindow: UIWindow?) {
236
+ if newWindow == nil { attachSectionIndexToList() }
237
+ super.willMove(toWindow: newWindow)
238
+ }
239
+
240
+ override func didMoveToWindow() {
241
+ super.didMoveToWindow()
242
+ updateSectionIndexAttachment()
243
+ }
244
+
236
245
  override func layoutSubviews() {
237
246
  super.layoutSubviews()
247
+ updateSectionIndexAttachment()
238
248
  let direction = effectiveUserInterfaceLayoutDirection
239
249
  if let lastLayoutSize,
240
250
  lastLayoutSize != bounds.size || lastLayoutDirection != nil && lastLayoutDirection != direction {
@@ -1027,10 +1037,10 @@ final class NativeListView: UIView {
1027
1037
  titles: sectionIndexEntries.map(\.title),
1028
1038
  textColor: nativeListColor(config.theme, "disabledText", "#8D8D8D"),
1029
1039
  activeColor: nativeListColor(config.theme, "positive", "#218358"),
1030
- activeTextColor: nativeListColor(config.theme, "inverseText", "#FCFCFC"),
1031
- centeredInWindow: config.sectionIndexCenteredInWindow
1040
+ activeTextColor: nativeListColor(config.theme, "inverseText", "#FCFCFC")
1032
1041
  )
1033
1042
  sectionIndexView.isHidden = sectionIndexEntries.isEmpty
1043
+ updateSectionIndexAttachment()
1034
1044
  sectionIndexPreview.configure(
1035
1045
  fillColor: UIColor(
1036
1046
  red: 202.0 / 255.0,
@@ -1049,6 +1059,63 @@ final class NativeListView: UIView {
1049
1059
  if sectionIndexEntries.isEmpty { finishSectionIndexInteraction(immediately: true) }
1050
1060
  }
1051
1061
 
1062
+ private func updateSectionIndexAttachment() {
1063
+ guard config?.sectionIndexCenteredInWindow == true,
1064
+ !sectionIndexEntries.isEmpty,
1065
+ bounds.width > 0,
1066
+ bounds.height > 0,
1067
+ isVisibleInHierarchy,
1068
+ let window else {
1069
+ attachSectionIndexToList()
1070
+ return
1071
+ }
1072
+ attachSectionIndex(to: window)
1073
+ }
1074
+
1075
+ private var isVisibleInHierarchy: Bool {
1076
+ var current: UIView? = self
1077
+ while let view = current {
1078
+ if view.isHidden || view.alpha <= 0.01 { return false }
1079
+ current = view.superview
1080
+ }
1081
+ return true
1082
+ }
1083
+
1084
+ private func attachSectionIndexToList() {
1085
+ guard sectionIndexView.superview !== self || sectionIndexLayoutConstraints.isEmpty else { return }
1086
+ NSLayoutConstraint.deactivate(sectionIndexLayoutConstraints)
1087
+ sectionIndexWindowHeightConstraint = nil
1088
+ sectionIndexView.removeFromSuperview()
1089
+ addSubview(sectionIndexView)
1090
+ sectionIndexLayoutConstraints = [
1091
+ sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
1092
+ sectionIndexView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
1093
+ sectionIndexView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
1094
+ sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexRailWidth),
1095
+ ]
1096
+ NSLayoutConstraint.activate(sectionIndexLayoutConstraints)
1097
+ }
1098
+
1099
+ private func attachSectionIndex(to window: UIWindow) {
1100
+ let railHeight = sectionIndexView.preferredHeight(constrainedTo: window.bounds.height)
1101
+ if sectionIndexView.superview === window {
1102
+ sectionIndexWindowHeightConstraint?.constant = railHeight
1103
+ return
1104
+ }
1105
+ NSLayoutConstraint.deactivate(sectionIndexLayoutConstraints)
1106
+ sectionIndexView.removeFromSuperview()
1107
+ window.addSubview(sectionIndexView)
1108
+ let heightConstraint = sectionIndexView.heightAnchor.constraint(equalToConstant: railHeight)
1109
+ sectionIndexWindowHeightConstraint = heightConstraint
1110
+ sectionIndexLayoutConstraints = [
1111
+ sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
1112
+ sectionIndexView.centerYAnchor.constraint(equalTo: window.centerYAnchor),
1113
+ sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexRailWidth),
1114
+ heightConstraint,
1115
+ ]
1116
+ NSLayoutConstraint.activate(sectionIndexLayoutConstraints)
1117
+ }
1118
+
1052
1119
  private func selectSectionIndex(_ index: Int, interacting: Bool) {
1053
1120
  guard let entry = sectionIndexEntries[safe: index] else { return }
1054
1121
  let changed = sectionIndexView.activeIndex != index
@@ -2156,7 +2223,6 @@ private final class NativeListSectionIndexView: UIControl, UIGestureRecognizerDe
2156
2223
  private var textColor: UIColor = .secondaryLabel
2157
2224
  private var activeColor: UIColor = .tintColor
2158
2225
  private var activeTextColor: UIColor = .white
2159
- private var centeredInWindow = false
2160
2226
  private var lastTouchIndex: Int?
2161
2227
  private(set) var activeIndex: Int?
2162
2228
 
@@ -2197,14 +2263,12 @@ private final class NativeListSectionIndexView: UIControl, UIGestureRecognizerDe
2197
2263
  titles: [String],
2198
2264
  textColor: UIColor,
2199
2265
  activeColor: UIColor,
2200
- activeTextColor: UIColor,
2201
- centeredInWindow: Bool
2266
+ activeTextColor: UIColor
2202
2267
  ) {
2203
2268
  self.titles = titles
2204
2269
  self.textColor = textColor
2205
2270
  self.activeColor = activeColor
2206
2271
  self.activeTextColor = activeTextColor
2207
- self.centeredInWindow = centeredInWindow
2208
2272
  labels.forEach { $0.removeFromSuperview() }
2209
2273
  labels = titles.map { title in
2210
2274
  let label = UILabel()
@@ -2313,20 +2377,15 @@ private final class NativeListSectionIndexView: UIControl, UIGestureRecognizerDe
2313
2377
  entryCenterY(index: index, metrics: indexMetrics(count: titles.count))
2314
2378
  }
2315
2379
 
2380
+ func preferredHeight(constrainedTo maximumHeight: CGFloat) -> CGFloat {
2381
+ min(maximumHeight, Self.edgePadding * 2 + Self.labelSpacing * CGFloat(titles.count))
2382
+ }
2383
+
2316
2384
  private func indexMetrics(count: Int) -> (originY: CGFloat, trackHeight: CGFloat) {
2317
2385
  guard count > 0 else { return (bounds.midY, 0) }
2318
2386
  let availableHeight = max(0, bounds.height - Self.edgePadding * 2)
2319
2387
  let trackHeight = min(availableHeight, Self.labelSpacing * CGFloat(count))
2320
- let centeredOriginY = (bounds.height - trackHeight) / 2
2321
- guard centeredInWindow, let window else { return (centeredOriginY, trackHeight) }
2322
- let windowCenterY = window.safeAreaLayoutGuide.layoutFrame.midY
2323
- let localCenterY = convert(CGPoint(x: 0, y: windowCenterY), from: window).y
2324
- return (
2325
- (localCenterY - trackHeight / 2).clamped(
2326
- to: Self.edgePadding...max(Self.edgePadding, bounds.height - Self.edgePadding - trackHeight)
2327
- ),
2328
- trackHeight
2329
- )
2388
+ return ((bounds.height - trackHeight) / 2, trackHeight)
2330
2389
  }
2331
2390
 
2332
2391
  private func entryCenterY(
@@ -10,6 +10,7 @@ const SECTION_INDEX_RAIL_WIDTH = 32;
10
10
  const SECTION_INDEX_EDGE_PADDING = 8;
11
11
  const SECTION_INDEX_LABEL_SPACING = 16;
12
12
  const SECTION_INDEX_MIN_HEIGHT = 120;
13
+ const SECTION_INDEX_WINDOW_Z_INDEX = 100_000;
13
14
  const DEFAULT_VIEWPORT_WIDTH = 320;
14
15
  const DEFAULT_VIEWPORT_HEIGHT = 640;
15
16
  const OVERSCAN_VIEWPORTS = 1;
@@ -467,12 +468,12 @@ export const WEB_LIST_CSS = `
467
468
  .ok-native-list-item[data-native-list-reorderable="true"]>.ok-native-list-row{cursor:grab}
468
469
  .ok-native-list-root[data-native-list-dragging="true"] .ok-native-list-row{cursor:grabbing}
469
470
  .ok-native-list-item[data-native-list-animate-reorder="true"]{transition:transform ${WEB_REORDER_ANIMATION.outOfWayDurationMs}ms ${WEB_REORDER_ANIMATION.outOfWayTimingFunction},height ${WEB_REORDER_ANIMATION.outOfWayDurationMs}ms ${WEB_REORDER_ANIMATION.outOfWayTimingFunction}}
470
- .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-row{overflow:hidden;border-radius:12px;background:var(--nl-row)}
471
+ .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-row{overflow:hidden;border-radius:12px;background:var(--nl-pressed)}
471
472
  .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-row>*{visibility:hidden}
472
- .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-wallet-group{border-color:transparent;background:var(--nl-row)}
473
+ .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-wallet-group{border-color:transparent;background:var(--nl-pressed)}
473
474
  .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-wallet-group>*{visibility:hidden}
474
475
  .ok-native-list-reorder-preview{position:fixed;left:0;top:0;z-index:100001;pointer-events:none;overflow:hidden;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.12);transform-origin:center;will-change:transform;font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
475
- .ok-native-list-reorder-preview>.ok-native-list-row{background:var(--nl-row);cursor:grabbing}
476
+ .ok-native-list-reorder-preview>.ok-native-list-row{background:var(--nl-pressed);cursor:grabbing}
476
477
  .ok-native-list-reorder-preview[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)}
477
478
  .ok-native-list-reorder-count{position:absolute;right:4px;bottom:4px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:24px;height:24px;padding:0 6px;border:1px solid var(--nl-row);border-radius:12px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;line-height:22px;font-weight:600}
478
479
  .ok-native-list-item[data-separator="true"]>.ok-native-list-row{border-bottom:1px solid var(--nl-separator)}
@@ -504,7 +505,7 @@ export const WEB_LIST_CSS = `
504
505
  .ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain}
505
506
  .ok-native-list-row.ok-native-list-market-skeleton{padding:12px 20px;gap:0}.ok-native-list-skeleton-left{display:flex;align-items:center;gap:12px;flex:1}.ok-native-list-skeleton-text{display:flex;flex-direction:column;gap:4px}.ok-native-list-skeleton-right{display:flex;align-items:center;gap:8px}.ok-native-list-skeleton-mark{display:block;flex-shrink:0;border-radius:8px;animation:ok-native-list-skeleton 1.5s linear infinite alternate}@keyframes ok-native-list-skeleton{from{background-color:var(--nl-skeleton-base)}to{background-color:var(--nl-skeleton-highlight)}}.ok-native-list-market-spinner{display:block;width:20px;height:20px;flex-shrink:0;color:var(--nl-icon);animation:ok-native-list-spin .75s linear infinite}
506
507
  @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner,.ok-native-list-market-spinner{animation:none}.ok-native-list-skeleton-mark{animation:none;background:var(--nl-skeleton-base)}}
507
- .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport{scrollbar-width:none}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport::-webkit-scrollbar{display:none}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:15px;display:flex;width:14px;height:14px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:7px;background:transparent;color:var(--nl-disabled);font-family:inherit;font-size:10px;font-weight:400;line-height:1;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-positive);color:var(--nl-inverse-text);font-weight:500}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-positive);outline-offset:1px}
508
+ .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport,.ok-native-list-viewport-frame[data-section-index-visible="true"]>.ok-native-list-viewport{scrollbar-width:none}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport::-webkit-scrollbar,.ok-native-list-viewport-frame[data-section-index-visible="true"]>.ok-native-list-viewport::-webkit-scrollbar{display:none}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer;font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:15px;display:flex;width:14px;height:14px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:7px;background:transparent;color:var(--nl-disabled);font-family:inherit;font-size:10px;font-weight:400;line-height:1;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-positive);color:var(--nl-inverse-text);font-weight:500}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-positive);outline-offset:1px}
508
509
  .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)}
509
510
  .ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)}
510
511
  .ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain}
@@ -2257,6 +2258,7 @@ export class NativeListWebEngine {
2257
2258
  disposeWebImageRetries(this.root);
2258
2259
  this.pool.forEach(disposeWebImageRetries);
2259
2260
  this.root.remove();
2261
+ this.indexRail.remove();
2260
2262
  this.hideReorderPreview();
2261
2263
  this.reorderPreview.remove();
2262
2264
  if (host) host.style.position = this.previousHostPosition;
@@ -2308,6 +2310,7 @@ export class NativeListWebEngine {
2308
2310
  Object.entries(values).forEach(([name, value]) => {
2309
2311
  if (!value) return;
2310
2312
  this.root.style.setProperty(name, value);
2313
+ this.indexRail.style.setProperty(name, value);
2311
2314
  this.reorderPreview.style.setProperty(name, value);
2312
2315
  });
2313
2316
  }
@@ -2315,14 +2318,12 @@ export class NativeListWebEngine {
2315
2318
  if (this.destroyed) return;
2316
2319
  const viewportWidth = this.viewport.clientWidth;
2317
2320
  const viewportHeight = this.snapshot.layout.orientation === 'horizontal' ? this.viewport.clientHeight : this.verticalScrollMetrics().viewportLength;
2321
+ const stickyInset = this.snapshot.layout.orientation === 'horizontal' ? 0 : this.collapsiblePagerInsets().sticky;
2318
2322
  if (this.snapshot.layout.orientation !== 'horizontal') {
2319
- const stickyInset = this.collapsiblePagerInsets().sticky;
2320
2323
  this.sticky.style.top = String(stickyInset) + 'px';
2321
- this.indexRail.style.top = String(stickyInset) + 'px';
2322
2324
  this.refreshIndicator.style.top = String(stickyInset + 8) + 'px';
2323
2325
  } else {
2324
2326
  this.sticky.style.top = '0px';
2325
- this.indexRail.style.top = '0px';
2326
2327
  this.refreshIndicator.style.top = '8px';
2327
2328
  }
2328
2329
  if (this.lastViewportWidth >= 0 && (viewportWidth !== this.lastViewportWidth || viewportHeight !== this.lastViewportHeight)) {
@@ -2342,7 +2343,7 @@ export class NativeListWebEngine {
2342
2343
  this.layout = computeWebListLayout(measuredSnapshot, viewportWidth, viewportHeight, this.reorderCompactKey);
2343
2344
  this.content.style.width = String(this.layout.contentWidth) + 'px';
2344
2345
  this.content.style.height = String(this.layout.contentHeight) + 'px';
2345
- this.renderSectionIndex(this.viewport.clientHeight <= 0 ? DEFAULT_VIEWPORT_HEIGHT : Math.max(1, viewportHeight));
2346
+ this.renderSectionIndex(this.viewport.clientHeight <= 0 ? DEFAULT_VIEWPORT_HEIGHT : Math.max(1, viewportHeight), stickyInset);
2346
2347
  if (previousHorizontal !== this.layout.horizontal) {
2347
2348
  this.viewport.scrollLeft = 0;
2348
2349
  this.viewport.scrollTop = 0;
@@ -2570,32 +2571,54 @@ export class NativeListWebEngine {
2570
2571
  sectionIndexMetrics(viewportHeight) {
2571
2572
  const availableHeight = Math.max(0, viewportHeight - SECTION_INDEX_EDGE_PADDING * 2);
2572
2573
  const trackHeight = Math.min(availableHeight, SECTION_INDEX_LABEL_SPACING * this.sectionIndexEntries.length);
2573
- const centeredOriginY = (viewportHeight - trackHeight) / 2;
2574
- if (!this.snapshot.capabilities?.sectionIndex?.centeredInWindow) {
2575
- return {
2576
- originY: centeredOriginY,
2577
- trackHeight
2578
- };
2579
- }
2580
- const view = this.document.defaultView;
2581
- if (!view) return {
2582
- originY: centeredOriginY,
2583
- trackHeight
2584
- };
2585
- const visualViewport = view.visualViewport;
2586
- const windowCenterY = visualViewport ? visualViewport.offsetTop + visualViewport.height / 2 : view.innerHeight / 2;
2587
- const railTop = this.viewportFrame.getBoundingClientRect().top + (Number.parseFloat(this.indexRail.style.top) || 0);
2588
- const minOriginY = SECTION_INDEX_EDGE_PADDING;
2589
- const maxOriginY = Math.max(minOriginY, viewportHeight - SECTION_INDEX_EDGE_PADDING - trackHeight);
2590
2574
  return {
2591
- originY: Math.min(maxOriginY, Math.max(minOriginY, windowCenterY - railTop - trackHeight / 2)),
2575
+ originY: (viewportHeight - trackHeight) / 2,
2592
2576
  trackHeight
2593
2577
  };
2594
2578
  }
2595
- renderSectionIndex(viewportHeight) {
2579
+ configureSectionIndexRail(viewportHeight, stickyInset, windowCentered) {
2580
+ const view = this.document.defaultView;
2581
+ const direction = view?.getComputedStyle(this.viewportFrame).direction === 'rtl' ? 'rtl' : 'ltr';
2582
+ this.indexRail.style.direction = direction;
2583
+ if (!windowCentered) {
2584
+ if (this.indexRail.parentElement !== this.viewportFrame) {
2585
+ this.viewportFrame.appendChild(this.indexRail);
2586
+ }
2587
+ this.indexRail.style.removeProperty('position');
2588
+ this.indexRail.style.removeProperty('left');
2589
+ this.indexRail.style.removeProperty('right');
2590
+ this.indexRail.style.removeProperty('height');
2591
+ this.indexRail.style.removeProperty('z-index');
2592
+ this.indexRail.style.insetInlineEnd = '0px';
2593
+ this.indexRail.style.top = String(stickyInset) + 'px';
2594
+ this.indexRail.style.bottom = '0px';
2595
+ return viewportHeight;
2596
+ }
2597
+ if (!view) return viewportHeight;
2598
+ const visualViewport = view.visualViewport;
2599
+ const windowTop = visualViewport?.offsetTop ?? 0;
2600
+ const windowHeight = visualViewport?.height ?? view.innerHeight;
2601
+ const railHeight = Math.min(windowHeight, SECTION_INDEX_EDGE_PADDING * 2 + SECTION_INDEX_LABEL_SPACING * this.sectionIndexEntries.length);
2602
+ const frame = this.viewportFrame.getBoundingClientRect();
2603
+ if (this.indexRail.parentElement !== this.document.body) {
2604
+ this.document.body.appendChild(this.indexRail);
2605
+ }
2606
+ this.indexRail.style.position = 'fixed';
2607
+ this.indexRail.style.removeProperty('left');
2608
+ this.indexRail.style.removeProperty('right');
2609
+ this.indexRail.style.insetInlineEnd = String(Math.max(0, direction === 'rtl' ? frame.left : view.innerWidth - frame.right)) + 'px';
2610
+ this.indexRail.style.top = String(windowTop + (windowHeight - railHeight) / 2) + 'px';
2611
+ this.indexRail.style.bottom = 'auto';
2612
+ this.indexRail.style.height = String(railHeight) + 'px';
2613
+ this.indexRail.style.zIndex = String(SECTION_INDEX_WINDOW_Z_INDEX);
2614
+ return railHeight;
2615
+ }
2616
+ renderSectionIndex(viewportHeight, stickyInset) {
2596
2617
  this.indexRail.replaceChildren();
2597
2618
  if (!sectionIndexEnabled(this.snapshot)) {
2598
2619
  this.sectionIndexEntries = [];
2620
+ this.configureSectionIndexRail(viewportHeight, stickyInset, false);
2621
+ setData(this.viewportFrame, 'sectionIndexVisible', false);
2599
2622
  this.indexRail.hidden = true;
2600
2623
  return;
2601
2624
  }
@@ -2605,12 +2628,16 @@ export class NativeListWebEngine {
2605
2628
  position
2606
2629
  }] : []);
2607
2630
  if (this.sectionIndexEntries.length === 0 || viewportHeight < SECTION_INDEX_MIN_HEIGHT) {
2631
+ this.configureSectionIndexRail(viewportHeight, stickyInset, false);
2632
+ setData(this.viewportFrame, 'sectionIndexVisible', false);
2608
2633
  this.indexRail.hidden = true;
2609
2634
  return;
2610
2635
  }
2611
- const visibleEntryIndices = this.sectionIndexVisibleEntryIndices(viewportHeight);
2636
+ const windowCentered = this.snapshot.capabilities?.sectionIndex?.centeredInWindow === true && this.viewport.clientHeight > 0;
2637
+ const indexLayoutHeight = this.configureSectionIndexRail(viewportHeight, stickyInset, windowCentered);
2638
+ const visibleEntryIndices = this.sectionIndexVisibleEntryIndices(indexLayoutHeight);
2612
2639
  setData(this.indexRail, 'compact', visibleEntryIndices.length < this.sectionIndexEntries.length);
2613
- const metrics = this.sectionIndexMetrics(viewportHeight);
2640
+ const metrics = this.sectionIndexMetrics(indexLayoutHeight);
2614
2641
  const visibleTrackHeight = Math.min(metrics.trackHeight, SECTION_INDEX_LABEL_SPACING * visibleEntryIndices.length);
2615
2642
  const visibleOriginY = metrics.originY + (metrics.trackHeight - visibleTrackHeight) / 2;
2616
2643
  const fragment = this.document.createDocumentFragment();
@@ -2628,6 +2655,7 @@ export class NativeListWebEngine {
2628
2655
  });
2629
2656
  this.indexRail.appendChild(fragment);
2630
2657
  this.indexRail.hidden = this.indexRail.childElementCount === 0;
2658
+ setData(this.viewportFrame, 'sectionIndexVisible', !this.indexRail.hidden);
2631
2659
  }
2632
2660
  updateVisibleSelection() {
2633
2661
  const update = (element, row) => {
@@ -154,6 +154,7 @@ export declare class NativeListWebEngine {
154
154
  private renderFooter;
155
155
  private sectionIndexVisibleEntryIndices;
156
156
  private sectionIndexMetrics;
157
+ private configureSectionIndexRail;
157
158
  private renderSectionIndex;
158
159
  private updateVisibleSelection;
159
160
  private updateVisibleState;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-native-list",
3
- "version": "3.0.122",
3
+ "version": "3.0.124",
4
4
  "description": "Template-driven native RecyclerView and UICollectionView for React Native",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/module/index.js",
@@ -38,7 +38,7 @@
38
38
  "nitrogen": "nitrogen",
39
39
  "typecheck": "tsc -b",
40
40
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
41
- "test": "jest --runInBand",
41
+ "test": "jest --runInBand && node --test src/__tests__/selector-parity.cjs",
42
42
  "release": "yarn prepare && npm whoami && npm publish --access public"
43
43
  },
44
44
  "keywords": [
@@ -83,8 +83,8 @@
83
83
  "typescript": "^5.9.2"
84
84
  },
85
85
  "peerDependencies": {
86
- "@onekeyfe/react-native-image": "3.0.122",
87
- "@onekeyfe/react-native-native-logger": "3.0.122",
86
+ "@onekeyfe/react-native-image": "3.0.124",
87
+ "@onekeyfe/react-native-native-logger": "3.0.124",
88
88
  "react": "*",
89
89
  "react-native": "*",
90
90
  "react-native-nitro-modules": "0.37.0"
@@ -51,6 +51,7 @@ const SECTION_INDEX_RAIL_WIDTH = 32;
51
51
  const SECTION_INDEX_EDGE_PADDING = 8;
52
52
  const SECTION_INDEX_LABEL_SPACING = 16;
53
53
  const SECTION_INDEX_MIN_HEIGHT = 120;
54
+ const SECTION_INDEX_WINDOW_Z_INDEX = 100_000;
54
55
  const DEFAULT_VIEWPORT_WIDTH = 320;
55
56
  const DEFAULT_VIEWPORT_HEIGHT = 640;
56
57
  const OVERSCAN_VIEWPORTS = 1;
@@ -885,12 +886,12 @@ export const WEB_LIST_CSS = `
885
886
  .ok-native-list-item[data-native-list-reorderable="true"]>.ok-native-list-row{cursor:grab}
886
887
  .ok-native-list-root[data-native-list-dragging="true"] .ok-native-list-row{cursor:grabbing}
887
888
  .ok-native-list-item[data-native-list-animate-reorder="true"]{transition:transform ${WEB_REORDER_ANIMATION.outOfWayDurationMs}ms ${WEB_REORDER_ANIMATION.outOfWayTimingFunction},height ${WEB_REORDER_ANIMATION.outOfWayDurationMs}ms ${WEB_REORDER_ANIMATION.outOfWayTimingFunction}}
888
- .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-row{overflow:hidden;border-radius:12px;background:var(--nl-row)}
889
+ .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-row{overflow:hidden;border-radius:12px;background:var(--nl-pressed)}
889
890
  .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-row>*{visibility:hidden}
890
- .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-wallet-group{border-color:transparent;background:var(--nl-row)}
891
+ .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-wallet-group{border-color:transparent;background:var(--nl-pressed)}
891
892
  .ok-native-list-item[data-native-list-dragging="true"]>.ok-native-list-wallet-group>*{visibility:hidden}
892
893
  .ok-native-list-reorder-preview{position:fixed;left:0;top:0;z-index:100001;pointer-events:none;overflow:hidden;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.12);transform-origin:center;will-change:transform;font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
893
- .ok-native-list-reorder-preview>.ok-native-list-row{background:var(--nl-row);cursor:grabbing}
894
+ .ok-native-list-reorder-preview>.ok-native-list-row{background:var(--nl-pressed);cursor:grabbing}
894
895
  .ok-native-list-reorder-preview[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)}
895
896
  .ok-native-list-reorder-count{position:absolute;right:4px;bottom:4px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:24px;height:24px;padding:0 6px;border:1px solid var(--nl-row);border-radius:12px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;line-height:22px;font-weight:600}
896
897
  .ok-native-list-item[data-separator="true"]>.ok-native-list-row{border-bottom:1px solid var(--nl-separator)}
@@ -922,7 +923,7 @@ export const WEB_LIST_CSS = `
922
923
  .ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain}
923
924
  .ok-native-list-row.ok-native-list-market-skeleton{padding:12px 20px;gap:0}.ok-native-list-skeleton-left{display:flex;align-items:center;gap:12px;flex:1}.ok-native-list-skeleton-text{display:flex;flex-direction:column;gap:4px}.ok-native-list-skeleton-right{display:flex;align-items:center;gap:8px}.ok-native-list-skeleton-mark{display:block;flex-shrink:0;border-radius:8px;animation:ok-native-list-skeleton 1.5s linear infinite alternate}@keyframes ok-native-list-skeleton{from{background-color:var(--nl-skeleton-base)}to{background-color:var(--nl-skeleton-highlight)}}.ok-native-list-market-spinner{display:block;width:20px;height:20px;flex-shrink:0;color:var(--nl-icon);animation:ok-native-list-spin .75s linear infinite}
924
925
  @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner,.ok-native-list-market-spinner{animation:none}.ok-native-list-skeleton-mark{animation:none;background:var(--nl-skeleton-base)}}
925
- .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport{scrollbar-width:none}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport::-webkit-scrollbar{display:none}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:15px;display:flex;width:14px;height:14px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:7px;background:transparent;color:var(--nl-disabled);font-family:inherit;font-size:10px;font-weight:400;line-height:1;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-positive);color:var(--nl-inverse-text);font-weight:500}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-positive);outline-offset:1px}
926
+ .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport,.ok-native-list-viewport-frame[data-section-index-visible="true"]>.ok-native-list-viewport{scrollbar-width:none}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport::-webkit-scrollbar,.ok-native-list-viewport-frame[data-section-index-visible="true"]>.ok-native-list-viewport::-webkit-scrollbar{display:none}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer;font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:15px;display:flex;width:14px;height:14px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:7px;background:transparent;color:var(--nl-disabled);font-family:inherit;font-size:10px;font-weight:400;line-height:1;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-positive);color:var(--nl-inverse-text);font-weight:500}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-positive);outline-offset:1px}
926
927
  .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)}
927
928
  .ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)}
928
929
  .ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain}
@@ -3887,6 +3888,7 @@ export class NativeListWebEngine {
3887
3888
  disposeWebImageRetries(this.root);
3888
3889
  this.pool.forEach(disposeWebImageRetries);
3889
3890
  this.root.remove();
3891
+ this.indexRail.remove();
3890
3892
  this.hideReorderPreview();
3891
3893
  this.reorderPreview.remove();
3892
3894
  if (host) host.style.position = this.previousHostPosition;
@@ -3945,6 +3947,7 @@ export class NativeListWebEngine {
3945
3947
  Object.entries(values).forEach(([name, value]) => {
3946
3948
  if (!value) return;
3947
3949
  this.root.style.setProperty(name, value);
3950
+ this.indexRail.style.setProperty(name, value);
3948
3951
  this.reorderPreview.style.setProperty(name, value);
3949
3952
  });
3950
3953
  }
@@ -3956,14 +3959,15 @@ export class NativeListWebEngine {
3956
3959
  this.snapshot.layout.orientation === 'horizontal'
3957
3960
  ? this.viewport.clientHeight
3958
3961
  : this.verticalScrollMetrics().viewportLength;
3962
+ const stickyInset =
3963
+ this.snapshot.layout.orientation === 'horizontal'
3964
+ ? 0
3965
+ : this.collapsiblePagerInsets().sticky;
3959
3966
  if (this.snapshot.layout.orientation !== 'horizontal') {
3960
- const stickyInset = this.collapsiblePagerInsets().sticky;
3961
3967
  this.sticky.style.top = String(stickyInset) + 'px';
3962
- this.indexRail.style.top = String(stickyInset) + 'px';
3963
3968
  this.refreshIndicator.style.top = String(stickyInset + 8) + 'px';
3964
3969
  } else {
3965
3970
  this.sticky.style.top = '0px';
3966
- this.indexRail.style.top = '0px';
3967
3971
  this.refreshIndicator.style.top = '8px';
3968
3972
  }
3969
3973
  if (
@@ -3999,7 +4003,8 @@ export class NativeListWebEngine {
3999
4003
  this.renderSectionIndex(
4000
4004
  this.viewport.clientHeight <= 0
4001
4005
  ? DEFAULT_VIEWPORT_HEIGHT
4002
- : Math.max(1, viewportHeight)
4006
+ : Math.max(1, viewportHeight),
4007
+ stickyInset
4003
4008
  );
4004
4009
  if (previousHorizontal !== this.layout.horizontal) {
4005
4010
  this.viewport.scrollLeft = 0;
@@ -4333,37 +4338,75 @@ export class NativeListWebEngine {
4333
4338
  availableHeight,
4334
4339
  SECTION_INDEX_LABEL_SPACING * this.sectionIndexEntries.length
4335
4340
  );
4336
- const centeredOriginY = (viewportHeight - trackHeight) / 2;
4337
- if (!this.snapshot.capabilities?.sectionIndex?.centeredInWindow) {
4338
- return { originY: centeredOriginY, trackHeight };
4339
- }
4340
- const view = this.document.defaultView;
4341
- if (!view) return { originY: centeredOriginY, trackHeight };
4342
- const visualViewport = view.visualViewport;
4343
- const windowCenterY = visualViewport
4344
- ? visualViewport.offsetTop + visualViewport.height / 2
4345
- : view.innerHeight / 2;
4346
- const railTop =
4347
- this.viewportFrame.getBoundingClientRect().top +
4348
- (Number.parseFloat(this.indexRail.style.top) || 0);
4349
- const minOriginY = SECTION_INDEX_EDGE_PADDING;
4350
- const maxOriginY = Math.max(
4351
- minOriginY,
4352
- viewportHeight - SECTION_INDEX_EDGE_PADDING - trackHeight
4353
- );
4354
4341
  return {
4355
- originY: Math.min(
4356
- maxOriginY,
4357
- Math.max(minOriginY, windowCenterY - railTop - trackHeight / 2)
4358
- ),
4342
+ originY: (viewportHeight - trackHeight) / 2,
4359
4343
  trackHeight,
4360
4344
  };
4361
4345
  }
4362
4346
 
4363
- private renderSectionIndex(viewportHeight: number) {
4347
+ private configureSectionIndexRail(
4348
+ viewportHeight: number,
4349
+ stickyInset: number,
4350
+ windowCentered: boolean
4351
+ ): number {
4352
+ const view = this.document.defaultView;
4353
+ const direction =
4354
+ view?.getComputedStyle(this.viewportFrame).direction === 'rtl'
4355
+ ? 'rtl'
4356
+ : 'ltr';
4357
+ this.indexRail.style.direction = direction;
4358
+ if (!windowCentered) {
4359
+ if (this.indexRail.parentElement !== this.viewportFrame) {
4360
+ this.viewportFrame.appendChild(this.indexRail);
4361
+ }
4362
+ this.indexRail.style.removeProperty('position');
4363
+ this.indexRail.style.removeProperty('left');
4364
+ this.indexRail.style.removeProperty('right');
4365
+ this.indexRail.style.removeProperty('height');
4366
+ this.indexRail.style.removeProperty('z-index');
4367
+ this.indexRail.style.insetInlineEnd = '0px';
4368
+ this.indexRail.style.top = String(stickyInset) + 'px';
4369
+ this.indexRail.style.bottom = '0px';
4370
+ return viewportHeight;
4371
+ }
4372
+
4373
+ if (!view) return viewportHeight;
4374
+ const visualViewport = view.visualViewport;
4375
+ const windowTop = visualViewport?.offsetTop ?? 0;
4376
+ const windowHeight = visualViewport?.height ?? view.innerHeight;
4377
+ const railHeight = Math.min(
4378
+ windowHeight,
4379
+ SECTION_INDEX_EDGE_PADDING * 2 +
4380
+ SECTION_INDEX_LABEL_SPACING * this.sectionIndexEntries.length
4381
+ );
4382
+ const frame = this.viewportFrame.getBoundingClientRect();
4383
+ if (this.indexRail.parentElement !== this.document.body) {
4384
+ this.document.body.appendChild(this.indexRail);
4385
+ }
4386
+ this.indexRail.style.position = 'fixed';
4387
+ this.indexRail.style.removeProperty('left');
4388
+ this.indexRail.style.removeProperty('right');
4389
+ this.indexRail.style.insetInlineEnd =
4390
+ String(
4391
+ Math.max(
4392
+ 0,
4393
+ direction === 'rtl' ? frame.left : view.innerWidth - frame.right
4394
+ )
4395
+ ) + 'px';
4396
+ this.indexRail.style.top =
4397
+ String(windowTop + (windowHeight - railHeight) / 2) + 'px';
4398
+ this.indexRail.style.bottom = 'auto';
4399
+ this.indexRail.style.height = String(railHeight) + 'px';
4400
+ this.indexRail.style.zIndex = String(SECTION_INDEX_WINDOW_Z_INDEX);
4401
+ return railHeight;
4402
+ }
4403
+
4404
+ private renderSectionIndex(viewportHeight: number, stickyInset: number) {
4364
4405
  this.indexRail.replaceChildren();
4365
4406
  if (!sectionIndexEnabled(this.snapshot)) {
4366
4407
  this.sectionIndexEntries = [];
4408
+ this.configureSectionIndexRail(viewportHeight, stickyInset, false);
4409
+ setData(this.viewportFrame, 'sectionIndexVisible', false);
4367
4410
  this.indexRail.hidden = true;
4368
4411
  return;
4369
4412
  }
@@ -4376,17 +4419,27 @@ export class NativeListWebEngine {
4376
4419
  this.sectionIndexEntries.length === 0 ||
4377
4420
  viewportHeight < SECTION_INDEX_MIN_HEIGHT
4378
4421
  ) {
4422
+ this.configureSectionIndexRail(viewportHeight, stickyInset, false);
4423
+ setData(this.viewportFrame, 'sectionIndexVisible', false);
4379
4424
  this.indexRail.hidden = true;
4380
4425
  return;
4381
4426
  }
4427
+ const windowCentered =
4428
+ this.snapshot.capabilities?.sectionIndex?.centeredInWindow === true &&
4429
+ this.viewport.clientHeight > 0;
4430
+ const indexLayoutHeight = this.configureSectionIndexRail(
4431
+ viewportHeight,
4432
+ stickyInset,
4433
+ windowCentered
4434
+ );
4382
4435
  const visibleEntryIndices =
4383
- this.sectionIndexVisibleEntryIndices(viewportHeight);
4436
+ this.sectionIndexVisibleEntryIndices(indexLayoutHeight);
4384
4437
  setData(
4385
4438
  this.indexRail,
4386
4439
  'compact',
4387
4440
  visibleEntryIndices.length < this.sectionIndexEntries.length
4388
4441
  );
4389
- const metrics = this.sectionIndexMetrics(viewportHeight);
4442
+ const metrics = this.sectionIndexMetrics(indexLayoutHeight);
4390
4443
  const visibleTrackHeight = Math.min(
4391
4444
  metrics.trackHeight,
4392
4445
  SECTION_INDEX_LABEL_SPACING * visibleEntryIndices.length
@@ -4418,6 +4471,7 @@ export class NativeListWebEngine {
4418
4471
  });
4419
4472
  this.indexRail.appendChild(fragment);
4420
4473
  this.indexRail.hidden = this.indexRail.childElementCount === 0;
4474
+ setData(this.viewportFrame, 'sectionIndexVisible', !this.indexRail.hidden);
4421
4475
  }
4422
4476
 
4423
4477
  private updateVisibleSelection() {