@octane-xplat/ui 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/CommandPalette.tsrx.js +7 -1
  4. package/dist/native/Grid.tsrx.js +31 -2
  5. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  6. package/dist/native/Markdown.tsrx.js +233 -0
  7. package/dist/native/Overlay.tsrx.js +15 -26
  8. package/dist/native/Pagination.tsrx.js +15 -12
  9. package/dist/native/PinInput.tsrx.js +25 -7
  10. package/dist/native/Popover.tsrx.js +33 -23
  11. package/dist/native/Pressable.tsrx.js +4 -1
  12. package/dist/native/Screen.tsrx.js +1 -1
  13. package/dist/native/SearchInput.tsrx.js +6 -3
  14. package/dist/native/Sheet.tsrx.js +23 -38
  15. package/dist/native/Slider.tsrx.js +23 -14
  16. package/dist/native/Text.tsrx.js +16 -0
  17. package/dist/native/TextArea.tsrx.js +6 -3
  18. package/dist/native/TextInput.tsrx.js +6 -3
  19. package/dist/native/VirtualList.tsrx.js +305 -121
  20. package/dist/native/WebView.tsrx.js +17 -2
  21. package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
  22. package/dist/native/escape-props.js +2 -0
  23. package/dist/native/grid-auto-placement.js +83 -0
  24. package/dist/native/grid-placement.js +20 -0
  25. package/dist/native/index.shared.js +7 -5
  26. package/dist/native/keyboard-inset.mobile.js +5 -5
  27. package/dist/native/navigation-request.js +26 -0
  28. package/dist/native/octane-client-build.json +1 -1
  29. package/dist/native/overlay-lifetime.mobile.js +65 -0
  30. package/dist/native/pan.tsrx.js +16 -2
  31. package/dist/native/route-table.js +90 -10
  32. package/dist/native/route.js +26 -16
  33. package/dist/native/shared.js +7 -5
  34. package/dist/native/sheet-service.js +2 -2
  35. package/dist/native/svg.mobile.js +1 -1
  36. package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
  37. package/dist/native/text-focus.js +10 -0
  38. package/dist/native/text-write.js +18 -11
  39. package/dist/native/toast-anchor.tsrx.js +1 -1
  40. package/dist/native/ui.js +7 -6
  41. package/dist/native/useMeasure.tsrx.js +17 -18
  42. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  43. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  44. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  45. package/dist/native/virtual-list-cell-pool.js +63 -0
  46. package/dist/web/Button.web.tsrx.js +1 -1
  47. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  48. package/dist/web/Markdown.tsrx.js +98 -0
  49. package/dist/web/Overlay.web.tsrx.js +81 -32
  50. package/dist/web/PinInput.web.tsrx.js +24 -10
  51. package/dist/web/Pressable.web.tsrx.js +169 -53
  52. package/dist/web/SearchInput.web.tsrx.js +2 -2
  53. package/dist/web/Sheet.web.tsrx.js +20 -7
  54. package/dist/web/TextArea.web.tsrx.js +22 -17
  55. package/dist/web/TextInput.web.tsrx.js +2 -2
  56. package/dist/web/VirtualList.web.tsrx.js +220 -109
  57. package/dist/web/WebView.web.tsrx.js +3 -3
  58. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  59. package/dist/web/modal-focus.web.js +95 -0
  60. package/dist/web/navigation-request.js +26 -0
  61. package/dist/web/octane-client-build.json +1 -1
  62. package/dist/web/pan.web.tsrx.js +27 -10
  63. package/dist/web/route-table.js +90 -10
  64. package/dist/web/route.web.js +88 -22
  65. package/dist/web/sheet-service.web.js +8 -0
  66. package/dist/web/theme/colorScheme.web.js +31 -4
  67. package/dist/web/ui.js +8 -6
  68. package/dist/web/virtual-list-cell-pool.js +63 -0
  69. package/package.json +21 -19
  70. package/platforms/android/native-api-usage.json +7 -7
  71. package/src/CommandPalette.tsrx +8 -1
  72. package/src/Grid.tsrx +32 -1
  73. package/src/Icon.macos.tsrx +1 -0
  74. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  75. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  76. package/src/KeyboardAvoiding.tsrx +15 -0
  77. package/src/KeyboardAvoiding.web.tsrx +15 -0
  78. package/src/Markdown.tsrx +121 -0
  79. package/src/Meter.tsrx +1 -0
  80. package/src/Overlay.tsrx +15 -30
  81. package/src/Overlay.web.tsrx +12 -3
  82. package/src/Pagination.tsrx +6 -4
  83. package/src/PinInput.macos.tsrx +20 -6
  84. package/src/PinInput.mobile.test.ts +107 -0
  85. package/src/PinInput.tsrx +20 -6
  86. package/src/PinInput.web.test.tsrx +42 -0
  87. package/src/PinInput.web.tsrx +20 -6
  88. package/src/Popover.tsrx +36 -22
  89. package/src/Pressable.tsrx +1 -1
  90. package/src/Pressable.web.test.tsrx +36 -0
  91. package/src/Pressable.web.tsrx +37 -6
  92. package/src/RouteHost.macos.tsrx +1 -0
  93. package/src/Screen.tsrx +1 -1
  94. package/src/SearchInput.tsrx +4 -3
  95. package/src/SearchInput.web.tsrx +1 -1
  96. package/src/Sheet.tsrx +21 -42
  97. package/src/Sheet.web.tsrx +9 -1
  98. package/src/Slider.tsrx +13 -11
  99. package/src/Text.tsrx +36 -0
  100. package/src/TextArea.tsrx +6 -5
  101. package/src/TextArea.web.test.tsrx +39 -0
  102. package/src/TextArea.web.tsrx +7 -5
  103. package/src/TextInput.tsrx +6 -6
  104. package/src/TextInput.web.tsrx +1 -1
  105. package/src/VirtualList.macos.tsrx +93 -6
  106. package/src/VirtualList.mobile.test.tsrx +232 -0
  107. package/src/VirtualList.tsrx +285 -81
  108. package/src/VirtualList.web.tsrx +131 -25
  109. package/src/WebView.tsrx +20 -10
  110. package/src/WebView.web.tsrx +3 -0
  111. package/src/android/BottomNavigationView.android.tsrx +1 -0
  112. package/src/android/MaterialDialog.android.tsrx +1 -0
  113. package/src/anim.macos.tsrx +1 -0
  114. package/src/apply-native-modifiers.native.ts +4 -1
  115. package/src/child-array.ts +12 -7
  116. package/src/colorScheme.macos.ts +1 -0
  117. package/src/controls.macos.tsrx +4 -1
  118. package/src/data-driver.mobile.test.ts +120 -0
  119. package/src/data-lifecycle.mobile.test.ts +251 -0
  120. package/src/escape-props.ts +13 -1
  121. package/src/grid-auto-placement.ts +163 -0
  122. package/src/grid-placement.ts +29 -0
  123. package/src/hosted-unsupported.macos.tsrx +1 -0
  124. package/src/index.macos.ts +23 -3
  125. package/src/index.mobile.ts +0 -1
  126. package/src/index.shared.ts +22 -1
  127. package/src/index.shared.web.ts +291 -0
  128. package/src/index.web.ts +1 -1
  129. package/src/input-readiness.web.test.tsrx +82 -0
  130. package/src/ios/UITabBar.ios.tsrx +1 -0
  131. package/src/keyboard-inset.mobile.test.ts +62 -0
  132. package/src/keyboard-inset.mobile.ts +63 -43
  133. package/src/layout-shells.macos.tsrx +1 -5
  134. package/src/macos-extras.macos.tsrx +2 -0
  135. package/src/modal-focus.web.test.tsrx +63 -0
  136. package/src/modal-focus.web.ts +162 -0
  137. package/src/navigation-request.ts +37 -0
  138. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  139. package/src/overlay-lifetime.mobile.ts +107 -0
  140. package/src/pan.mobile.test.tsrx +116 -0
  141. package/src/pan.tsrx +20 -2
  142. package/src/pan.web.tsrx +35 -7
  143. package/src/platformProps.ts +1 -0
  144. package/src/popover.mobile.test.ts +9 -1
  145. package/src/pressable-accessibility.mobile.test.ts +30 -0
  146. package/src/props.ts +163 -3
  147. package/src/refresh.ts +6 -1
  148. package/src/refresh.web.test.ts +77 -77
  149. package/src/route-android.test.ts +162 -0
  150. package/src/route-history.web.test.ts +80 -0
  151. package/src/route-navigation.web.test.ts +173 -0
  152. package/src/route-table.test.ts +188 -9
  153. package/src/route-table.ts +138 -10
  154. package/src/route.macos.ts +56 -13
  155. package/src/route.ts +54 -24
  156. package/src/route.web.ts +141 -24
  157. package/src/sheet-detents.ts +5 -3
  158. package/src/sheet-service.macos.ts +5 -1
  159. package/src/sheet-service.ts +1 -1
  160. package/src/sheet-service.web.ts +8 -0
  161. package/src/sheet-snap.test.ts +23 -23
  162. package/src/sheet-snap.ts +6 -1
  163. package/src/svg.mobile.ts +9 -3
  164. package/src/tap-to-blur.ios.ts +106 -0
  165. package/src/tap-to-blur.mobile.ts +21 -95
  166. package/src/text-focus.ts +12 -0
  167. package/src/text-input.mobile.test.ts +89 -0
  168. package/src/text-write.ts +21 -11
  169. package/src/theme/colorScheme.linux.ts +2 -4
  170. package/src/theme/colorScheme.web.ts +51 -4
  171. package/src/theme/theme-scheme.macos.ts +8 -2
  172. package/src/theme/tokens.css +1740 -1718
  173. package/src/translate.macos.ts +4 -1
  174. package/src/useMeasure.tsrx +20 -21
  175. package/src/virtual-list-cell-pool.test.ts +53 -0
  176. package/src/virtual-list-cell-pool.ts +81 -0
  177. package/src/virtual-list-layout.test.ts +11 -8
  178. package/src/virtual-list-layout.ts +4 -8
  179. package/src/windows.linux.ts +7 -2
  180. package/src/windows.macos.ts +1 -0
  181. package/types/generated/.tsrx-typegen-manifest.json +16 -3
  182. package/types/generated/Grid.d.ts +0 -1
  183. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  184. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  185. package/types/generated/Markdown.d.ts +59 -0
  186. package/types/generated/TextArea.d.ts +2 -2
  187. package/types/generated/TextArea.web.d.ts +1 -1
  188. package/types/generated/TextInput.d.ts +2 -3
  189. package/types/generated/VirtualList.d.ts +3 -3
  190. package/types/generated/escape-props.d.ts +4 -0
  191. package/types/generated/grid-auto-placement.d.ts +13 -0
  192. package/types/generated/grid-placement.d.ts +5 -0
  193. package/types/generated/index.mobile.d.ts +0 -1
  194. package/types/generated/index.shared.d.ts +6 -3
  195. package/types/generated/index.shared.web.d.ts +114 -0
  196. package/types/generated/index.web.d.ts +1 -1
  197. package/types/generated/modal-focus.web.d.ts +3 -0
  198. package/types/generated/navigation-request.d.ts +10 -0
  199. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  200. package/types/generated/props.d.ts +111 -3
  201. package/types/generated/route-table.d.ts +14 -3
  202. package/types/generated/svg.mobile.d.ts +2 -2
  203. package/types/generated/tap-to-blur.ios.d.ts +13 -0
  204. package/types/generated/tap-to-blur.mobile.d.ts +1 -15
  205. package/types/generated/text-focus.d.ts +3 -0
  206. package/types/generated/text-write.d.ts +3 -1
  207. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  208. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  209. package/types/index.macos.d.ts +15 -3
  210. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  211. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  212. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  213. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  214. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  215. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  216. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  217. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  218. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  219. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
- import { useMemo, useRef, useState } from 'octane'
2
+ import { useEffect, useMemo, useRef, useState } from 'octane'
3
3
  import type { VirtualListProps } from './props'
4
4
  import {
5
5
  createVirtualListEntries,
@@ -7,9 +7,14 @@ import {
7
7
  virtualListMeasurementKey,
8
8
  virtualListRange,
9
9
  VirtualListSizeIndex,
10
+ VIRTUAL_LIST_ESTIMATED_ROW_SIZE,
11
+ VIRTUAL_LIST_MIN_TYPE_SAMPLES,
10
12
  type VirtualListEntry,
11
13
  type VirtualListSizeRecord,
12
14
  } from './virtual-list-layout'
15
+
16
+ import { VirtualListCellPool, type VirtualListCell } from './virtual-list-cell-pool'
17
+
13
18
  import { cx } from './cx'
14
19
  import { applyEscapeProps, nativeAccessibilityRole, nativeAccessibilityState } from './escape-props'
15
20
  import { layoutChildProps } from './layout-child'
@@ -18,40 +23,84 @@ function sameSize(a: number, b: number) {
18
23
  return Math.abs(a - b) < 0.5
19
24
  }
20
25
 
26
+ function scheduleListFrame(callback: () => void): () => void {
27
+ const requestFrame = (globalThis as any).requestAnimationFrame
28
+ const cancelFrame = (globalThis as any).cancelAnimationFrame
29
+ if (typeof requestFrame !== 'function' || typeof cancelFrame !== 'function') {
30
+ const timer = setTimeout(callback, 16)
31
+ return () => clearTimeout(timer)
32
+ }
33
+
34
+ let cancelled = false
35
+ let current: number | null = null
36
+ let next: number | null = null
37
+ // NativeScript dispatches an initial rAF through a macrotask. A nested
38
+ // request joins Choreographer/CADisplayLink rather than a free-running timer.
39
+ current = requestFrame(() => {
40
+ current = null
41
+ if (cancelled) return
42
+ next = requestFrame(() => {
43
+ next = null
44
+ if (!cancelled) callback()
45
+ })
46
+ })
47
+
48
+ return () => {
49
+ cancelled = true
50
+ if (current != null) cancelFrame(current)
51
+ if (next != null) cancelFrame(next)
52
+ }
53
+ }
54
+
21
55
  interface VirtualListAnchor {
22
56
  rowKey: string
23
57
  index: number
24
58
  within: number
25
59
  }
26
60
 
27
- /** Shared vertical virtualization over NativeScript ScrollView. Off-window
28
- * rows unmount; measured row-height changes above the visible anchor adjust
29
- * the native scroll offset after layout. */
61
+ /** Shared vertical virtualization with pooled outer hosts and keyed logical
62
+ * rows. Off-window item subtrees unmount; measured heights position cells and
63
+ * preserve the visible keyed anchor after layout. */
30
64
  export function VirtualList<T = any>(props: VirtualListProps<T>) @{
31
65
  const entries = useMemo(
32
66
  () => createVirtualListEntries(props.items, props.keyExtractor, props.getItemType),
33
67
  [props.items, props.keyExtractor, props.getItemType],
34
68
  )
69
+
35
70
  const [windowVersion, setWindowVersion] = useState(0)
36
71
  const sizeIndexRef = useRef(new VirtualListSizeIndex())
72
+ const cellsRef = useRef(new VirtualListCellPool<T>())
73
+ const cellNodesRef = useRef(new Map<number, any>())
74
+ const cellCallbacksRef = useRef(new Map<number, (view: any) => void>())
75
+ const measuredAssignmentsRef = useRef(new Map<number, number>())
37
76
  const sizeCacheRef = useRef(new Map<string, VirtualListSizeRecord>())
77
+ const typeSamplesRef = useRef(new Map<string, { sum: number; count: number }>())
78
+ const appliedEstimatesRef = useRef(new Map<string, number>())
79
+ const estimateDirtyTypesRef = useRef(new Set<string>())
38
80
  const entriesRef = useRef<VirtualListEntry<T>[]>([])
39
81
  const indexByRowKeyRef = useRef(new Map<string, number>())
40
82
  const scrollRef = useRef<any>(null)
41
83
  const scrollOffsetRef = useRef(0)
42
84
  const viewportHeightRef = useRef(0)
43
85
  const viewportWidthRef = useRef(0)
86
+ const contentWidthRef = useRef(0)
44
87
  const headerHeightRef = useRef(0)
45
- const estimateScheduledRef = useRef(false)
46
- const estimateAnchorRef = useRef<VirtualListAnchor | null>(null)
88
+ const estimateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
89
+ const frameCancelRef = useRef<(() => void) | null>(null)
90
+ const disposedRef = useRef(false)
91
+ const pendingOffsetDeltaRef = useRef(0)
92
+ const measurementsRef = useRef(
93
+ new Map<string, { entry: VirtualListEntry<T>; width: number; height: number; cellId: number; generation: number; view?: any }>(),
94
+ )
95
+
47
96
  const rangeRef = useRef({ start: -1, end: -1, top: -1, bottom: -1 })
48
97
  const pendingAnchorRef = useRef<VirtualListAnchor | null>(null)
49
98
  const pendingAnchorRowsRef = useRef(new Set<string>())
50
- const queuedAnchorRestoreRef = useRef<VirtualListAnchor | null>(null)
51
99
  const anchorRestoreScheduledRef = useRef(false)
52
100
  const anchorRestoreTimerRef = useRef<any>(null)
53
101
  const hasSeparator = Boolean(props.renderSeparator)
54
102
  const hasSeparatorRef = useRef(hasSeparator)
103
+ const separatorChanged = hasSeparatorRef.current !== hasSeparator
55
104
  hasSeparatorRef.current = hasSeparator
56
105
 
57
106
  function rebuildLayout(nextEntries: VirtualListEntry<T>[], width: number) {
@@ -65,6 +114,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
65
114
  entry.rowKey,
66
115
  hasSeparatorRef.current && entry.index < nextEntries.length - 1,
67
116
  )
117
+
68
118
  activeMeasurements.add(cacheKey)
69
119
  }
70
120
 
@@ -72,13 +122,35 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
72
122
  if (!activeMeasurements.has(cacheKey)) sizeCacheRef.current.delete(cacheKey)
73
123
  }
74
124
 
125
+ const samples = new Map<string, { sum: number; count: number }>()
126
+ for (const entry of nextEntries) {
127
+ const cached = sizeCacheRef.current.get(virtualListMeasurementKey(
128
+ entry.rowKey, hasSeparatorRef.current && entry.index < nextEntries.length - 1,
129
+ ))
130
+
131
+ if (!cached) continue
132
+ const sample = samples.get(entry.typeKey) ?? { sum: 0, count: 0 }
133
+ sample.sum += cached.height
134
+ sample.count++
135
+ samples.set(entry.typeKey, sample)
136
+ }
137
+
138
+ typeSamplesRef.current = samples
139
+ estimateDirtyTypesRef.current.clear()
140
+ appliedEstimatesRef.current = new Map([...samples].map(([type, sample]) => [
141
+ type, sample.count >= VIRTUAL_LIST_MIN_TYPE_SAMPLES
142
+ ? sample.sum / sample.count : VIRTUAL_LIST_ESTIMATED_ROW_SIZE,
143
+ ]))
144
+
75
145
  indexByRowKeyRef.current = indexByKey
76
146
  sizeIndexRef.current.reset(
77
- estimateVirtualListSizes(nextEntries, hasSeparatorRef.current, width, sizeCacheRef.current),
147
+ estimateVirtualListSizes(
148
+ nextEntries, hasSeparatorRef.current, contentWidthRef.current || width, sizeCacheRef.current,
149
+ ),
78
150
  )
79
151
  }
80
152
 
81
- if (entries !== entriesRef.current) {
153
+ if (entries !== entriesRef.current || separatorChanged) {
82
154
  const anchor = captureCurrentAnchor()
83
155
  const previousByKey = new Map(entriesRef.current.map((entry) => [entry.rowKey, entry]))
84
156
  const changedRows = entries.filter((entry) => previousByKey.get(entry.rowKey)?.item !== entry.item)
@@ -96,6 +168,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
96
168
  )
97
169
  .map((entry) => entry.rowKey),
98
170
  )
171
+
99
172
  pendingAnchorRef.current = pendingRows.size > 0 ? anchor : null
100
173
  pendingAnchorRowsRef.current = pendingRows
101
174
 
@@ -112,7 +185,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
112
185
  )
113
186
  }
114
187
 
115
- function refreshWindow() {
188
+ function commitWindow() {
116
189
  const next = currentRange()
117
190
  const previous = rangeRef.current
118
191
  if (
@@ -126,6 +199,34 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
126
199
  }
127
200
  }
128
201
 
202
+ function refreshWindow() {
203
+ if (disposedRef.current || frameCancelRef.current != null) return
204
+ frameCancelRef.current = scheduleListFrame(() => {
205
+ frameCancelRef.current = null
206
+ if (disposedRef.current) return
207
+ const measurements = [...measurementsRef.current.values()]
208
+ measurementsRef.current.clear()
209
+ for (const measurement of measurements) {
210
+ if (!cellsRef.current.isCurrent(measurement.cellId, measurement.entry.rowKey, measurement.generation)) continue
211
+ const index = indexByRowKeyRef.current.get(measurement.entry.rowKey)
212
+ if (index === undefined || entriesRef.current[index]?.item !== measurement.entry.item ||
213
+ entriesRef.current[index]?.index !== measurement.entry.index) continue
214
+
215
+ if (measurement.view?.isLoaded === false) continue
216
+ if (measurement.view?.isLayoutValid === false) {
217
+ measurementsRef.current.set(measurement.entry.rowKey, measurement)
218
+ refreshWindow()
219
+ continue
220
+ }
221
+
222
+ const size = measurement.view?.getActualSize?.() ?? measurement
223
+ if (size.width > 0 && size.height >= 0) recordRowSize(measurement.entry.rowKey, size.width, size.height)
224
+ }
225
+
226
+ commitWindow()
227
+ })
228
+ }
229
+
129
230
  function captureAnchor(): VirtualListAnchor | null {
130
231
  const listOffset = scrollOffsetRef.current - headerHeightRef.current
131
232
  if (listOffset < 0 || entriesRef.current.length === 0) return null
@@ -142,50 +243,44 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
142
243
 
143
244
  function captureCurrentAnchor() {
144
245
  const nativeOffset = Number(scrollRef.current?.verticalOffset)
145
- if (Number.isFinite(nativeOffset)) scrollOffsetRef.current = nativeOffset
246
+ if (Number.isFinite(nativeOffset)) scrollOffsetRef.current = nativeOffset + pendingOffsetDeltaRef.current
146
247
  return captureAnchor()
147
248
  }
148
249
 
149
250
  function restoreAnchor(anchor: VirtualListAnchor | null) {
150
- if (!anchor) return
151
- queuedAnchorRestoreRef.current = anchor
152
- if (anchorRestoreScheduledRef.current) return
251
+ if (!anchor || disposedRef.current) return
252
+ const index = indexByRowKeyRef.current.get(anchor.rowKey)
153
253
  const view = scrollRef.current
154
- if (!view) return
155
-
254
+ if (index === undefined || !view) return
255
+ const nextOffset = Math.max(0, headerHeightRef.current + sizeIndexRef.current.prefix(index) + anchor.within)
256
+ const delta = nextOffset - scrollOffsetRef.current
257
+ if (sameSize(delta, 0)) return
258
+ // Apply geometry corrections to the live offset, preserving intervening input.
259
+ pendingOffsetDeltaRef.current += delta
260
+ scrollOffsetRef.current = nextOffset
261
+ if (anchorRestoreScheduledRef.current) return
156
262
  anchorRestoreScheduledRef.current = true
157
263
  const timer = setTimeout(() => {
158
264
  if (anchorRestoreTimerRef.current !== timer) return
159
265
  anchorRestoreTimerRef.current = null
160
266
  anchorRestoreScheduledRef.current = false
161
- if (scrollRef.current !== view) return
162
-
163
- const queuedAnchor = queuedAnchorRestoreRef.current
164
- queuedAnchorRestoreRef.current = null
165
- if (!queuedAnchor) return
166
- const index = indexByRowKeyRef.current.get(queuedAnchor.rowKey)
167
- if (index === undefined) return
168
-
169
- const nextOffset = Math.max(
170
- 0,
171
- headerHeightRef.current + sizeIndexRef.current.prefix(index) + queuedAnchor.within,
172
- )
267
+ if (disposedRef.current || scrollRef.current !== view) return
268
+ const correction = pendingOffsetDeltaRef.current
269
+ pendingOffsetDeltaRef.current = 0
173
270
  const nativeOffset = Number(view.verticalOffset)
174
- if (sameSize(nextOffset, nativeOffset)) {
175
- scrollOffsetRef.current = nativeOffset
176
- return
177
- }
178
-
179
- scrollOffsetRef.current = nextOffset
271
+ const target = Math.max(0, nativeOffset + correction)
272
+ scrollOffsetRef.current = target
273
+ if (sameSize(target, nativeOffset)) return
180
274
  const nativeView = view.nativeViewProtected
181
275
  const makePoint = (globalThis as any).CGPointMake
182
276
  if (typeof nativeView?.setContentOffsetAnimated === 'function' && typeof makePoint === 'function') {
183
277
  const current = nativeView.contentOffset
184
- nativeView.setContentOffsetAnimated(makePoint(current.x, nextOffset), false)
278
+ nativeView.setContentOffsetAnimated(makePoint(current.x, target), false)
185
279
  } else if (typeof view.scrollToVerticalOffset === 'function') {
186
- view.scrollToVerticalOffset(nextOffset, false)
280
+ view.scrollToVerticalOffset(target, false)
187
281
  }
188
282
  }, 0)
283
+
189
284
  anchorRestoreTimerRef.current = timer
190
285
  }
191
286
 
@@ -193,21 +288,19 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
193
288
  if (anchorRestoreTimerRef.current != null) clearTimeout(anchorRestoreTimerRef.current)
194
289
  anchorRestoreTimerRef.current = null
195
290
  anchorRestoreScheduledRef.current = false
196
- queuedAnchorRestoreRef.current = null
291
+ pendingOffsetDeltaRef.current = 0
197
292
  }
198
293
 
199
- function scheduleEstimateRebuild(anchor: VirtualListAnchor | null) {
200
- if (estimateScheduledRef.current) return
201
- estimateScheduledRef.current = true
202
- estimateAnchorRef.current = anchor
203
- Promise.resolve().then(() => {
204
- estimateScheduledRef.current = false
205
- const scheduledAnchor = estimateAnchorRef.current
206
- estimateAnchorRef.current = null
294
+ function scheduleEstimateRebuild() {
295
+ if (estimateTimerRef.current != null) clearTimeout(estimateTimerRef.current)
296
+ estimateTimerRef.current = setTimeout(() => {
297
+ estimateTimerRef.current = null
298
+ if (disposedRef.current || estimateDirtyTypesRef.current.size === 0) return
299
+ const anchor = captureCurrentAnchor()
207
300
  rebuildLayout(entriesRef.current, viewportWidthRef.current)
208
- restoreAnchor(scheduledAnchor)
301
+ restoreAnchor(anchor)
209
302
  refreshWindow()
210
- })
303
+ }, 120)
211
304
  }
212
305
 
213
306
  function recordRowSize(rowKey: string, width: number, height: number) {
@@ -220,14 +313,40 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
220
313
  const hasRowSeparator = hasSeparatorRef.current && index < entriesRef.current.length - 1
221
314
  const cacheKey = virtualListMeasurementKey(rowKey, hasRowSeparator)
222
315
  const pendingAnchor = pendingAnchorRowsRef.current.has(rowKey) ? pendingAnchorRef.current : null
223
- const anchor = pendingAnchor ?? queuedAnchorRestoreRef.current ?? captureCurrentAnchor()
316
+ const previous = sizeCacheRef.current.get(cacheKey)
317
+ if (previous && sameSize(previous.width, width) && sameSize(previous.height, height) && sameSize(sizeIndexRef.current.get(index), height)) {
318
+ pendingAnchorRowsRef.current.delete(rowKey)
319
+ if (pendingAnchorRowsRef.current.size === 0) pendingAnchorRef.current = null
320
+ return
321
+ }
322
+
323
+ const anchor = pendingAnchor ?? captureCurrentAnchor()
224
324
  sizeCacheRef.current.set(cacheKey, { width, height })
325
+ const sample = typeSamplesRef.current.get(entry.typeKey) ?? { sum: 0, count: 0 }
326
+ sample.sum += height - (previous?.height ?? 0)
327
+ if (!previous) sample.count++
328
+ typeSamplesRef.current.set(entry.typeKey, sample)
329
+ const applied = appliedEstimatesRef.current.get(entry.typeKey) ?? VIRTUAL_LIST_ESTIMATED_ROW_SIZE
330
+ const estimateChanged = sample.count >= VIRTUAL_LIST_MIN_TYPE_SAMPLES &&
331
+ Math.abs(sample.sum / sample.count - applied) > Math.max(1, applied * 0.02)
332
+
225
333
  const delta = sizeIndexRef.current.set(index, height)
226
334
  if (Math.abs(delta) >= 0.5) {
227
335
  const anchorIndex = anchor ? indexByRowKeyRef.current.get(anchor.rowKey) : undefined
228
336
  if (anchor && anchorIndex !== undefined && index < anchorIndex) restoreAnchor(anchor)
229
- refreshWindow()
230
- scheduleEstimateRebuild(anchor)
337
+ }
338
+
339
+ // Exact measurements always apply. Tiny changes to a type average do not
340
+ // justify rebuilding every unvisited estimate and relaying out the list.
341
+ if (estimateChanged) {
342
+ estimateDirtyTypesRef.current.add(entry.typeKey)
343
+ scheduleEstimateRebuild()
344
+ } else {
345
+ estimateDirtyTypesRef.current.delete(entry.typeKey)
346
+ if (estimateDirtyTypesRef.current.size === 0 && estimateTimerRef.current != null) {
347
+ clearTimeout(estimateTimerRef.current)
348
+ estimateTimerRef.current = null
349
+ }
231
350
  }
232
351
 
233
352
  if (pendingAnchor) {
@@ -241,50 +360,129 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
241
360
  const size = view.getActualSize?.()
242
361
  if (!size) return
243
362
 
363
+ const initialViewport = viewportHeightRef.current <= 0
244
364
  const widthChanged = !sameSize(size.width, viewportWidthRef.current)
245
365
  viewportHeightRef.current = size.height
246
366
  if (widthChanged) {
247
- const anchor = captureAnchor()
367
+ const anchor = initialViewport ? null : captureCurrentAnchor()
248
368
  viewportWidthRef.current = size.width
249
369
  rebuildLayout(entriesRef.current, size.width)
250
370
  restoreAnchor(anchor)
251
371
  }
252
- refreshWindow()
372
+
373
+ // Populate the first viewport before its initial native layout finishes.
374
+ if (initialViewport) commitWindow()
375
+ else refreshWindow()
253
376
  }
254
377
 
255
378
  function onScroll(event: any) {
256
379
  const view = event.object ?? scrollRef.current
257
380
  const nextOffset = Number(view?.verticalOffset ?? 0)
258
- const queuedAnchor = queuedAnchorRestoreRef.current
259
- if (queuedAnchor && Math.abs(nextOffset - scrollOffsetRef.current) > viewportHeightRef.current) {
260
- const anchorIndex = indexByRowKeyRef.current.get(queuedAnchor.rowKey)
261
- const nextIndex = sizeIndexRef.current.indexAt(
262
- Math.max(0, nextOffset - headerHeightRef.current),
263
- )
264
- if (anchorIndex !== undefined && Math.abs(nextIndex - anchorIndex) > 1) cancelAnchorRestore()
381
+ if (Math.abs(nextOffset + pendingOffsetDeltaRef.current - scrollOffsetRef.current) > viewportHeightRef.current) {
382
+ cancelAnchorRestore()
265
383
  }
266
- scrollOffsetRef.current = nextOffset
384
+
385
+ if (!sameSize(nextOffset + pendingOffsetDeltaRef.current, scrollOffsetRef.current)) {
386
+ pendingAnchorRef.current = null
387
+ pendingAnchorRowsRef.current.clear()
388
+ }
389
+
390
+ scrollOffsetRef.current = nextOffset + pendingOffsetDeltaRef.current
391
+ if (estimateTimerRef.current != null) scheduleEstimateRebuild()
392
+ refreshWindow()
393
+ }
394
+
395
+ function onContentLayout(event: any) {
396
+ const width = Number(event.object?.getActualSize?.().width ?? 0)
397
+ if (width <= 0 || sameSize(width, contentWidthRef.current)) return
398
+ const anchor = scrollOffsetRef.current > 0 ? captureCurrentAnchor() : null
399
+ contentWidthRef.current = width
400
+ rebuildLayout(entriesRef.current, viewportWidthRef.current)
401
+ restoreAnchor(anchor)
267
402
  refreshWindow()
268
403
  }
269
404
 
270
405
  function onHeaderLayout(event: any) {
271
406
  const height = Number(event.object?.getActualSize?.().height ?? 0)
272
407
  if (sameSize(height, headerHeightRef.current)) return
273
- const anchor = captureAnchor()
408
+ const anchor = scrollOffsetRef.current > 0 ? captureCurrentAnchor() : null
274
409
  headerHeightRef.current = height
275
410
  restoreAnchor(anchor)
276
411
  refreshWindow()
277
412
  }
278
413
 
279
- function onRowLayout(entry: VirtualListEntry<T>, event: any) {
414
+ function cellCallback(id: number) {
415
+ let callback = cellCallbacksRef.current.get(id)
416
+ if (!callback) {
417
+ callback = (view: any) => {
418
+ if (view) {
419
+ // Preserve native widgets when the renderer moves a keyed host.
420
+ view.reusable = true
421
+ cellNodesRef.current.set(id, view)
422
+ } else {
423
+ cellNodesRef.current.get(id)?.destroyNode?.(true)
424
+ cellNodesRef.current.delete(id)
425
+ cellCallbacksRef.current.delete(id)
426
+ }
427
+ }
428
+
429
+ cellCallbacksRef.current.set(id, callback)
430
+ }
431
+
432
+ return callback
433
+ }
434
+
435
+ function onRowLayout(cell: VirtualListCell<T>, event: any) {
436
+ const entry = cell.entry
280
437
  const size = event.object?.getActualSize?.()
281
- if (size) recordRowSize(entry.rowKey, size.width, size.height)
438
+ if (!entry || !size || disposedRef.current) return
439
+ measurementsRef.current.set(entry.rowKey, { entry, width: size.width, height: size.height, cellId: cell.id, generation: cell.generation })
440
+ refreshWindow()
282
441
  }
283
442
 
443
+ useEffect(() => {
444
+ disposedRef.current = false
445
+ return () => {
446
+ disposedRef.current = true
447
+ frameCancelRef.current?.()
448
+ if (estimateTimerRef.current != null) clearTimeout(estimateTimerRef.current)
449
+ frameCancelRef.current = null
450
+ estimateTimerRef.current = null
451
+ measurementsRef.current.clear()
452
+ cellsRef.current.clear()
453
+ cellNodesRef.current.clear()
454
+ cellCallbacksRef.current.clear()
455
+ measuredAssignmentsRef.current.clear()
456
+ cancelAnchorRestore()
457
+ }
458
+ }, [])
459
+
284
460
  const range = currentRange()
285
461
  rangeRef.current = range
286
462
  void windowVersion
287
- const visibleEntries = entries.slice(range.start, range.end)
463
+ const cells = cellsRef.current.update(entries.slice(range.start, range.end)).map((cell) => ({
464
+ ...cell, top: cell.entry ? sizeIndexRef.current.prefix(cell.entry.index) : 0,
465
+ }))
466
+
467
+ const contentHeight = sizeIndexRef.current.total
468
+ useEffect(() => {
469
+ for (const cell of cells) {
470
+ if (!cell.entry || measuredAssignmentsRef.current.get(cell.id) === cell.generation) continue
471
+ const view = cellNodesRef.current.get(cell.id)
472
+ if (!view) continue
473
+ measuredAssignmentsRef.current.set(cell.id, cell.generation)
474
+ measurementsRef.current.set(cell.entry.rowKey, {
475
+ entry: cell.entry, width: 0, height: 0, view, cellId: cell.id, generation: cell.generation,
476
+ })
477
+
478
+ refreshWindow()
479
+ }
480
+
481
+ for (const id of measuredAssignmentsRef.current.keys()) {
482
+ if (!cellNodesRef.current.has(id)) measuredAssignmentsRef.current.delete(id)
483
+ }
484
+ }, [cells])
485
+
288
486
  const style = { ...props.style, minHeight: props.style?.minHeight ?? 0 }
289
487
 
290
488
  <scrollview
@@ -308,7 +506,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
308
506
  onLayoutChanged={(event: any) => syncViewport(event.object)}
309
507
  onScroll={onScroll}
310
508
  >
311
- <flexboxlayout flexDirection="column" className={cx('vx-virtual-list-content')}>
509
+ <flexboxlayout flexDirection="column" className={cx('vx-virtual-list-content')} onLayoutChanged={onContentLayout}>
312
510
  {props.renderHeader ? (
313
511
  <flexboxlayout
314
512
  flexDirection="column"
@@ -326,27 +524,33 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
326
524
  </flexboxlayout>
327
525
  ) : null
328
526
  ) : (
329
- <>
330
- <flexboxlayout className="vx-virtual-list-spacer" style={{ height: range.top, flexShrink: 0 }} />
331
- @for (const entry of visibleEntries; key entry.rowKey) {
527
+ <absolutelayout className="vx-virtual-list-body" style={{ height: contentHeight, flexShrink: 0 }}>
528
+ @for (const cell of cells; key cell.id) {
332
529
  <flexboxlayout
530
+ ref={cellCallback(cell.id)}
333
531
  flexDirection="column"
334
- className="vx-virtual-list-row"
335
- style={{ flexShrink: 0 }}
336
- onLayoutChanged={(event: any) => onRowLayout(entry, event)}
532
+ className={cell.entry ? 'vx-virtual-list-cell vx-virtual-list-row' : 'vx-virtual-list-cell'}
533
+ visibility={cell.entry ? 'visible' : 'collapse'}
534
+ left={0}
535
+ top={cell.top}
536
+ width={contentWidthRef.current || viewportWidthRef.current || '100%'}
537
+ onLayoutChanged={(event: any) => onRowLayout(cell, event)}
337
538
  >
338
- <flexboxlayout flexDirection="column" className="vx-virtual-list-item">
339
- {props.renderItem(entry.item, entry.index)}
340
- </flexboxlayout>
341
- {hasSeparator && entry.index < entries.length - 1 ? (
342
- <flexboxlayout flexDirection="column" className="vx-virtual-list-separator">
343
- {props.renderSeparator?.()}
344
- </flexboxlayout>
345
- ) : null}
539
+ @for (const entry of cell.rows; key entry.rowKey) {
540
+ <>
541
+ <flexboxlayout flexDirection="column" className="vx-virtual-list-item">
542
+ {props.renderItem(entry.item, entry.index)}
543
+ </flexboxlayout>
544
+ {hasSeparator && entry.index < entries.length - 1 ? (
545
+ <flexboxlayout flexDirection="column" className="vx-virtual-list-separator">
546
+ {props.renderSeparator?.()}
547
+ </flexboxlayout>
548
+ ) : null}
549
+ </>
550
+ }
346
551
  </flexboxlayout>
347
552
  }
348
- <flexboxlayout className="vx-virtual-list-spacer" style={{ height: range.bottom, flexShrink: 0 }} />
349
- </>
553
+ </absolutelayout>
350
554
  )}
351
555
  {props.renderFooter ? (
352
556
  <flexboxlayout flexDirection="column" className="vx-virtual-list-footer" flexShrink={0}>