@onekeyfe/react-native-native-list 3.0.105

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 (181) hide show
  1. package/README.md +342 -0
  2. package/android/CMakeLists.txt +20 -0
  3. package/android/build.gradle +105 -0
  4. package/android/gradle.properties +4 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  7. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +125 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +165 -0
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +147 -0
  10. package/android/src/main/java/com/onekey/nativelist/NativeListPackage.kt +25 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +2939 -0
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1794 -0
  13. package/common/fonts/Roobert-Bold.ttf +0 -0
  14. package/common/fonts/Roobert-Medium.ttf +0 -0
  15. package/common/fonts/Roobert-Regular.ttf +0 -0
  16. package/common/fonts/Roobert-SemiBold.ttf +0 -0
  17. package/docs/DESIGN.md +129 -0
  18. package/ios/HybridNativeList.swift +131 -0
  19. package/ios/NativeListCell.swift +2858 -0
  20. package/ios/NativeListDesignAssets.swift +110 -0
  21. package/ios/NativeListModels.swift +215 -0
  22. package/ios/RNCNativeListView.swift +1737 -0
  23. package/ios/Resources/NativeListIcons.xcassets/Contents.json +1 -0
  24. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_bottom.imageset/Contents.json +1 -0
  25. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_bottom.imageset/icon.svg +1 -0
  26. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_top.imageset/Contents.json +1 -0
  27. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_top.imageset/icon.svg +1 -0
  28. package/ios/Resources/NativeListIcons.xcassets/onekey_chart_trending_up.imageset/Contents.json +1 -0
  29. package/ios/Resources/NativeListIcons.xcassets/onekey_chart_trending_up.imageset/icon.svg +1 -0
  30. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_checked.imageset/Contents.json +1 -0
  31. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_checked.imageset/icon.svg +1 -0
  32. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_indeterminate.imageset/Contents.json +1 -0
  33. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_indeterminate.imageset/icon.svg +1 -0
  34. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_bottom.imageset/Contents.json +1 -0
  35. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_bottom.imageset/icon.svg +1 -0
  36. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_grabber_ver.imageset/Contents.json +1 -0
  37. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_grabber_ver.imageset/icon.svg +1 -0
  38. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_right_small.imageset/Contents.json +1 -0
  39. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_right_small.imageset/icon.svg +1 -0
  40. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_top.imageset/Contents.json +1 -0
  41. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_top.imageset/icon.svg +1 -0
  42. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_hor.imageset/Contents.json +1 -0
  43. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_hor.imageset/icon.svg +1 -0
  44. package/ios/Resources/NativeListIcons.xcassets/onekey_drag.imageset/Contents.json +1 -0
  45. package/ios/Resources/NativeListIcons.xcassets/onekey_drag.imageset/icon.svg +1 -0
  46. package/ios/Resources/NativeListIcons.xcassets/onekey_error_solid.imageset/Contents.json +1 -0
  47. package/ios/Resources/NativeListIcons.xcassets/onekey_error_solid.imageset/icon.svg +1 -0
  48. package/ios/Resources/NativeListIcons.xcassets/onekey_image_square_waves.imageset/Contents.json +1 -0
  49. package/ios/Resources/NativeListIcons.xcassets/onekey_image_square_waves.imageset/icon.svg +1 -0
  50. package/ios/Resources/NativeListIcons.xcassets/onekey_info_circle.imageset/Contents.json +1 -0
  51. package/ios/Resources/NativeListIcons.xcassets/onekey_info_circle.imageset/icon.svg +1 -0
  52. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle.imageset/Contents.json +1 -0
  53. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle.imageset/icon.svg +1 -0
  54. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle_solid.imageset/Contents.json +1 -0
  55. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle_solid.imageset/icon.svg +1 -0
  56. package/ios/Resources/NativeListIcons.xcassets/onekey_pencil.imageset/Contents.json +1 -0
  57. package/ios/Resources/NativeListIcons.xcassets/onekey_pencil.imageset/icon.svg +1 -0
  58. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_circle.imageset/Contents.json +1 -0
  59. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_circle.imageset/icon.svg +1 -0
  60. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_small.imageset/Contents.json +1 -0
  61. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_small.imageset/icon.svg +1 -0
  62. package/ios/Resources/NativeListIcons.xcassets/onekey_questionmark_solid.imageset/Contents.json +1 -0
  63. package/ios/Resources/NativeListIcons.xcassets/onekey_questionmark_solid.imageset/icon.svg +1 -0
  64. package/ios/Resources/NativeListIcons.xcassets/onekey_shield_exclamation.imageset/Contents.json +1 -0
  65. package/ios/Resources/NativeListIcons.xcassets/onekey_shield_exclamation.imageset/icon.svg +1 -0
  66. package/ios/Resources/NativeListIcons.xcassets/onekey_speaker_promote.imageset/Contents.json +1 -0
  67. package/ios/Resources/NativeListIcons.xcassets/onekey_speaker_promote.imageset/icon.svg +1 -0
  68. package/ios/Resources/NativeListIcons.xcassets/onekey_star.imageset/Contents.json +1 -0
  69. package/ios/Resources/NativeListIcons.xcassets/onekey_star.imageset/icon.svg +1 -0
  70. package/ios/Resources/NativeListIcons.xcassets/onekey_star_solid.imageset/Contents.json +1 -0
  71. package/ios/Resources/NativeListIcons.xcassets/onekey_star_solid.imageset/icon.svg +1 -0
  72. package/ios/Resources/NativeListIcons.xcassets/onekey_swap_hor.imageset/Contents.json +1 -0
  73. package/ios/Resources/NativeListIcons.xcassets/onekey_swap_hor.imageset/icon.svg +1 -0
  74. package/lib/module/NativeList.js +183 -0
  75. package/lib/module/NativeList.nitro.js +4 -0
  76. package/lib/module/NativeList.types.js +4 -0
  77. package/lib/module/NativeList.web.js +167 -0
  78. package/lib/module/index.js +6 -0
  79. package/lib/module/models.js +2 -0
  80. package/lib/module/package.json +1 -0
  81. package/lib/module/scrolling.js +121 -0
  82. package/lib/module/selection.js +85 -0
  83. package/lib/module/validation.js +562 -0
  84. package/lib/module/web/NativeListWebEngine.js +2238 -0
  85. package/lib/nitrogen/generated/.gitattributes +1 -0
  86. package/lib/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  87. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +201 -0
  88. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +84 -0
  89. package/lib/nitrogen/generated/android/c++/JNativeListScrollAlignment.hpp +64 -0
  90. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +104 -0
  91. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.hpp +54 -0
  92. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_std__string.kt +78 -0
  93. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +177 -0
  94. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/NativeListScrollAlignment.kt +25 -0
  95. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/nativelistOnLoad.kt +35 -0
  96. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +96 -0
  97. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListStateUpdater.kt +23 -0
  98. package/lib/nitrogen/generated/android/nativelist+autolinking.cmake +83 -0
  99. package/lib/nitrogen/generated/android/nativelist+autolinking.gradle +27 -0
  100. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +58 -0
  101. package/lib/nitrogen/generated/android/nativelistOnLoad.hpp +34 -0
  102. package/lib/nitrogen/generated/ios/NativeListModule+autolinking.rb +62 -0
  103. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +41 -0
  104. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +91 -0
  105. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Umbrella.hpp +49 -0
  106. package/lib/nitrogen/generated/ios/NativeListModuleAutolinking.mm +33 -0
  107. package/lib/nitrogen/generated/ios/NativeListModuleAutolinking.swift +26 -0
  108. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.cpp +11 -0
  109. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +180 -0
  110. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +180 -0
  111. package/lib/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  112. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +69 -0
  113. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +447 -0
  114. package/lib/nitrogen/generated/ios/swift/NativeListScrollAlignment.swift +48 -0
  115. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +43 -0
  116. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +87 -0
  117. package/lib/nitrogen/generated/shared/c++/NativeListScrollAlignment.hpp +84 -0
  118. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +46 -0
  119. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +103 -0
  120. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +16 -0
  121. package/lib/typescript/package.json +1 -0
  122. package/lib/typescript/src/NativeList.d.ts +18 -0
  123. package/lib/typescript/src/NativeList.nitro.d.ts +29 -0
  124. package/lib/typescript/src/NativeList.types.d.ts +87 -0
  125. package/lib/typescript/src/NativeList.web.d.ts +19 -0
  126. package/lib/typescript/src/index.d.ts +7 -0
  127. package/lib/typescript/src/models.d.ts +456 -0
  128. package/lib/typescript/src/scrolling.d.ts +32 -0
  129. package/lib/typescript/src/selection.d.ts +27 -0
  130. package/lib/typescript/src/validation.d.ts +7 -0
  131. package/lib/typescript/src/web/NativeListWebEngine.d.ts +203 -0
  132. package/nitro.json +23 -0
  133. package/nitrogen/generated/.gitattributes +1 -0
  134. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  135. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +201 -0
  136. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +84 -0
  137. package/nitrogen/generated/android/c++/JNativeListScrollAlignment.hpp +64 -0
  138. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +104 -0
  139. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.hpp +54 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_std__string.kt +78 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +177 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/NativeListScrollAlignment.kt +25 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/nativelistOnLoad.kt +35 -0
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +96 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListStateUpdater.kt +23 -0
  146. package/nitrogen/generated/android/nativelist+autolinking.cmake +83 -0
  147. package/nitrogen/generated/android/nativelist+autolinking.gradle +27 -0
  148. package/nitrogen/generated/android/nativelistOnLoad.cpp +58 -0
  149. package/nitrogen/generated/android/nativelistOnLoad.hpp +34 -0
  150. package/nitrogen/generated/ios/NativeListModule+autolinking.rb +62 -0
  151. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +41 -0
  152. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +91 -0
  153. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Umbrella.hpp +49 -0
  154. package/nitrogen/generated/ios/NativeListModuleAutolinking.mm +33 -0
  155. package/nitrogen/generated/ios/NativeListModuleAutolinking.swift +26 -0
  156. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.cpp +11 -0
  157. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +180 -0
  158. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +180 -0
  159. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  160. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +69 -0
  161. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +447 -0
  162. package/nitrogen/generated/ios/swift/NativeListScrollAlignment.swift +48 -0
  163. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +43 -0
  164. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +87 -0
  165. package/nitrogen/generated/shared/c++/NativeListScrollAlignment.hpp +84 -0
  166. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +46 -0
  167. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +103 -0
  168. package/nitrogen/generated/shared/json/NativeListConfig.json +16 -0
  169. package/package.json +126 -0
  170. package/react-native-native-list.podspec +38 -0
  171. package/react-native.config.js +19 -0
  172. package/src/NativeList.nitro.ts +48 -0
  173. package/src/NativeList.tsx +298 -0
  174. package/src/NativeList.types.ts +125 -0
  175. package/src/NativeList.web.tsx +258 -0
  176. package/src/index.ts +35 -0
  177. package/src/models.ts +604 -0
  178. package/src/scrolling.ts +189 -0
  179. package/src/selection.ts +159 -0
  180. package/src/validation.ts +877 -0
  181. package/src/web/NativeListWebEngine.ts +3767 -0
@@ -0,0 +1,1794 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import android.animation.TimeInterpolator
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.RectF
8
+ import android.graphics.drawable.GradientDrawable
9
+ import android.os.Bundle
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.view.HapticFeedbackConstants
13
+ import android.view.Choreographer
14
+ import android.view.Gravity
15
+ import android.view.MotionEvent
16
+ import android.view.View
17
+ import android.view.accessibility.AccessibilityEvent
18
+ import android.view.accessibility.AccessibilityNodeInfo
19
+ import android.widget.FrameLayout
20
+ import android.widget.LinearLayout
21
+ import android.widget.SeekBar
22
+ import android.widget.TextView
23
+ import androidx.recyclerview.widget.GridLayoutManager
24
+ import androidx.recyclerview.widget.ItemTouchHelper
25
+ import androidx.recyclerview.widget.LinearLayoutManager
26
+ import androidx.recyclerview.widget.LinearSmoothScroller
27
+ import androidx.recyclerview.widget.RecyclerView
28
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
29
+ import com.facebook.react.uimanager.ThemedReactContext
30
+ import org.json.JSONArray
31
+ import org.json.JSONObject
32
+ import java.lang.ref.WeakReference
33
+ import java.util.UUID
34
+ import kotlin.math.ceil
35
+ import kotlin.math.cos
36
+ import kotlin.math.exp
37
+ import kotlin.math.hypot
38
+ import kotlin.math.max
39
+ import kotlin.math.roundToInt
40
+ import kotlin.math.sin
41
+ import kotlin.math.sqrt
42
+
43
+ class NativeListView(
44
+ private val reactContext: ThemedReactContext,
45
+ ) : LinearLayout(reactContext) {
46
+ private class ActionAnchorRecord(
47
+ val token: String,
48
+ origin: NativeListActionOrigin,
49
+ ) {
50
+ val sourceView = WeakReference(origin.sourceView)
51
+ val ownerRowView = WeakReference(origin.ownerRowView)
52
+ val bindingEpoch = origin.bindingEpoch
53
+ var open = false
54
+ var invalidatedReason: String? = null
55
+ }
56
+
57
+ private sealed class ScrollRequest {
58
+ data class Key(
59
+ val key: String,
60
+ val animated: Boolean,
61
+ val alignment: String,
62
+ val viewPosition: Double,
63
+ val viewOffset: Double,
64
+ ) : ScrollRequest()
65
+
66
+ data class Index(
67
+ val index: Int,
68
+ val animated: Boolean,
69
+ val alignment: String,
70
+ val viewPosition: Double,
71
+ val viewOffset: Double,
72
+ ) : ScrollRequest()
73
+
74
+ data class Offset(val offset: Double, val animated: Boolean) : ScrollRequest()
75
+ data class End(val animated: Boolean) : ScrollRequest()
76
+ }
77
+
78
+ var onRowAction: ((String) -> Unit)? = null
79
+ var onActionAnchorInvalidated: ((String) -> Unit)? = null
80
+ var onSelectionDelta: ((String) -> Unit)? = null
81
+ var onReorder: ((String) -> Unit)? = null
82
+ var onEndReached: ((String) -> Unit)? = null
83
+ var onVisibleRangeChanged: ((String) -> Unit)? = null
84
+ private val density = resources.displayMetrics.density
85
+ private val recyclerView = RecyclerView(context)
86
+ private val refreshLayout = SwipeRefreshLayout(context)
87
+ private val contentContainer = FrameLayout(context)
88
+ private val adapter = NativeListAdapter(reactContext)
89
+ private val layoutManager = GridLayoutManager(context, 1)
90
+ private val reorderPlaceholderDecoration = ReorderPlaceholderDecoration(
91
+ adapter = adapter,
92
+ insetPx = dp(REORDER_PLACEHOLDER_INSET_DP),
93
+ radiusPx = dp(REORDER_PLACEHOLDER_RADIUS_DP).toFloat(),
94
+ )
95
+ private val reorderSpringInterpolator = ReorderSpringInterpolator(
96
+ damping = REORDER_SPRING_DAMPING,
97
+ stiffness = REORDER_SPRING_STIFFNESS,
98
+ mass = REORDER_SPRING_MASS,
99
+ durationSeconds = REORDER_SPRING_DURATION_MS / 1_000.0,
100
+ )
101
+ private val footerView = NativeListRowView(reactContext)
102
+ private val sectionIndexView = NativeListSectionIndexView(context)
103
+ private val sectionIndexPreview = TextView(context)
104
+ private var config: NativeListConfig? = null
105
+ private var stickyDecoration: StickySectionHeaderDecoration? = null
106
+ private var spacingDecoration: ItemSpacingDecoration? = null
107
+ private var itemTouchHelper: ItemTouchHelper? = null
108
+ private var reorderTouchListener: RecyclerView.OnItemTouchListener? = null
109
+ private var reorderTouchHandler: Handler? = null
110
+ private var endReachedGeneration: Int? = null
111
+ private var lastVisibleRangeSignature: String? = null
112
+ private var visibleEventScheduled = false
113
+ private var dragFrom = RecyclerView.NO_POSITION
114
+ private var dragTo = RecyclerView.NO_POSITION
115
+ private var pendingReorder: List<NativeListItem>? = null
116
+ private var sectionIndexEntries: List<NativeListSectionIndexEntry> = emptyList()
117
+ private var sectionIndexScrubbing = false
118
+ private var sectionIndexProgrammaticScroll = false
119
+ private var sectionIndexHapticsEnabled = true
120
+ private var pendingScrollRequest: ScrollRequest? = null
121
+ private var contentScrollOffsetPx = 0
122
+ private val actionAnchorInstanceId = UUID.randomUUID().toString()
123
+ private var actionAnchorCounter = 0L
124
+ private var actionAnchor: ActionAnchorRecord? = null
125
+ private var lastLayoutWidth = -1
126
+ private var lastLayoutHeight = -1
127
+ private var lastLayoutDirection = layoutDirection
128
+ private var disposed = false
129
+
130
+ init {
131
+ orientation = VERTICAL
132
+ recyclerView.adapter = adapter
133
+ recyclerView.layoutManager = layoutManager
134
+ recyclerView.itemAnimator = null
135
+ recyclerView.addItemDecoration(reorderPlaceholderDecoration)
136
+ recyclerView.setHasFixedSize(false)
137
+ layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
138
+ override fun getSpanSize(position: Int): Int {
139
+ if (config?.layout != "grid") return 1
140
+ val type = adapter.itemAt(position)?.type
141
+ return if (type == "sectionHeader" || type == "system" || type == "action") {
142
+ layoutManager.spanCount
143
+ } else {
144
+ 1
145
+ }
146
+ }
147
+ }
148
+ refreshLayout.addView(
149
+ recyclerView,
150
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
151
+ )
152
+ contentContainer.addView(
153
+ refreshLayout,
154
+ FrameLayout.LayoutParams(
155
+ FrameLayout.LayoutParams.MATCH_PARENT,
156
+ FrameLayout.LayoutParams.MATCH_PARENT,
157
+ ),
158
+ )
159
+ contentContainer.addView(
160
+ sectionIndexView,
161
+ FrameLayout.LayoutParams(dp(48), FrameLayout.LayoutParams.MATCH_PARENT, Gravity.END),
162
+ )
163
+ sectionIndexPreview.apply {
164
+ gravity = Gravity.CENTER
165
+ textSize = NativeListScale.font(resources, 28f)
166
+ typeface = NativeListFonts.semibold(context)
167
+ visibility = GONE
168
+ alpha = 0f
169
+ importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
170
+ }
171
+ contentContainer.addView(
172
+ sectionIndexPreview,
173
+ FrameLayout.LayoutParams(dp(72), dp(72), Gravity.CENTER),
174
+ )
175
+ addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
176
+ footerView.visibility = GONE
177
+ addView(footerView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
178
+
179
+ adapter.onRowPress = ::handleRowPress
180
+ adapter.onAction = ::handleAction
181
+ adapter.onBindingInvalidated = ::handleBindingInvalidated
182
+ adapter.checkboxState = ::resolveCheckboxState
183
+ footerView.onRowPress = ::handleRowPress
184
+ footerView.onAction = ::handleAction
185
+ footerView.onBindingInvalidated = ::handleBindingInvalidated
186
+ sectionIndexView.onSelect = ::selectSectionIndex
187
+ sectionIndexView.onInteractionEnded = { finishSectionIndexInteraction() }
188
+ sectionIndexView.visibility = GONE
189
+
190
+ refreshLayout.isEnabled = false
191
+ refreshLayout.setOnRefreshListener {
192
+ emit(
193
+ ROW_ACTION,
194
+ JSONObject().put("actionKey", "nativeList.refresh"),
195
+ )
196
+ }
197
+
198
+ recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
199
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
200
+ when (newState) {
201
+ RecyclerView.SCROLL_STATE_DRAGGING -> {
202
+ sectionIndexProgrammaticScroll = false
203
+ invalidateActionAnchor("scroll")
204
+ }
205
+ RecyclerView.SCROLL_STATE_IDLE -> sectionIndexProgrammaticScroll = false
206
+ }
207
+ }
208
+
209
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
210
+ if (dx != 0 || dy != 0) invalidateActionAnchor("scroll")
211
+ contentScrollOffsetPx = (
212
+ contentScrollOffsetPx +
213
+ if (layoutManager.orientation == RecyclerView.VERTICAL) dy else dx
214
+ ).coerceAtLeast(0)
215
+ syncSectionIndexToVisibleRows()
216
+ scheduleVisibleEvent()
217
+ checkEndReached()
218
+ }
219
+ })
220
+ }
221
+
222
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
223
+ super.onLayout(changed, left, top, right, bottom)
224
+ val nextWidth = right - left
225
+ val nextHeight = bottom - top
226
+ if (
227
+ lastLayoutWidth >= 0 &&
228
+ (nextWidth != lastLayoutWidth || nextHeight != lastLayoutHeight || layoutDirection != lastLayoutDirection)
229
+ ) {
230
+ invalidateActionAnchor("layout")
231
+ }
232
+ lastLayoutWidth = nextWidth
233
+ lastLayoutHeight = nextHeight
234
+ lastLayoutDirection = layoutDirection
235
+ performPendingScrollIfNeeded()
236
+ }
237
+
238
+ fun applySnapshot(snapshotJson: String) {
239
+ val next = try {
240
+ NativeListConfig.parse(snapshotJson)
241
+ } catch (_: Exception) {
242
+ return
243
+ }
244
+ invalidateActionAnchor("snapshot")
245
+ val previous = config
246
+ if (previous != null && canApplyStableContentUpdate(previous, next)) {
247
+ val changedSummaryKeys = previous.items.indices.mapNotNull { index ->
248
+ previous.items[index].key.takeIf {
249
+ previous.items[index].content != next.items[index].content
250
+ }
251
+ }.toSet()
252
+ config = next
253
+ adapter.theme = next.theme
254
+ adapter.layout = next.layout
255
+ adapter.orientation = next.orientation
256
+ adapter.selectedKeys = next.selectedKeys
257
+ adapter.submitList(next.items) {
258
+ recyclerView.post {
259
+ bindVisibleSelection(changedSummaryKeys)
260
+ bindFooterSelection()
261
+ performPendingScrollIfNeeded()
262
+ }
263
+ }
264
+ return
265
+ }
266
+ config = next
267
+ endReachedGeneration = null
268
+ pendingReorder = null
269
+ adapter.theme = next.theme
270
+ adapter.layout = next.layout
271
+ adapter.orientation = next.orientation
272
+ adapter.selectedKeys = next.selectedKeys
273
+ configureSectionIndex(next)
274
+ updateLayout(next)
275
+ adapter.submitList(next.items) {
276
+ relayoutContents()
277
+ performPendingScrollIfNeeded()
278
+ syncSectionIndexToVisibleRows()
279
+ scheduleVisibleEvent()
280
+ if (next.items.isNotEmpty()) checkEndReached()
281
+ }
282
+ refreshLayout.isEnabled = next.pullToRefresh
283
+ refreshLayout.isRefreshing = next.refreshing
284
+ bindFooter(next)
285
+ updateReordering(next)
286
+ }
287
+
288
+ /**
289
+ * A controlled selection update sends the snapshot back after the native
290
+ * selection delta. Its structure is unchanged; only selectedKeys and the
291
+ * fixed-height summary copy may differ. Keep RecyclerView's current layout
292
+ * and scroll anchor for that echo instead of rebuilding decorations and
293
+ * forcing the host through another layout pass.
294
+ */
295
+ private fun canApplyStableContentUpdate(
296
+ previous: NativeListConfig,
297
+ next: NativeListConfig,
298
+ ): Boolean {
299
+ if (
300
+ previous.generation != next.generation ||
301
+ previous.layout != next.layout ||
302
+ previous.orientation != next.orientation ||
303
+ previous.gridColumns != next.gridColumns ||
304
+ previous.stickyHeaders != next.stickyHeaders ||
305
+ previous.contentPadding != next.contentPadding ||
306
+ previous.contentPaddingHorizontal != next.contentPaddingHorizontal ||
307
+ previous.contentPaddingTop != next.contentPaddingTop ||
308
+ previous.contentPaddingBottom != next.contentPaddingBottom ||
309
+ previous.itemSpacing != next.itemSpacing ||
310
+ previous.selectionMode != next.selectionMode ||
311
+ previous.rowPressToggles != next.rowPressToggles ||
312
+ previous.reorderable != next.reorderable ||
313
+ previous.pullToRefresh != next.pullToRefresh ||
314
+ previous.refreshing != next.refreshing ||
315
+ previous.loadMore != next.loadMore ||
316
+ previous.endReachedThreshold != next.endReachedThreshold ||
317
+ previous.sectionIndexEnabled != next.sectionIndexEnabled ||
318
+ previous.sectionIndexHapticsEnabled != next.sectionIndexHapticsEnabled ||
319
+ previous.theme?.toString() != next.theme?.toString() ||
320
+ previous.fixedFooter?.content != next.fixedFooter?.content ||
321
+ previous.items.size != next.items.size
322
+ ) {
323
+ return false
324
+ }
325
+ return previous.items.indices.all { index ->
326
+ val oldItem = previous.items[index]
327
+ val newItem = next.items[index]
328
+ oldItem.key == newItem.key &&
329
+ oldItem.type == newItem.type &&
330
+ (oldItem.content == newItem.content || isStableSummaryUpdate(oldItem, newItem))
331
+ }
332
+ }
333
+
334
+ private fun isStableSummaryUpdate(
335
+ previous: NativeListItem,
336
+ next: NativeListItem,
337
+ ): Boolean {
338
+ if (
339
+ previous.type != "sectionHeader" ||
340
+ previous.json.optString("variant") != "summary" ||
341
+ next.json.optString("variant") != "summary"
342
+ ) {
343
+ return false
344
+ }
345
+ val previousStructure = JSONObject(previous.content).apply {
346
+ remove("title")
347
+ remove("value")
348
+ }
349
+ val nextStructure = JSONObject(next.content).apply {
350
+ remove("title")
351
+ remove("value")
352
+ }
353
+ return previousStructure.toString() == nextStructure.toString()
354
+ }
355
+
356
+ fun applyPatches(patchesJson: String) {
357
+ val current = config ?: return
358
+ val patches = try {
359
+ JSONArray(patchesJson)
360
+ } catch (_: Exception) {
361
+ return
362
+ }
363
+ val indexByKey = current.items.withIndex().associate { it.value.key to it.index }
364
+ val pending = ArrayList<Pair<Int, JSONObject>>(patches.length())
365
+ val seen = HashSet<String>()
366
+ for (index in 0 until patches.length()) {
367
+ val patch = patches.optJSONObject(index) ?: return
368
+ val key = patch.optString("key")
369
+ val itemIndex = indexByKey[key] ?: return
370
+ if (!seen.add(key)) return
371
+ if (patch.optString("type") != current.items[itemIndex].type) return
372
+ val changes = patch.optJSONObject("changes") ?: return
373
+ pending.add(itemIndex to changes)
374
+ }
375
+ val nextItems = current.items.toMutableList()
376
+ val selected = LinkedHashSet(current.selectedKeys)
377
+ try {
378
+ pending.forEach { (index, changes) ->
379
+ val previous = nextItems[index]
380
+ val merged = NativeListItem.parse(mergeRow(previous.json, changes))
381
+ nextItems[index] = merged
382
+ if (changes.has("selected")) {
383
+ if (changes.optBoolean("selected")) selected.add(merged.key) else selected.remove(merged.key)
384
+ }
385
+ }
386
+ } catch (_: Exception) {
387
+ return
388
+ }
389
+ invalidateActionAnchor("snapshot")
390
+ val next = current.copy(items = nextItems, selectedKeys = selected)
391
+ config = next
392
+ adapter.selectedKeys = selected
393
+ adapter.submitList(nextItems) { relayoutContents() }
394
+ bindFooter(next)
395
+ }
396
+
397
+ fun reconcileSelection(selectedKeysJson: String) {
398
+ val current = config ?: return
399
+ val array = try {
400
+ JSONArray(selectedKeysJson)
401
+ } catch (_: Exception) {
402
+ return
403
+ }
404
+ val known = current.items.filter { it.isSelectable }.mapTo(HashSet()) { it.key }
405
+ val next = LinkedHashSet<String>()
406
+ for (index in 0 until array.length()) {
407
+ val key = array.optString(index)
408
+ if (!known.contains(key)) return
409
+ next.add(key)
410
+ }
411
+ if (current.selectionMode == "single" && next.size > 1) return
412
+ config = current.copy(selectedKeys = next)
413
+ adapter.selectedKeys = next
414
+ notifySelectionChanged()
415
+ }
416
+
417
+ fun scrollToKey(
418
+ key: String,
419
+ animated: Boolean,
420
+ alignment: String,
421
+ viewPosition: Double = 0.0,
422
+ viewOffset: Double = 0.0,
423
+ ) {
424
+ requestScroll(ScrollRequest.Key(key, animated, alignment, viewPosition, viewOffset))
425
+ }
426
+
427
+ fun scrollToIndex(
428
+ index: Int,
429
+ animated: Boolean,
430
+ alignment: String,
431
+ viewPosition: Double = 0.0,
432
+ viewOffset: Double = 0.0,
433
+ ) {
434
+ requestScroll(ScrollRequest.Index(index, animated, alignment, viewPosition, viewOffset))
435
+ }
436
+
437
+ fun scrollToOffset(offset: Double, animated: Boolean) {
438
+ requestScroll(ScrollRequest.Offset(offset, animated))
439
+ }
440
+
441
+ fun scrollToEnd(animated: Boolean) {
442
+ requestScroll(ScrollRequest.End(animated))
443
+ }
444
+
445
+ private fun requestScroll(request: ScrollRequest) {
446
+ if (performScroll(request)) {
447
+ pendingScrollRequest = null
448
+ } else {
449
+ pendingScrollRequest = request
450
+ }
451
+ }
452
+
453
+ private fun performPendingScrollIfNeeded() {
454
+ val request = pendingScrollRequest ?: return
455
+ if (performScroll(request)) pendingScrollRequest = null
456
+ }
457
+
458
+ private fun performScroll(request: ScrollRequest): Boolean {
459
+ val current = config ?: return false
460
+ if (adapter.itemCount != current.items.size) return false
461
+ if (recyclerView.width <= 0 || recyclerView.height <= 0) return false
462
+ return when (request) {
463
+ is ScrollRequest.Key -> {
464
+ val index = current.items.indexOfFirst { it.key == request.key }
465
+ if (index >= 0) {
466
+ performIndexScroll(
467
+ index,
468
+ request.animated,
469
+ request.alignment,
470
+ request.viewPosition,
471
+ request.viewOffset,
472
+ )
473
+ }
474
+ true
475
+ }
476
+ is ScrollRequest.Index -> {
477
+ if (request.index in current.items.indices) {
478
+ performIndexScroll(
479
+ request.index,
480
+ request.animated,
481
+ request.alignment,
482
+ request.viewPosition,
483
+ request.viewOffset,
484
+ )
485
+ }
486
+ true
487
+ }
488
+ is ScrollRequest.Offset -> {
489
+ performOffsetScroll(request.offset, request.animated)
490
+ true
491
+ }
492
+ is ScrollRequest.End -> {
493
+ if (current.items.isNotEmpty()) {
494
+ performIndexScroll(
495
+ current.items.lastIndex,
496
+ request.animated,
497
+ "end",
498
+ 1.0,
499
+ 0.0,
500
+ )
501
+ }
502
+ true
503
+ }
504
+ }
505
+ }
506
+
507
+ private fun performIndexScroll(
508
+ index: Int,
509
+ animated: Boolean,
510
+ alignment: String,
511
+ requestedViewPosition: Double,
512
+ viewOffset: Double,
513
+ ) {
514
+ val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
515
+ val visibleView = manager.findViewByPosition(index)
516
+ val viewPosition = if (alignment == "nearest") {
517
+ val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
518
+ manager.paddingTop
519
+ } else {
520
+ manager.paddingLeft
521
+ }
522
+ val viewportEnd = if (manager.orientation == RecyclerView.VERTICAL) {
523
+ manager.height - manager.paddingBottom
524
+ } else {
525
+ manager.width - manager.paddingRight
526
+ }
527
+ if (visibleView != null) {
528
+ val itemStart = decoratedStart(manager, visibleView)
529
+ val itemEnd = decoratedEnd(manager, visibleView)
530
+ when {
531
+ itemStart < viewportStart -> 0.0
532
+ itemEnd > viewportEnd -> 1.0
533
+ else -> return
534
+ }
535
+ } else {
536
+ val firstVisible = manager.findFirstVisibleItemPosition()
537
+ if (firstVisible != RecyclerView.NO_POSITION && index < firstVisible) 0.0 else 1.0
538
+ }
539
+ } else {
540
+ requestedViewPosition.coerceIn(0.0, 1.0)
541
+ }
542
+ val offsetPx = (viewOffset * density).roundToInt()
543
+
544
+ if (visibleView != null) {
545
+ alignVisibleView(manager, visibleView, viewPosition, offsetPx, animated)
546
+ return
547
+ }
548
+ if (animated) {
549
+ val smoothScroller = object : LinearSmoothScroller(context) {
550
+ override fun getVerticalSnapPreference(): Int = SNAP_TO_START
551
+ override fun getHorizontalSnapPreference(): Int = SNAP_TO_START
552
+
553
+ override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int {
554
+ if (manager.orientation != RecyclerView.VERTICAL) return 0
555
+ return smoothAlignmentDelta(manager, view, viewPosition, offsetPx)
556
+ }
557
+
558
+ override fun calculateDxToMakeVisible(view: View, snapPreference: Int): Int {
559
+ if (manager.orientation != RecyclerView.HORIZONTAL) return 0
560
+ return smoothAlignmentDelta(manager, view, viewPosition, offsetPx)
561
+ }
562
+ }
563
+ smoothScroller.targetPosition = index
564
+ manager.startSmoothScroll(smoothScroller)
565
+ return
566
+ }
567
+
568
+ val provisionalOffset = (viewPosition * viewportLength(manager)).roundToInt() + offsetPx
569
+ manager.scrollToPositionWithOffset(index, provisionalOffset)
570
+ relayoutRecyclerView()
571
+ alignAfterLayout(index, viewPosition, offsetPx)
572
+ }
573
+
574
+ private fun performOffsetScroll(offset: Double, animated: Boolean) {
575
+ val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
576
+ val target = (offset * density).roundToInt().coerceAtLeast(0)
577
+ if (target == 0) {
578
+ if (animated) recyclerView.smoothScrollToPosition(0)
579
+ else manager.scrollToPositionWithOffset(0, 0)
580
+ return
581
+ }
582
+ val delta = target - contentScrollOffsetPx
583
+ if (manager.orientation == RecyclerView.VERTICAL) {
584
+ if (animated) recyclerView.smoothScrollBy(0, delta) else recyclerView.scrollBy(0, delta)
585
+ } else {
586
+ if (animated) recyclerView.smoothScrollBy(delta, 0) else recyclerView.scrollBy(delta, 0)
587
+ }
588
+ }
589
+
590
+ private fun alignAfterLayout(
591
+ index: Int,
592
+ viewPosition: Double,
593
+ viewOffset: Int,
594
+ attemptsRemaining: Int = 2,
595
+ ) {
596
+ recyclerView.post {
597
+ val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@post
598
+ val view = manager.findViewByPosition(index)
599
+ if (view == null) {
600
+ if (attemptsRemaining > 0) {
601
+ alignAfterLayout(index, viewPosition, viewOffset, attemptsRemaining - 1)
602
+ }
603
+ return@post
604
+ }
605
+ alignVisibleView(manager, view, viewPosition, viewOffset, false)
606
+ }
607
+ }
608
+
609
+ private fun alignVisibleView(
610
+ manager: LinearLayoutManager,
611
+ view: View,
612
+ viewPosition: Double,
613
+ viewOffset: Int,
614
+ animated: Boolean,
615
+ ) {
616
+ val itemStart = decoratedStart(manager, view)
617
+ val itemLength = decoratedEnd(manager, view) - itemStart
618
+ val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
619
+ manager.paddingTop
620
+ } else {
621
+ manager.paddingLeft
622
+ }
623
+ val targetStart = viewportStart +
624
+ (viewPosition * (viewportLength(manager) - itemLength).coerceAtLeast(0)).roundToInt() +
625
+ viewOffset
626
+ val delta = itemStart - targetStart
627
+ if (manager.orientation == RecyclerView.VERTICAL) {
628
+ if (animated) recyclerView.smoothScrollBy(0, delta) else recyclerView.scrollBy(0, delta)
629
+ } else {
630
+ if (animated) recyclerView.smoothScrollBy(delta, 0) else recyclerView.scrollBy(delta, 0)
631
+ }
632
+ }
633
+
634
+ private fun smoothAlignmentDelta(
635
+ manager: LinearLayoutManager,
636
+ view: View,
637
+ viewPosition: Double,
638
+ viewOffset: Int,
639
+ ): Int {
640
+ val itemStart = decoratedStart(manager, view)
641
+ val itemLength = decoratedEnd(manager, view) - itemStart
642
+ val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
643
+ manager.paddingTop
644
+ } else {
645
+ manager.paddingLeft
646
+ }
647
+ val targetStart = viewportStart +
648
+ (viewPosition * (viewportLength(manager) - itemLength).coerceAtLeast(0)).roundToInt() +
649
+ viewOffset
650
+ return targetStart - itemStart
651
+ }
652
+
653
+ private fun decoratedStart(manager: LinearLayoutManager, view: View): Int =
654
+ if (manager.orientation == RecyclerView.VERTICAL) {
655
+ manager.getDecoratedTop(view)
656
+ } else {
657
+ manager.getDecoratedLeft(view)
658
+ }
659
+
660
+ private fun decoratedEnd(manager: LinearLayoutManager, view: View): Int =
661
+ if (manager.orientation == RecyclerView.VERTICAL) {
662
+ manager.getDecoratedBottom(view)
663
+ } else {
664
+ manager.getDecoratedRight(view)
665
+ }
666
+
667
+ private fun viewportLength(manager: LinearLayoutManager): Int =
668
+ if (manager.orientation == RecyclerView.VERTICAL) {
669
+ manager.height - manager.paddingTop - manager.paddingBottom
670
+ } else {
671
+ manager.width - manager.paddingLeft - manager.paddingRight
672
+ }
673
+
674
+ fun setRefreshing(refreshing: Boolean) {
675
+ refreshLayout.isRefreshing = refreshing
676
+ config = config?.copy(refreshing = refreshing)
677
+ }
678
+
679
+ fun setActionAnchorState(stateJson: String) {
680
+ val state = try {
681
+ JSONObject(stateJson)
682
+ } catch (_: Exception) {
683
+ return
684
+ }
685
+ val anchor = actionAnchor ?: return
686
+ if (state.optString("token") != anchor.token || !state.has("open")) return
687
+ if (!state.optBoolean("open")) {
688
+ val sourceView = anchor.sourceView.get()
689
+ if (state.optBoolean("restoreFocus") && isActionAnchorValid(anchor) && sourceView != null) {
690
+ sourceView.requestFocus()
691
+ sourceView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
692
+ }
693
+ actionAnchor = null
694
+ return
695
+ }
696
+ val invalidatedReason = anchor.invalidatedReason
697
+ if (invalidatedReason != null) {
698
+ emitActionAnchorInvalidated(anchor, invalidatedReason)
699
+ } else if (isActionAnchorValid(anchor)) {
700
+ anchor.open = true
701
+ } else {
702
+ emitActionAnchorInvalidated(anchor, "rebind")
703
+ }
704
+ }
705
+
706
+ fun dispose() {
707
+ if (disposed) return
708
+ invalidateActionAnchor("destroy")
709
+ actionAnchor = null
710
+ disposed = true
711
+ pendingScrollRequest = null
712
+ visibleEventScheduled = false
713
+ reorderTouchHandler?.removeCallbacksAndMessages(null)
714
+ reorderTouchHandler = null
715
+ reorderTouchListener?.let(recyclerView::removeOnItemTouchListener)
716
+ reorderTouchListener = null
717
+ itemTouchHelper?.attachToRecyclerView(null)
718
+ itemTouchHelper = null
719
+ footerView.recycle()
720
+ footerView.dispose()
721
+ recyclerView.swapAdapter(null, false)
722
+ adapter.dispose()
723
+ }
724
+
725
+ private fun updateLayout(next: NativeListConfig) {
726
+ val orientation = if (next.orientation == "horizontal") RecyclerView.HORIZONTAL else RecyclerView.VERTICAL
727
+ layoutManager.orientation = orientation
728
+ layoutManager.spanCount = if (next.layout == "grid") next.gridColumns else 1
729
+ layoutManager.spanSizeLookup.invalidateSpanIndexCache()
730
+ val defaultPadding = next.contentPadding
731
+ val horizontalPadding = next.contentPaddingHorizontal ?: defaultPadding
732
+ val topPadding = next.contentPaddingTop ?: defaultPadding
733
+ val bottomPadding = next.contentPaddingBottom ?: defaultPadding
734
+ val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else 48
735
+ recyclerView.setPaddingRelative(
736
+ dp(horizontalPadding),
737
+ dp(topPadding),
738
+ dp(horizontalPadding + indexGutter),
739
+ dp(bottomPadding),
740
+ )
741
+ recyclerView.clipToPadding = false
742
+ recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty()
743
+
744
+ spacingDecoration?.let(recyclerView::removeItemDecoration)
745
+ spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing)).also(recyclerView::addItemDecoration)
746
+ stickyDecoration?.let(recyclerView::removeItemDecoration)
747
+ stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) {
748
+ StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration)
749
+ } else null
750
+ }
751
+
752
+ private fun configureSectionIndex(next: NativeListConfig) {
753
+ val previousKey = sectionIndexView.activeIndex?.let { sectionIndexEntries.getOrNull(it) }?.key
754
+ finishSectionIndexInteraction(immediately = true)
755
+ val enabled = next.sectionIndexEnabled &&
756
+ next.layout == "sectioned" &&
757
+ next.orientation != "horizontal"
758
+ sectionIndexEntries = if (enabled) {
759
+ next.items.mapIndexedNotNull { position, item ->
760
+ if (item.type != "sectionHeader") return@mapIndexedNotNull null
761
+ val title = item.json.optString("indexTitle")
762
+ if (title.isEmpty()) null else NativeListSectionIndexEntry(item.key, title, position)
763
+ }
764
+ } else {
765
+ emptyList()
766
+ }
767
+ sectionIndexHapticsEnabled = next.sectionIndexHapticsEnabled
768
+ sectionIndexView.configure(
769
+ sectionIndexEntries.map { it.title },
770
+ themeColor(next.theme, "secondaryText", "#646464"),
771
+ themeColor(next.theme, "accent", "#108303"),
772
+ )
773
+ sectionIndexView.visibility = if (sectionIndexEntries.isEmpty()) GONE else VISIBLE
774
+ sectionIndexPreview.setTextColor(themeColor(next.theme, "inverseText", "#FCFCFC"))
775
+ sectionIndexPreview.background = GradientDrawable().apply {
776
+ setColor(themeColor(next.theme, "inverseBackground", "#202020"))
777
+ cornerRadius = dp(16).toFloat()
778
+ }
779
+ sectionIndexView.setActiveIndex(
780
+ previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } },
781
+ )
782
+ }
783
+
784
+ private fun selectSectionIndex(index: Int, interacting: Boolean) {
785
+ val entry = sectionIndexEntries.getOrNull(index) ?: return
786
+ val changed = sectionIndexView.activeIndex != index
787
+ sectionIndexScrubbing = interacting
788
+ sectionIndexProgrammaticScroll = true
789
+ sectionIndexView.setActiveIndex(index)
790
+ recyclerView.stopScroll()
791
+ scrollToIndex(entry.position, animated = false, alignment = "start")
792
+ if (interacting) {
793
+ sectionIndexPreview.animate().cancel()
794
+ sectionIndexPreview.text = entry.title
795
+ sectionIndexPreview.visibility = VISIBLE
796
+ sectionIndexPreview.alpha = 1f
797
+ if (changed && sectionIndexHapticsEnabled) {
798
+ sectionIndexView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
799
+ }
800
+ }
801
+ }
802
+
803
+ private fun finishSectionIndexInteraction(immediately: Boolean = false) {
804
+ sectionIndexScrubbing = false
805
+ if (immediately) sectionIndexProgrammaticScroll = false
806
+ sectionIndexPreview.animate().cancel()
807
+ if (immediately) {
808
+ sectionIndexPreview.alpha = 0f
809
+ sectionIndexPreview.visibility = GONE
810
+ } else {
811
+ sectionIndexPreview.animate()
812
+ .alpha(0f)
813
+ .setDuration(150)
814
+ .withEndAction { sectionIndexPreview.visibility = GONE }
815
+ .start()
816
+ }
817
+ }
818
+
819
+ private fun syncSectionIndexToVisibleRows() {
820
+ if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return
821
+ val firstVisible = layoutManager.findFirstVisibleItemPosition()
822
+ if (firstVisible == RecyclerView.NO_POSITION) return
823
+ sectionIndexView.setActiveIndex(
824
+ sectionIndexEntries.indexOfLast { it.position <= firstVisible }.takeIf { it >= 0 },
825
+ )
826
+ }
827
+
828
+ private fun themeColor(theme: JSONObject?, key: String, fallback: String): Int = try {
829
+ parseNativeListColor(theme?.optString(key)?.takeIf(String::isNotEmpty) ?: fallback)
830
+ } catch (_: IllegalArgumentException) {
831
+ parseNativeListColor(fallback)
832
+ }
833
+
834
+ private fun bindFooter(next: NativeListConfig) {
835
+ val footer = next.fixedFooter
836
+ if (footer == null) {
837
+ footerView.visibility = GONE
838
+ footerView.recycle()
839
+ } else {
840
+ footerView.visibility = VISIBLE
841
+ footerView.bind(
842
+ footer,
843
+ next.theme,
844
+ next.layout,
845
+ "vertical",
846
+ null,
847
+ next.selectedKeys.contains(footer.key),
848
+ ::resolveCheckboxState,
849
+ )
850
+ }
851
+ }
852
+
853
+ private fun handleRowPress(item: NativeListItem, origin: NativeListActionOrigin) {
854
+ val current = config ?: return
855
+ if (current.rowPressToggles && item.isSelectable && current.selectionMode != "none") {
856
+ updateSelection(NativeSelectionTarget("row", item.key), item.key)
857
+ } else {
858
+ val actionKey = when {
859
+ item.type == "action" -> item.json.optString("actionKey")
860
+ item.type == "system" && item.json.optString("variant") == "retry" -> item.json.optString("actionKey")
861
+ else -> "press"
862
+ }
863
+ val payload = JSONObject()
864
+ .put("rowKey", item.key)
865
+ .put("actionKey", actionKey)
866
+ item.sectionKey?.let { payload.put("sectionKey", it) }
867
+ createActionAnchor(origin)?.let { payload.put("anchor", it) }
868
+ emit(ROW_ACTION, payload)
869
+ }
870
+ }
871
+
872
+ private fun handleAction(
873
+ item: NativeListItem,
874
+ actionKey: String,
875
+ target: NativeSelectionTarget?,
876
+ origin: NativeListActionOrigin?,
877
+ ) {
878
+ if (item.json.optBoolean("disabled", false)) return
879
+ if (target != null && config?.selectionMode != "none") {
880
+ updateSelection(target, item.key)
881
+ return
882
+ }
883
+ val payload = JSONObject()
884
+ .put("rowKey", item.key)
885
+ .put("actionKey", actionKey)
886
+ item.sectionKey?.let { payload.put("sectionKey", it) }
887
+ origin?.let(::createActionAnchor)?.let { payload.put("anchor", it) }
888
+ emit(ROW_ACTION, payload)
889
+ }
890
+
891
+ private fun createActionAnchor(origin: NativeListActionOrigin): JSONObject? {
892
+ if (!isOriginValid(origin)) return null
893
+ invalidateActionAnchor("rebind")
894
+ actionAnchorCounter += 1
895
+ val generation = config?.generation ?: 0
896
+ val token = "$actionAnchorInstanceId:$generation:$actionAnchorCounter:${origin.bindingEpoch}"
897
+ val location = IntArray(2)
898
+ origin.sourceView.getLocationInWindow(location)
899
+ val record = ActionAnchorRecord(token, origin)
900
+ actionAnchor = record
901
+ return JSONObject()
902
+ .put("token", token)
903
+ .put(
904
+ "windowRect",
905
+ JSONObject()
906
+ .put("x", location[0] / density)
907
+ .put("y", location[1] / density)
908
+ .put("width", origin.sourceView.width / density)
909
+ .put("height", origin.sourceView.height / density),
910
+ )
911
+ .put("source", origin.source)
912
+ .put("generation", generation)
913
+ .put("layoutDirection", if (origin.sourceView.layoutDirection == LAYOUT_DIRECTION_RTL) "rtl" else "ltr")
914
+ .also { anchor -> origin.slot?.let { anchor.put("slot", it) } }
915
+ }
916
+
917
+ private fun isOriginValid(origin: NativeListActionOrigin): Boolean =
918
+ origin.ownerRowView.bindingEpoch == origin.bindingEpoch &&
919
+ origin.sourceView.isAttachedToWindow &&
920
+ isDescendantOf(origin.sourceView, origin.ownerRowView)
921
+
922
+ private fun isActionAnchorValid(anchor: ActionAnchorRecord): Boolean {
923
+ if (anchor.invalidatedReason != null) return false
924
+ val sourceView = anchor.sourceView.get() ?: return false
925
+ val ownerRowView = anchor.ownerRowView.get() ?: return false
926
+ return ownerRowView.bindingEpoch == anchor.bindingEpoch &&
927
+ sourceView.isAttachedToWindow &&
928
+ isDescendantOf(sourceView, ownerRowView)
929
+ }
930
+
931
+ private fun isDescendantOf(view: View, ancestor: View): Boolean {
932
+ var current: Any? = view
933
+ while (current is View) {
934
+ if (current === ancestor) return true
935
+ current = current.parent
936
+ }
937
+ return false
938
+ }
939
+
940
+ private fun handleBindingInvalidated(row: NativeListRowView, epoch: Long) {
941
+ val anchor = actionAnchor ?: return
942
+ if (anchor.ownerRowView.get() === row && anchor.bindingEpoch == epoch) {
943
+ invalidateActionAnchor("rebind")
944
+ }
945
+ }
946
+
947
+ private fun invalidateActionAnchor(reason: String) {
948
+ val anchor = actionAnchor ?: return
949
+ if (anchor.invalidatedReason != null) return
950
+ anchor.invalidatedReason = reason
951
+ if (anchor.open) emitActionAnchorInvalidated(anchor, reason)
952
+ }
953
+
954
+ private fun emitActionAnchorInvalidated(anchor: ActionAnchorRecord, reason: String) {
955
+ if (actionAnchor !== anchor) return
956
+ actionAnchor = null
957
+ emit(
958
+ ACTION_ANCHOR_INVALIDATED,
959
+ JSONObject().put("token", anchor.token).put("reason", reason),
960
+ )
961
+ }
962
+
963
+ private fun updateSelection(target: NativeSelectionTarget, sourceKey: String) {
964
+ val current = config ?: return
965
+ if (current.selectionMode == "none") return
966
+ val targets = selectionKeys(target, current.items)
967
+ if (targets.isEmpty()) return
968
+ val before = LinkedHashSet(current.selectedKeys)
969
+ val after = LinkedHashSet(before)
970
+ if (current.selectionMode == "single") {
971
+ val key = targets.first()
972
+ after.clear()
973
+ if (!before.contains(key)) after.add(key)
974
+ } else {
975
+ val allSelected = targets.all(before::contains)
976
+ targets.forEach { if (allSelected) after.remove(it) else after.add(it) }
977
+ }
978
+ if (before == after) return
979
+ config = current.copy(selectedKeys = after)
980
+ adapter.selectedKeys = after
981
+ notifySelectionChanged()
982
+
983
+ val added = after.filterNot(before::contains)
984
+ val removed = before.filterNot(after::contains)
985
+ val payload = JSONObject()
986
+ .put("addedKeys", JSONArray(added))
987
+ .put("removedKeys", JSONArray(removed))
988
+ .put("source", target.scope)
989
+ .put("sourceKey", target.key ?: sourceKey)
990
+ emit(SELECTION_DELTA, payload)
991
+ }
992
+
993
+ private fun selectionKeys(
994
+ target: NativeSelectionTarget,
995
+ items: List<NativeListItem>,
996
+ ): List<String> = when (target.scope) {
997
+ "row" -> items.filter { it.key == target.key && it.isSelectable }.map { it.key }
998
+ "section" -> items.filter { it.sectionKey == target.key && it.isSelectable }.map { it.key }
999
+ "list" -> items.filter { it.isSelectable }.map { it.key }
1000
+ else -> emptyList()
1001
+ }
1002
+
1003
+ private fun resolveCheckboxState(
1004
+ item: NativeListItem,
1005
+ target: NativeSelectionTarget?,
1006
+ fallback: String,
1007
+ ): String {
1008
+ val current = config ?: return fallback
1009
+ val resolvedTarget = target ?: NativeSelectionTarget("row", item.key)
1010
+ val keys = selectionKeys(resolvedTarget, current.items)
1011
+ if (keys.isEmpty()) return fallback
1012
+ val count = keys.count(current.selectedKeys::contains)
1013
+ return when {
1014
+ count == 0 -> "unchecked"
1015
+ count == keys.size -> "checked"
1016
+ else -> "indeterminate"
1017
+ }
1018
+ }
1019
+
1020
+ private fun notifySelectionChanged() {
1021
+ adapter.notifyItemRangeChanged(0, adapter.itemCount, SELECTION_PAYLOAD)
1022
+ recyclerView.post { bindVisibleSelection() }
1023
+ bindFooterSelection()
1024
+ }
1025
+
1026
+ private fun bindVisibleSelection(changedSummaryKeys: Set<String> = emptySet()) {
1027
+ val current = config ?: return
1028
+ for (index in 0 until recyclerView.childCount) {
1029
+ val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(index)) as? NativeListViewHolder
1030
+ ?: continue
1031
+ val boundKey = (holder.rowView.tag as? NativeListItem)?.key ?: continue
1032
+ val position = adapter.positionOfKey(boundKey)
1033
+ val item = adapter.itemAt(position) ?: continue
1034
+ if (item.key in changedSummaryKeys) holder.rowView.bindStableSummary(item)
1035
+ holder.rowView.bindSelection(
1036
+ item,
1037
+ current.theme,
1038
+ current.layout,
1039
+ position,
1040
+ current.selectedKeys.contains(item.key),
1041
+ ::resolveCheckboxState,
1042
+ )
1043
+ }
1044
+ }
1045
+
1046
+ private fun bindFooterSelection() {
1047
+ config?.let { current ->
1048
+ current.fixedFooter?.let { footer ->
1049
+ footerView.bindSelection(
1050
+ footer,
1051
+ current.theme,
1052
+ current.layout,
1053
+ null,
1054
+ current.selectedKeys.contains(footer.key),
1055
+ ::resolveCheckboxState,
1056
+ )
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ private fun updateReordering(next: NativeListConfig) {
1062
+ reorderTouchHandler?.removeCallbacksAndMessages(null)
1063
+ reorderTouchHandler = null
1064
+ reorderTouchListener?.let(recyclerView::removeOnItemTouchListener)
1065
+ reorderTouchListener = null
1066
+ itemTouchHelper?.attachToRecyclerView(null)
1067
+ itemTouchHelper = null
1068
+ adapter.cancelReorder()
1069
+ if (!next.reorderable) return
1070
+ val callback = object : ItemTouchHelper.SimpleCallback(
1071
+ ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
1072
+ 0,
1073
+ ) {
1074
+ private var compactWalletGroupDrag = false
1075
+ private var compactWalletGroupTop = Float.NaN
1076
+
1077
+ override fun isLongPressDragEnabled(): Boolean = false
1078
+
1079
+ override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
1080
+ super.onSelectedChanged(viewHolder, actionState)
1081
+ if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
1082
+ val position = viewHolder?.bindingAdapterPosition ?: RecyclerView.NO_POSITION
1083
+ val item = adapter.itemAt(position)
1084
+ reorderPlaceholderDecoration.position = position
1085
+ reorderPlaceholderDecoration.color = reorderActiveBackground(next.theme)
1086
+ compactWalletGroupDrag = item?.type == "walletGroup" && viewHolder != null
1087
+ recyclerView.invalidate()
1088
+ (viewHolder as? NativeListViewHolder)?.rowView?.setReorderActive(true)
1089
+ if (compactWalletGroupDrag && viewHolder != null) {
1090
+ recyclerView.postOnAnimation {
1091
+ if (viewHolder.itemView.isAttachedToWindow) {
1092
+ relayoutRecyclerViewImmediately()
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
1100
+ val item = adapter.itemAt(viewHolder.bindingAdapterPosition)
1101
+ if (item == null || !item.isReorderable) return makeMovementFlags(0, 0)
1102
+ val dragFlags = if (next.orientation == "horizontal") ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT else ItemTouchHelper.UP or ItemTouchHelper.DOWN
1103
+ return makeMovementFlags(dragFlags, 0)
1104
+ }
1105
+
1106
+ override fun onMove(
1107
+ recyclerView: RecyclerView,
1108
+ source: RecyclerView.ViewHolder,
1109
+ target: RecyclerView.ViewHolder,
1110
+ ): Boolean {
1111
+ val from = source.bindingAdapterPosition
1112
+ val to = target.bindingAdapterPosition
1113
+ val base = pendingReorder ?: adapter.currentList
1114
+ val fromItem = base.getOrNull(from) ?: return false
1115
+ val toItem = base.getOrNull(to) ?: return false
1116
+ if (!fromItem.isReorderable || !toItem.isReorderable || fromItem.sectionKey != toItem.sectionKey) return false
1117
+ val crossedPosition = dragTo != to
1118
+ if (dragFrom == RecyclerView.NO_POSITION) dragFrom = from
1119
+ dragTo = to
1120
+ if (crossedPosition) {
1121
+ recyclerView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
1122
+ }
1123
+ val displacedView = target.itemView
1124
+ prepareDisplacedReorderAnimation(displacedView)
1125
+ val reordered = adapter.moveReordered(from, to) ?: return false
1126
+ pendingReorder = reordered
1127
+ reorderPlaceholderDecoration.position = to
1128
+ recyclerView.invalidate()
1129
+ recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1130
+ return true
1131
+ }
1132
+
1133
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
1134
+
1135
+ override fun onChildDraw(
1136
+ canvas: Canvas,
1137
+ recyclerView: RecyclerView,
1138
+ viewHolder: RecyclerView.ViewHolder,
1139
+ dX: Float,
1140
+ dY: Float,
1141
+ actionState: Int,
1142
+ isCurrentlyActive: Boolean,
1143
+ ) {
1144
+ if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
1145
+ if (compactWalletGroupDrag) {
1146
+ compactWalletGroupTop = viewHolder.itemView.top + dY
1147
+ }
1148
+ }
1149
+ super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
1150
+ }
1151
+
1152
+ override fun interpolateOutOfBoundsScroll(
1153
+ recyclerView: RecyclerView,
1154
+ viewSize: Int,
1155
+ viewSizeOutOfBounds: Int,
1156
+ totalSize: Int,
1157
+ msSinceStartScroll: Long,
1158
+ ): Int {
1159
+ if (!compactWalletGroupDrag) {
1160
+ return super.interpolateOutOfBoundsScroll(
1161
+ recyclerView,
1162
+ viewSize,
1163
+ viewSizeOutOfBounds,
1164
+ totalSize,
1165
+ msSinceStartScroll,
1166
+ )
1167
+ }
1168
+ if (compactWalletGroupTop.isNaN()) return 0
1169
+ val compactHeight = dp(68)
1170
+ val compactTop = compactWalletGroupTop.roundToInt()
1171
+ val compactOutOfBounds = when {
1172
+ compactTop < recyclerView.paddingTop -> compactTop - recyclerView.paddingTop
1173
+ compactTop + compactHeight > recyclerView.height - recyclerView.paddingBottom ->
1174
+ compactTop + compactHeight - (recyclerView.height - recyclerView.paddingBottom)
1175
+ else -> 0
1176
+ }
1177
+ if (compactOutOfBounds == 0) return 0
1178
+ return super.interpolateOutOfBoundsScroll(
1179
+ recyclerView,
1180
+ compactHeight,
1181
+ compactOutOfBounds,
1182
+ totalSize,
1183
+ msSinceStartScroll,
1184
+ )
1185
+ }
1186
+
1187
+ override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
1188
+ val rowView = (viewHolder as? NativeListViewHolder)?.rowView
1189
+ val draggedGroupKey = (rowView?.tag as? NativeListItem)?.key
1190
+ super.clearView(recyclerView, viewHolder)
1191
+ val wasCompactWalletGroupDrag = compactWalletGroupDrag
1192
+ if (!wasCompactWalletGroupDrag) rowView?.setReorderActive(false)
1193
+ reorderPlaceholderDecoration.position = RecyclerView.NO_POSITION
1194
+ compactWalletGroupDrag = false
1195
+ compactWalletGroupTop = Float.NaN
1196
+ recyclerView.invalidate()
1197
+ val from = dragFrom
1198
+ val to = dragTo
1199
+ val reordered = pendingReorder
1200
+ val destinationPosition = if (to != RecyclerView.NO_POSITION) {
1201
+ to
1202
+ } else {
1203
+ viewHolder.bindingAdapterPosition
1204
+ }
1205
+ val finishCompactWalletGroup = {
1206
+ if (wasCompactWalletGroupDrag) {
1207
+ recyclerView.postOnAnimation {
1208
+ relayoutRecyclerViewImmediately()
1209
+ val resolvedPosition = draggedGroupKey
1210
+ ?.let(adapter::positionOfKey)
1211
+ ?.takeIf { it != RecyclerView.NO_POSITION }
1212
+ ?: destinationPosition
1213
+ val destinationRow = recyclerView
1214
+ .findViewHolderForAdapterPosition(resolvedPosition)
1215
+ ?.let { it as? NativeListViewHolder }
1216
+ ?.rowView
1217
+ val groupRow = destinationRow?.takeIf {
1218
+ (it.tag as? NativeListItem)?.type == "walletGroup"
1219
+ } ?: rowView
1220
+ groupRow?.finishWalletGroupReorder(
1221
+ REORDER_SPRING_DURATION_MS,
1222
+ reorderSpringInterpolator,
1223
+ ) {
1224
+ recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ if (from != RecyclerView.NO_POSITION && to != RecyclerView.NO_POSITION && from != to && reordered != null) {
1230
+ val moved = reordered.getOrNull(to)
1231
+ if (moved != null) {
1232
+ config = config?.copy(items = reordered)
1233
+ val payload = JSONObject()
1234
+ .put("key", moved.key)
1235
+ .put("fromIndex", from)
1236
+ .put("toIndex", to)
1237
+ reordered.getOrNull(to - 1)?.let { payload.put("beforeKey", it.key) }
1238
+ reordered.getOrNull(to + 1)?.let { payload.put("afterKey", it.key) }
1239
+ adapter.commitReordered(reordered) {
1240
+ finishCompactWalletGroup()
1241
+ if (!wasCompactWalletGroupDrag) {
1242
+ recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
1243
+ }
1244
+ emit(REORDER, payload)
1245
+ }
1246
+ }
1247
+ } else {
1248
+ adapter.cancelReorder()
1249
+ finishCompactWalletGroup()
1250
+ }
1251
+ dragFrom = RecyclerView.NO_POSITION
1252
+ dragTo = RecyclerView.NO_POSITION
1253
+ pendingReorder = null
1254
+ }
1255
+ }
1256
+ itemTouchHelper = ItemTouchHelper(callback).also { it.attachToRecyclerView(recyclerView) }
1257
+ val handler = Handler(Looper.getMainLooper())
1258
+ reorderTouchHandler = handler
1259
+ val movementLimit = dp(REORDER_ALLOWABLE_MOVEMENT_DP).toFloat()
1260
+ reorderTouchListener = object : RecyclerView.SimpleOnItemTouchListener() {
1261
+ private var candidate: RecyclerView.ViewHolder? = null
1262
+ private var downX = 0f
1263
+ private var downY = 0f
1264
+ private var dragStarted = false
1265
+ private val startDrag = Runnable {
1266
+ val holder = candidate ?: return@Runnable
1267
+ val position = holder.bindingAdapterPosition
1268
+ val item = adapter.itemAt(position) ?: return@Runnable
1269
+ if (!item.isReorderable) return@Runnable
1270
+ if (item.type != "walletGroup" && recyclerView.isLayoutRequested) {
1271
+ relayoutRecyclerViewImmediately()
1272
+ }
1273
+ dragStarted = true
1274
+ beginDrag(holder)
1275
+ }
1276
+
1277
+ private fun beginDrag(holder: RecyclerView.ViewHolder) {
1278
+ if (candidate !== holder || !holder.itemView.isAttachedToWindow) return
1279
+ itemTouchHelper?.startDrag(holder)
1280
+ holder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
1281
+ }
1282
+
1283
+ private fun cancelPending() {
1284
+ handler.removeCallbacks(startDrag)
1285
+ candidate = null
1286
+ }
1287
+
1288
+ override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean {
1289
+ when (event.actionMasked) {
1290
+ MotionEvent.ACTION_DOWN -> {
1291
+ cancelPending()
1292
+ dragStarted = false
1293
+ downX = event.x
1294
+ downY = event.y
1295
+ val child = recyclerView.findChildViewUnder(event.x, event.y)
1296
+ candidate = child
1297
+ ?.let(recyclerView::getChildViewHolder)
1298
+ ?.takeIf { holder ->
1299
+ adapter.itemAt(holder.bindingAdapterPosition)?.let { item ->
1300
+ val holderLocation = IntArray(2)
1301
+ holder.itemView.getLocationOnScreen(holderLocation)
1302
+ item.isReorderable && (
1303
+ item.type != "walletGroup" ||
1304
+ event.rawY in holderLocation[1].toFloat()..(holderLocation[1] + dp(68)).toFloat()
1305
+ )
1306
+ } == true
1307
+ }
1308
+ if (candidate != null) handler.postDelayed(startDrag, REORDER_LONG_PRESS_MS)
1309
+ }
1310
+ MotionEvent.ACTION_MOVE -> if (
1311
+ !dragStarted && hypot(event.x - downX, event.y - downY) > movementLimit
1312
+ ) {
1313
+ cancelPending()
1314
+ }
1315
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> cancelPending()
1316
+ }
1317
+ return false
1318
+ }
1319
+
1320
+ }.also(recyclerView::addOnItemTouchListener)
1321
+ }
1322
+
1323
+ private fun prepareDisplacedReorderAnimation(view: View) {
1324
+ val previousX = view.x
1325
+ val previousY = view.y
1326
+ view.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
1327
+ override fun onLayoutChange(
1328
+ changedView: View,
1329
+ left: Int,
1330
+ top: Int,
1331
+ right: Int,
1332
+ bottom: Int,
1333
+ oldLeft: Int,
1334
+ oldTop: Int,
1335
+ oldRight: Int,
1336
+ oldBottom: Int,
1337
+ ) {
1338
+ changedView.removeOnLayoutChangeListener(this)
1339
+ val deltaX = previousX - changedView.x
1340
+ val deltaY = previousY - changedView.y
1341
+ if (deltaX == 0f && deltaY == 0f) return
1342
+ changedView.animate().cancel()
1343
+ changedView.translationX = deltaX
1344
+ changedView.translationY = deltaY
1345
+ changedView.animate()
1346
+ .translationX(0f)
1347
+ .translationY(0f)
1348
+ .setDuration(REORDER_SPRING_DURATION_MS)
1349
+ .setInterpolator(reorderSpringInterpolator)
1350
+ .start()
1351
+ }
1352
+ })
1353
+ }
1354
+
1355
+ private fun reorderActiveBackground(theme: JSONObject?): Int = try {
1356
+ parseNativeListColor(
1357
+ theme?.optString("rowPressedBackground", "#00000017") ?: "#00000017",
1358
+ )
1359
+ } catch (_: IllegalArgumentException) {
1360
+ parseNativeListColor("#00000017")
1361
+ }
1362
+
1363
+ private fun scheduleVisibleEvent() {
1364
+ if (visibleEventScheduled) return
1365
+ visibleEventScheduled = true
1366
+ Choreographer.getInstance().postFrameCallback {
1367
+ visibleEventScheduled = false
1368
+ val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@postFrameCallback
1369
+ val first = manager.findFirstVisibleItemPosition()
1370
+ val last = manager.findLastVisibleItemPosition()
1371
+ val firstKey = adapter.itemAt(first)?.key
1372
+ val lastKey = adapter.itemAt(last)?.key
1373
+ val signature = "$first:$last:${firstKey.orEmpty()}:${lastKey.orEmpty()}"
1374
+ if (signature == lastVisibleRangeSignature) return@postFrameCallback
1375
+ lastVisibleRangeSignature = signature
1376
+ val payload = JSONObject()
1377
+ .put("firstIndex", first)
1378
+ .put("lastIndex", last)
1379
+ firstKey?.let { payload.put("firstKey", it) }
1380
+ lastKey?.let { payload.put("lastKey", it) }
1381
+ emit(VISIBLE_RANGE_CHANGED, payload)
1382
+ }
1383
+ }
1384
+
1385
+ private fun checkEndReached() {
1386
+ val current = config ?: return
1387
+ if (!current.loadMore || current.generation == endReachedGeneration || adapter.itemCount == 0) return
1388
+ val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
1389
+ val lastVisible = manager.findLastVisibleItemPosition()
1390
+ val thresholdItems = max(1, ceil(adapter.itemCount * current.endReachedThreshold).toInt())
1391
+ if (lastVisible >= adapter.itemCount - thresholdItems) {
1392
+ endReachedGeneration = current.generation
1393
+ val payload = JSONObject().put("generation", current.generation)
1394
+ adapter.itemAt(adapter.itemCount - 1)?.let { payload.put("lastKey", it.key) }
1395
+ emit(END_REACHED, payload)
1396
+ }
1397
+ }
1398
+
1399
+ private fun emit(eventName: String, payload: JSONObject) {
1400
+ val json = payload.toString()
1401
+ when (eventName) {
1402
+ ROW_ACTION -> onRowAction?.invoke(json)
1403
+ ACTION_ANCHOR_INVALIDATED -> onActionAnchorInvalidated?.invoke(json)
1404
+ SELECTION_DELTA -> onSelectionDelta?.invoke(json)
1405
+ REORDER -> onReorder?.invoke(json)
1406
+ END_REACHED -> onEndReached?.invoke(json)
1407
+ VISIBLE_RANGE_CHANGED -> onVisibleRangeChanged?.invoke(json)
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * React Native owns the outer view's layout pass and does not always honor a
1413
+ * nested RecyclerView's requestLayout after the first mount. Re-run this
1414
+ * already-sized host once after a committed data update so the native rows
1415
+ * are measured and rebound without rebuilding the adapter.
1416
+ */
1417
+ private fun relayoutContents() {
1418
+ post {
1419
+ if (disposed || width <= 0 || height <= 0) return@post
1420
+ forceLayout()
1421
+ measure(
1422
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
1423
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
1424
+ )
1425
+ layout(left, top, right, bottom)
1426
+ }
1427
+ }
1428
+
1429
+ private fun relayoutRecyclerView() {
1430
+ recyclerView.post {
1431
+ if (disposed || recyclerView.width <= 0 || recyclerView.height <= 0) return@post
1432
+ recyclerView.forceLayout()
1433
+ recyclerView.measure(
1434
+ MeasureSpec.makeMeasureSpec(recyclerView.width, MeasureSpec.EXACTLY),
1435
+ MeasureSpec.makeMeasureSpec(recyclerView.height, MeasureSpec.EXACTLY),
1436
+ )
1437
+ recyclerView.layout(
1438
+ recyclerView.left,
1439
+ recyclerView.top,
1440
+ recyclerView.right,
1441
+ recyclerView.bottom,
1442
+ )
1443
+ }
1444
+ }
1445
+
1446
+ private fun relayoutRecyclerViewImmediately() {
1447
+ if (disposed || recyclerView.isComputingLayout || recyclerView.width <= 0 || recyclerView.height <= 0) {
1448
+ return
1449
+ }
1450
+ recyclerView.forceLayout()
1451
+ recyclerView.measure(
1452
+ MeasureSpec.makeMeasureSpec(recyclerView.width, MeasureSpec.EXACTLY),
1453
+ MeasureSpec.makeMeasureSpec(recyclerView.height, MeasureSpec.EXACTLY),
1454
+ )
1455
+ recyclerView.layout(
1456
+ recyclerView.left,
1457
+ recyclerView.top,
1458
+ recyclerView.right,
1459
+ recyclerView.bottom,
1460
+ )
1461
+ }
1462
+
1463
+ private fun dp(value: Int): Int = NativeListScale.dp(resources, value)
1464
+
1465
+ companion object {
1466
+ private const val REORDER_LONG_PRESS_MS = 200L
1467
+ private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10
1468
+ private const val REORDER_PLACEHOLDER_INSET_DP = 8
1469
+ private const val REORDER_PLACEHOLDER_RADIUS_DP = 12
1470
+ private const val REORDER_SPRING_DAMPING = 25.0
1471
+ private const val REORDER_SPRING_STIFFNESS = 400.0
1472
+ private const val REORDER_SPRING_MASS = 0.4
1473
+ private const val REORDER_SPRING_DURATION_MS = 300L
1474
+ private const val ROW_ACTION = "rowAction"
1475
+ private const val ACTION_ANCHOR_INVALIDATED = "actionAnchorInvalidated"
1476
+ private const val SELECTION_DELTA = "selectionDelta"
1477
+ private const val REORDER = "reorder"
1478
+ private const val END_REACHED = "endReached"
1479
+ private const val VISIBLE_RANGE_CHANGED = "visibleRangeChanged"
1480
+ }
1481
+ }
1482
+
1483
+ private class ReorderPlaceholderDecoration(
1484
+ private val adapter: NativeListAdapter,
1485
+ private val insetPx: Int,
1486
+ private val radiusPx: Float,
1487
+ ) : RecyclerView.ItemDecoration() {
1488
+ var position: Int = RecyclerView.NO_POSITION
1489
+ var color: Int = Color.TRANSPARENT
1490
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
1491
+ private val bounds = RectF()
1492
+
1493
+ override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
1494
+ val item = adapter.itemAt(position) ?: return
1495
+ val isWalletSidebar =
1496
+ item.type == "identity" && item.json.optString("presentation") == "walletSidebar"
1497
+ if (!isWalletSidebar && item.type != "walletGroup") return
1498
+ val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
1499
+ bounds.set(
1500
+ (view.left + insetPx).toFloat(),
1501
+ view.top.toFloat(),
1502
+ (view.right - insetPx).toFloat(),
1503
+ view.bottom.toFloat(),
1504
+ )
1505
+ paint.color = color
1506
+ canvas.drawRoundRect(bounds, radiusPx, radiusPx, paint)
1507
+ }
1508
+ }
1509
+
1510
+ private class ReorderSpringInterpolator(
1511
+ damping: Double,
1512
+ stiffness: Double,
1513
+ mass: Double,
1514
+ private val durationSeconds: Double,
1515
+ ) : TimeInterpolator {
1516
+ private val naturalFrequency = sqrt(stiffness / mass)
1517
+ private val dampingRatio = damping / (2 * sqrt(stiffness * mass))
1518
+ private val dampedFrequency = naturalFrequency * sqrt(1 - dampingRatio * dampingRatio)
1519
+
1520
+ override fun getInterpolation(input: Float): Float {
1521
+ if (input <= 0f) return 0f
1522
+ if (input >= 1f) return 1f
1523
+ val time = input * durationSeconds
1524
+ val decay = exp(-dampingRatio * naturalFrequency * time)
1525
+ val displacement = decay * (
1526
+ cos(dampedFrequency * time) +
1527
+ dampingRatio * naturalFrequency / dampedFrequency * sin(dampedFrequency * time)
1528
+ )
1529
+ return (1 - displacement).toFloat()
1530
+ }
1531
+ }
1532
+
1533
+ private data class NativeListSectionIndexEntry(
1534
+ val key: String,
1535
+ val title: String,
1536
+ val position: Int,
1537
+ )
1538
+
1539
+ private class NativeListSectionIndexView(
1540
+ context: android.content.Context,
1541
+ ) : View(context) {
1542
+ var onSelect: ((Int, Boolean) -> Unit)? = null
1543
+ var onInteractionEnded: (() -> Unit)? = null
1544
+ var activeIndex: Int? = null
1545
+ private set
1546
+ private var titles: List<String> = emptyList()
1547
+ private var normalColor = Color.GRAY
1548
+ private var activeColor = Color.BLACK
1549
+ private var lastTouchIndex: Int? = null
1550
+ private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1551
+ textAlign = Paint.Align.CENTER
1552
+ typeface = NativeListFonts.medium(context)
1553
+ }
1554
+ private val activePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1555
+ textAlign = Paint.Align.CENTER
1556
+ typeface = NativeListFonts.semibold(context)
1557
+ }
1558
+
1559
+ init {
1560
+ isClickable = true
1561
+ isFocusable = true
1562
+ importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
1563
+ contentDescription = ACCESSIBILITY_LABEL
1564
+ }
1565
+
1566
+ fun configure(titles: List<String>, normalColor: Int, activeColor: Int) {
1567
+ this.titles = titles
1568
+ this.normalColor = normalColor
1569
+ this.activeColor = activeColor
1570
+ activeIndex = null
1571
+ updateContentDescription()
1572
+ invalidate()
1573
+ }
1574
+
1575
+ fun setActiveIndex(index: Int?) {
1576
+ if (activeIndex == index) return
1577
+ activeIndex = index
1578
+ updateContentDescription()
1579
+ invalidate()
1580
+ }
1581
+
1582
+ override fun onDraw(canvas: Canvas) {
1583
+ super.onDraw(canvas)
1584
+ if (titles.isEmpty()) return
1585
+ val cellHeight = cellHeight()
1586
+ val originY = (height - cellHeight * titles.size) / 2f
1587
+ val textSize = NativeListScale.font(resources, 10f) * resources.displayMetrics.scaledDensity
1588
+ normalPaint.color = normalColor
1589
+ normalPaint.textSize = textSize
1590
+ activePaint.color = activeColor
1591
+ activePaint.textSize = textSize
1592
+ titles.forEachIndexed { index, title ->
1593
+ val paint = if (index == activeIndex) activePaint else normalPaint
1594
+ val centerY = originY + cellHeight * (index + 0.5f)
1595
+ val baseline = centerY - (paint.descent() + paint.ascent()) / 2f
1596
+ canvas.drawText(title, width / 2f, baseline, paint)
1597
+ }
1598
+ }
1599
+
1600
+ override fun onTouchEvent(event: MotionEvent): Boolean {
1601
+ if (titles.isEmpty() || !isEnabled) return false
1602
+ when (event.actionMasked) {
1603
+ MotionEvent.ACTION_DOWN -> {
1604
+ parent?.requestDisallowInterceptTouchEvent(true)
1605
+ lastTouchIndex = null
1606
+ selectAt(event.y, interacting = true)
1607
+ return true
1608
+ }
1609
+ MotionEvent.ACTION_MOVE -> {
1610
+ selectAt(event.y, interacting = true)
1611
+ return true
1612
+ }
1613
+ MotionEvent.ACTION_UP -> {
1614
+ selectAt(event.y, interacting = true)
1615
+ lastTouchIndex = null
1616
+ parent?.requestDisallowInterceptTouchEvent(false)
1617
+ performClick()
1618
+ onInteractionEnded?.invoke()
1619
+ return true
1620
+ }
1621
+ MotionEvent.ACTION_CANCEL -> {
1622
+ lastTouchIndex = null
1623
+ parent?.requestDisallowInterceptTouchEvent(false)
1624
+ onInteractionEnded?.invoke()
1625
+ return true
1626
+ }
1627
+ }
1628
+ return super.onTouchEvent(event)
1629
+ }
1630
+
1631
+ override fun performClick(): Boolean {
1632
+ super.performClick()
1633
+ return true
1634
+ }
1635
+
1636
+ override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
1637
+ super.onInitializeAccessibilityNodeInfo(info)
1638
+ info.className = SeekBar::class.java.name
1639
+ info.isScrollable = titles.size > 1
1640
+ if (titles.isNotEmpty()) {
1641
+ info.rangeInfo = AccessibilityNodeInfo.RangeInfo.obtain(
1642
+ AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_INT,
1643
+ 0f,
1644
+ (titles.size - 1).toFloat(),
1645
+ (activeIndex ?: 0).toFloat(),
1646
+ )
1647
+ info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
1648
+ info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)
1649
+ info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS)
1650
+ }
1651
+ }
1652
+
1653
+ override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
1654
+ if (titles.isEmpty()) return super.performAccessibilityAction(action, arguments)
1655
+ val next = when (action) {
1656
+ AccessibilityNodeInfo.ACTION_SCROLL_FORWARD ->
1657
+ ((activeIndex ?: -1) + 1).coerceAtMost(titles.lastIndex)
1658
+ AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD ->
1659
+ ((activeIndex ?: 1) - 1).coerceAtLeast(0)
1660
+ AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id ->
1661
+ arguments?.getFloat(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE)?.roundToInt()
1662
+ ?.coerceIn(0, titles.lastIndex)
1663
+ else -> null
1664
+ } ?: return super.performAccessibilityAction(action, arguments)
1665
+ select(next, interacting = false)
1666
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED)
1667
+ return true
1668
+ }
1669
+
1670
+ private fun selectAt(y: Float, interacting: Boolean) {
1671
+ val cellHeight = cellHeight()
1672
+ val originY = (height - cellHeight * titles.size) / 2f
1673
+ val index = ((y - originY) / cellHeight).toInt().coerceIn(0, titles.lastIndex)
1674
+ if (interacting && lastTouchIndex == index) return
1675
+ lastTouchIndex = index.takeIf { interacting }
1676
+ select(index, interacting)
1677
+ }
1678
+
1679
+ private fun select(index: Int, interacting: Boolean) {
1680
+ onSelect?.invoke(index, interacting)
1681
+ setActiveIndex(index)
1682
+ }
1683
+
1684
+ private fun cellHeight(): Float =
1685
+ (height.toFloat() / titles.size.coerceAtLeast(1))
1686
+ .coerceAtMost(NativeListScale.dp(resources, 16f))
1687
+ .coerceAtLeast(1f)
1688
+
1689
+ private fun updateContentDescription() {
1690
+ contentDescription = activeIndex?.let { titles.getOrNull(it) }
1691
+ ?.let { "$ACCESSIBILITY_LABEL, $it" }
1692
+ ?: ACCESSIBILITY_LABEL
1693
+ }
1694
+
1695
+ companion object {
1696
+ private const val ACCESSIBILITY_LABEL = "Section index"
1697
+ }
1698
+ }
1699
+
1700
+ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {
1701
+ override fun getItemOffsets(
1702
+ outRect: android.graphics.Rect,
1703
+ view: View,
1704
+ parent: RecyclerView,
1705
+ state: RecyclerView.State,
1706
+ ) {
1707
+ if (spacing <= 0) return
1708
+ val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL
1709
+ if (horizontal) outRect.right = spacing else outRect.bottom = spacing
1710
+ }
1711
+ }
1712
+
1713
+ private class StickySectionHeaderDecoration(
1714
+ private val adapter: NativeListAdapter,
1715
+ private val context: android.content.Context,
1716
+ theme: JSONObject?,
1717
+ private val density: Float,
1718
+ ) : RecyclerView.ItemDecoration() {
1719
+ private val backgroundPaint = Paint().apply {
1720
+ color = parseColor(theme?.optString("rowBackground"), "#FFFFFF")
1721
+ }
1722
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1723
+ color = parseColor(theme?.optString("secondaryText"), "#0000009B")
1724
+ textSize = NativeListScale.font(context.resources, 14f) * density
1725
+ typeface = NativeListFonts.semibold(context)
1726
+ }
1727
+
1728
+ override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
1729
+ val manager = parent.layoutManager as? LinearLayoutManager ?: return
1730
+ val first = manager.findFirstVisibleItemPosition()
1731
+ if (first == RecyclerView.NO_POSITION) return
1732
+ var header: NativeListItem? = null
1733
+ for (index in first downTo 0) {
1734
+ val candidate = adapter.itemAt(index)
1735
+ if (candidate?.type == "sectionHeader") {
1736
+ if (candidate.json.optString("variant") == "summary") continue
1737
+ header = candidate.takeIf(::isSimpleStickySectionHeader)
1738
+ break
1739
+ }
1740
+ }
1741
+ val item = header ?: return
1742
+ val isHistory = item.json.optString("variant") == "history" ||
1743
+ item.sectionKey?.startsWith("history-") == true
1744
+ val height = NativeListScale.dp(context.resources, if (isHistory) 16 else 36)
1745
+ val textSize = NativeListScale.font(context.resources, if (isHistory) 12f else 14f)
1746
+ textPaint.textSize = textSize * density
1747
+ val horizontalInset = NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat()
1748
+ val left = parent.paddingLeft.toFloat()
1749
+ val right = (parent.width - parent.paddingRight).toFloat()
1750
+ canvas.drawRect(left, 0f, right, height.toFloat(), backgroundPaint)
1751
+ val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
1752
+ val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it }
1753
+ val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
1754
+ val textWidth = if (isHistory) {
1755
+ spacedTextWidth(value, NativeListScale.dp(context.resources, 1).toFloat())
1756
+ } else {
1757
+ textPaint.measureText(value)
1758
+ }
1759
+ val x = if (isRightToLeft) right - horizontalInset - textWidth else left + horizontalInset
1760
+ if (isHistory) {
1761
+ drawSpacedText(canvas, value, x, baseline, NativeListScale.dp(context.resources, 1).toFloat())
1762
+ } else {
1763
+ canvas.drawText(value, x, baseline, textPaint)
1764
+ }
1765
+ }
1766
+
1767
+ private fun drawSpacedText(canvas: Canvas, value: String, x: Float, baseline: Float, spacing: Float) {
1768
+ var cursor = x
1769
+ value.forEachIndexed { index, character ->
1770
+ val glyph = character.toString()
1771
+ canvas.drawText(glyph, cursor, baseline, textPaint)
1772
+ cursor += textPaint.measureText(glyph)
1773
+ if (index < value.lastIndex) cursor += spacing * 0.8f
1774
+ }
1775
+ }
1776
+
1777
+ private fun spacedTextWidth(value: String, spacing: Float): Float =
1778
+ value.sumOf { textPaint.measureText(it.toString()).toDouble() }.toFloat() +
1779
+ max(0, value.length - 1) * spacing * 0.8f
1780
+
1781
+ companion object {
1782
+ private fun parseColor(value: String?, fallback: String): Int = try {
1783
+ parseNativeListColor(if (value.isNullOrEmpty()) fallback else value)
1784
+ } catch (_: IllegalArgumentException) {
1785
+ parseNativeListColor(fallback)
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean =
1791
+ item.type == "sectionHeader" &&
1792
+ item.json.optString("variant") != "summary" &&
1793
+ item.json.optString("value").isEmpty() &&
1794
+ item.json.optJSONObject("checkbox") == null