@onekeyfe/react-native-native-list 3.0.157-alpha.254 → 3.0.157-alpha.255

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 (87) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
  5. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
  10. package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +163 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +4 -0
  14. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
  15. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
  16. package/docs/DESIGN.md +4 -2
  17. package/docs/ROW_TEMPLATES.md +45 -0
  18. package/docs/SPEC.md +227 -1
  19. package/docs/STYLE_SPEC.md +8 -0
  20. package/ios/HybridNativeList.swift +30 -0
  21. package/ios/NativeListActivityDetailsView.swift +229 -0
  22. package/ios/NativeListActivityRenderer.swift +24 -2
  23. package/ios/NativeListMediaPreviewSlot.swift +199 -0
  24. package/ios/NativeListMediaTileRenderer.swift +10 -4
  25. package/ios/NativeListModels.swift +4 -1
  26. package/ios/NativeListScrollPositionTracker.swift +30 -0
  27. package/ios/NativeListSectionHeaderRenderer.swift +27 -1
  28. package/ios/RNCNativeListView.swift +159 -4
  29. package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
  30. package/ios/tests/NativeListLifecycleTests.swift +123 -0
  31. package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
  32. package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
  33. package/lib/module/NativeList.js +56 -3
  34. package/lib/module/NativeList.web.js +5 -0
  35. package/lib/module/validation.js +64 -4
  36. package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  37. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  38. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  39. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  40. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  41. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  42. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  43. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  44. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  45. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  46. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  47. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  48. package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  49. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  50. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  51. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  52. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  53. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  54. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  55. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  56. package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
  57. package/lib/typescript/src/NativeList.types.d.ts +14 -1
  58. package/lib/typescript/src/models.d.ts +33 -5
  59. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  60. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  61. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  66. package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  67. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  68. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  69. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  70. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  71. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  72. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  73. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  74. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  75. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  76. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  77. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  78. package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  79. package/package.json +6 -5
  80. package/scripts/enable-container-slots.mjs +58 -0
  81. package/scripts/enable-container-slots.test.mjs +58 -0
  82. package/src/NativeList.nitro.ts +3 -0
  83. package/src/NativeList.tsx +84 -2
  84. package/src/NativeList.types.ts +14 -1
  85. package/src/NativeList.web.tsx +5 -0
  86. package/src/models.ts +38 -1
  87. package/src/validation.ts +146 -4
@@ -94,11 +94,18 @@ repositories {
94
94
  }
95
95
 
96
96
  def kotlinVersion = getExtOrDefault("kotlinVersion")
97
+ // Keep native media preview formats aligned with react-native-video's default.
98
+ def media3Version = rootProject.ext.has("media3Version") ? rootProject.ext.get("media3Version") : "1.4.1"
97
99
 
98
100
  dependencies {
99
101
  implementation "com.facebook.react:react-android"
100
102
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
101
103
  implementation "androidx.recyclerview:recyclerview:1.4.0"
104
+ implementation "androidx.media3:media3-exoplayer:$media3Version"
105
+ implementation "androidx.media3:media3-exoplayer-hls:$media3Version"
106
+ implementation "androidx.media3:media3-exoplayer-dash:$media3Version"
107
+ implementation "androidx.media3:media3-ui:$media3Version"
108
+ implementation "androidx.media3:media3-datasource-okhttp:$media3Version"
102
109
  implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
103
110
  implementation project(":react-native-nitro-modules")
104
111
  implementation project(":onekeyfe_react-native-native-logger")
@@ -11,6 +11,26 @@ class HybridNativeList(context: ThemedReactContext) : HybridNativeListSpec() {
11
11
 
12
12
  override val view: View = hostView
13
13
 
14
+ override var scrollPositionThresholdsJson: String = ""
15
+ set(value) {
16
+ if (field == value) return
17
+ field = value
18
+ dispatchToUi { hostView.setScrollPositionThresholdsJson(value) }
19
+ }
20
+
21
+ override var onScrollPositionThresholdChange: ((isBeyondThreshold: Boolean) -> Unit)? = null
22
+ set(value) {
23
+ field = value
24
+ dispatchToUi { hostView.onScrollPositionThresholdChange = value }
25
+ }
26
+
27
+ override var containerSlotHeightsJson: String = "[]"
28
+ set(value) {
29
+ if (field == value) return
30
+ field = value
31
+ dispatchToUi { hostView.setContainerSlotHeightsJson(value) }
32
+ }
33
+
14
34
  override var snapshotJson: String = ""
15
35
  get() = field
16
36
  set(value) {
@@ -0,0 +1,179 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import android.graphics.drawable.GradientDrawable
4
+ import android.text.Spannable
5
+ import android.text.SpannableStringBuilder
6
+ import android.text.style.AbsoluteSizeSpan
7
+ import android.view.Gravity
8
+ import android.view.View
9
+ import android.widget.LinearLayout
10
+ import com.facebook.react.uimanager.ThemedReactContext
11
+ import kotlin.math.roundToInt
12
+ import org.json.JSONObject
13
+
14
+ /** Bounded rich activity fields; no wallet or transaction semantics live here. */
15
+ internal class NativeListActivityDetailsView(private val reactContext: ThemedReactContext) : LinearLayout(reactContext) {
16
+ var onAction: ((String, View, String, Int) -> Unit)? = null
17
+ private var sourceScale = false
18
+ private val main = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
19
+ private val visual = NativeListLeadingVisual(reactContext)
20
+ private val identity = LinearLayout(context).apply { orientation = VERTICAL }
21
+ private val titleLine = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
22
+ private val title = NativeListTextView(context)
23
+ private val description = NativeListTextView(context)
24
+ private val status = NativeListTextView(context)
25
+ private val amounts = LinearLayout(context).apply { orientation = VERTICAL }
26
+ private val fee = LinearLayout(context).apply { orientation = VERTICAL; gravity = Gravity.END }
27
+ private val feeLabel = NativeListTextView(context)
28
+ private val feeLine = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.END }
29
+ private val feePrimary = NativeListTextView(context)
30
+ private val feeSecondary = NativeListTextView(context)
31
+ private val actions = LinearLayout(context).apply { orientation = HORIZONTAL }
32
+ private val amountViews = mutableListOf<AmountView>()
33
+ private val badges = mutableListOf<View>()
34
+
35
+ init {
36
+ orientation = VERTICAL
37
+ titleLine.addView(title, LayoutParams(0, -2, 1f))
38
+ identity.addView(titleLine)
39
+ identity.addView(description)
40
+ identity.addView(status)
41
+ feeLine.addView(feePrimary); feeLine.addView(feeSecondary)
42
+ fee.addView(feeLabel); fee.addView(feeLine)
43
+ main.addView(visual)
44
+ main.addView(identity, LayoutParams(0, -2, 1f))
45
+ main.addView(amounts)
46
+ main.addView(fee)
47
+ addView(main, LayoutParams(-1, -2))
48
+ addView(actions, LayoutParams(-1, -2))
49
+ }
50
+ private fun dp(value: Int) = if (sourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value)
51
+ private fun px(value: Double) = (value * resources.displayMetrics.density).roundToInt()
52
+ private fun color(tone: String, theme: JSONObject?): Int {
53
+ val (key, fallback) = when (tone) {
54
+ "positive", "success" -> "positive" to "#218358"
55
+ "negative", "danger" -> "negative" to "#CE2C31"
56
+ "secondary" -> "secondaryText" to "#646464"
57
+ "warning" -> "warning" to "#AB6400"
58
+ "info" -> "info" to "#007BEF"
59
+ else -> "primaryText" to "#202020"
60
+ }
61
+ return parseNativeListColor(theme?.optString(key, fallback) ?: fallback)
62
+ }
63
+ private fun text(label: NativeListTextView, value: String, style: JSONObject?, size: Float,
64
+ ink: Int, line: Int, weight: String = "regular", lines: Int = 1) {
65
+ NativeListResolvedText.resolve(context, value, style, size, weight, ink, line, lines, sourceScale).bind(label)
66
+ }
67
+ fun bind(item: NativeListItem, theme: JSONObject?, sourceScale: Boolean) {
68
+ this.sourceScale = sourceScale
69
+ val data = item.json
70
+ val style = data.optJSONObject("style") ?: JSONObject()
71
+ val image = style.optJSONObject("image") ?: JSONObject()
72
+ val table = data.optString("presentation") == "table"
73
+ val gap = if (style.has("leadingGap")) px(style.optDouble("leadingGap")) else dp(12)
74
+ val iw = if (image.has("width")) px(image.optDouble("width")) else dp(40)
75
+ visual.layoutParams = LayoutParams(iw, if (image.has("height")) px(image.optDouble("height")) else dp(40)).apply { marginEnd = gap }
76
+ visual.bind(data.optJSONObject("leading") ?: JSONObject(), image, item.key, theme, false, sourceScale, data.optJSONObject("secondaryLeading"))
77
+ amounts.layoutParams = if (table) LayoutParams(0, -2, 1f).apply { marginStart = gap } else LayoutParams(-2, -2).apply { marginStart = gap }
78
+ text(title, data.optString("title"), style.optJSONObject("title"), 16f, color("primary", theme), 24, "medium")
79
+ text(description, data.optString("description"), style.optJSONObject("description"), 14f, color("secondary", theme), 20, lines = 2)
80
+ text(status, data.optString("status"), style.optJSONObject("status"), 12f, color("secondary", theme), 16)
81
+ description.setOnClickListener(null)
82
+ val descriptionKey = data.optString("descriptionActionKey")
83
+ description.isClickable = descriptionKey.isNotEmpty()
84
+ if (descriptionKey.isNotEmpty()) description.setOnClickListener { onAction?.invoke(descriptionKey, description, "description", 0) }
85
+ for (view in listOf(description, status)) (view.layoutParams as LayoutParams).topMargin = if (style.has("lineGap")) px(style.optDouble("lineGap")) else 0
86
+ badges.forEach(titleLine::removeView); badges.clear()
87
+ val badgeData = data.optJSONArray("badges")
88
+ for (index in 0 until (badgeData?.length() ?: 0)) {
89
+ val descriptor = badgeData!!.getJSONObject(index)
90
+ val label = NativeListTextView(context)
91
+ val ink = color(descriptor.optString("tone"), theme)
92
+ text(label, descriptor.optString("text"), null, 12f, ink, 16, "medium")
93
+ label.setPadding(dp(4), 0, dp(4), 0)
94
+ label.background = GradientDrawable().apply { setColor((ink and 0x00ffffff) or 0x19000000); cornerRadius = dp(4).toFloat() }
95
+ titleLine.addView(label, LayoutParams(-2, -2).apply { marginStart = if (style.has("titleBadgeGap")) px(style.optDouble("titleBadgeGap")) else dp(4) })
96
+ badges.add(label)
97
+ }
98
+ val amountData = data.optJSONArray("amounts")
99
+ val count = amountData?.length() ?: 0
100
+ while (amountViews.size > count) { val child = amountViews.removeAt(amountViews.lastIndex); child.recycle(); amounts.removeView(child) }
101
+ while (amountViews.size < count) { val child = AmountView(); amountViews.add(child); amounts.addView(child) }
102
+ for (index in 0 until count) {
103
+ val descriptor = amountData!!.getJSONObject(index)
104
+ amountViews[index].bind(descriptor, "${item.key}:${descriptor.optString("key")}", style, theme, table)
105
+ amountViews[index].layoutParams = LayoutParams(-1, -2).apply { topMargin = if (index == 0) 0 else if (style.has("trailingGap")) px(style.optDouble("trailingGap")) else dp(2) }
106
+ }
107
+ val feeData = data.optJSONObject("fee")
108
+ fee.visibility = if (feeData == null) GONE else VISIBLE
109
+ fee.alpha = if (feeData?.optBoolean("hidden") == true) 0f else 1f
110
+ fee.layoutParams = LayoutParams(-2, -2).apply { marginStart = gap }
111
+ text(feeLabel, feeData?.optString("label") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16)
112
+ text(feePrimary, feeData?.optString("primary") ?: "", style.optJSONObject("secondaryAmount"), 14f, color("primary", theme), 20)
113
+ text(feeSecondary, feeData?.optString("secondary") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16)
114
+ (feeSecondary.layoutParams as LayoutParams).marginStart = dp(4)
115
+ applySegments(feePrimary, feeData?.optJSONArray("primaryTextSegments"), style.optJSONObject("secondaryAmount")?.optDouble("fontSize", 14.0) ?: 14.0)
116
+ applySegments(feeSecondary, feeData?.optJSONArray("secondaryTextSegments"), style.optJSONObject("secondaryAmount")?.optDouble("fontSize", 12.0) ?: 12.0)
117
+ actions.removeAllViews()
118
+ val actionData = data.optJSONArray("footerActions")
119
+ actions.visibility = if ((actionData?.length() ?: 0) == 0) GONE else VISIBLE
120
+ actions.layoutParams = LayoutParams(-1, -2).apply { marginStart = iw + gap; topMargin = dp(8) }
121
+ for (index in 0 until (actionData?.length() ?: 0)) {
122
+ val descriptor = actionData!!.getJSONObject(index)
123
+ val button = NativeListTextView(context)
124
+ text(button, descriptor.optString("label"), null, 14f, color(descriptor.optString("tone"), theme), 20, "medium")
125
+ button.setPadding(dp(8), dp(4), dp(8), dp(4))
126
+ button.background = GradientDrawable().apply { setColor(parseNativeListColor(theme?.optString("strongBackground", "#F0F0F0") ?: "#F0F0F0")); cornerRadius = dp(8).toFloat() }
127
+ button.isEnabled = !data.optBoolean("disabled") && !descriptor.optBoolean("disabled")
128
+ button.setOnClickListener { onAction?.invoke(descriptor.optString("key"), button, "footerAction", index) }
129
+ actions.addView(button, LayoutParams(-2, -2).apply { marginEnd = dp(8) })
130
+ }
131
+ }
132
+ private fun applySegments(label: NativeListTextView, segments: org.json.JSONArray?, fontSize: Double) {
133
+ if (segments == null || segments.length() == 0) return
134
+ val fullText = (0 until segments.length()).joinToString("") { segments.getJSONObject(it).optString("text") }
135
+ val styled = if (label.text.toString() == fullText) SpannableStringBuilder(label.text) else SpannableStringBuilder(fullText)
136
+ var start = 0
137
+ for (index in 0 until segments.length()) {
138
+ val segment = segments.getJSONObject(index)
139
+ val end = start + segment.optString("text").length
140
+ if (segment.optString("style") == "subscript") {
141
+ // Match NumberSizeableText: shrink the zero count without moving its baseline.
142
+ val size = kotlin.math.ceil(fontSize * 0.6) * label.textSize / fontSize
143
+ styled.setSpan(AbsoluteSizeSpan(size.roundToInt()), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
144
+ }
145
+ start = end
146
+ }
147
+ label.text = styled
148
+ }
149
+ fun recycle() { visual.recycle(); amountViews.forEach { it.recycle() }; description.setOnClickListener(null); actions.removeAllViews() }
150
+ fun dispose() { recycle(); visual.dispose(); amountViews.forEach { it.dispose() } }
151
+
152
+ private inner class AmountView : LinearLayout(context) {
153
+ private val leading = NativeListLeadingVisual(reactContext)
154
+ private val labels = LinearLayout(context).apply { orientation = VERTICAL }
155
+ private val primary = NativeListTextView(context)
156
+ private val secondary = NativeListTextView(context)
157
+ init {
158
+ orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL
159
+ labels.addView(primary); labels.addView(secondary)
160
+ addView(leading); addView(labels, LayoutParams(0, -2, 1f))
161
+ }
162
+ fun bind(data: JSONObject, key: String, style: JSONObject, theme: JSONObject?, table: Boolean) {
163
+ val visualData = data.optJSONObject("leading")
164
+ leading.visibility = if (visualData == null) GONE else VISIBLE
165
+ leading.layoutParams = LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(6) }
166
+ if (visualData != null) leading.bind(visualData, JSONObject().put("width", 20).put("height", 20), key, theme, false, sourceScale) else leading.recycle()
167
+ val primaryStyle = JSONObject(style.optJSONObject("primaryAmount")?.toString() ?: "{}")
168
+ if (!primaryStyle.has("alignment")) primaryStyle.put("alignment", if (table) "start" else "end")
169
+ text(primary, data.optString("text"), primaryStyle, 16f, color(data.optString("tone"), theme), 24, "medium")
170
+ applySegments(primary, data.optJSONArray("textSegments"), primaryStyle.optDouble("fontSize", 16.0))
171
+ val secondaryStyle = JSONObject(style.optJSONObject("secondaryAmount")?.toString() ?: "{}")
172
+ if (!secondaryStyle.has("alignment")) secondaryStyle.put("alignment", if (table) "start" else "end")
173
+ text(secondary, data.optString("secondaryText"), secondaryStyle, 14f, color("secondary", theme), 20)
174
+ applySegments(secondary, data.optJSONArray("secondaryTextSegments"), secondaryStyle.optDouble("fontSize", 14.0))
175
+ }
176
+ fun recycle() { leading.recycle() }
177
+ fun dispose() { leading.dispose() }
178
+ }
179
+ }
@@ -9,6 +9,7 @@ import org.json.JSONObject
9
9
 
10
10
  internal class NativeListActivityRowView(context: ThemedReactContext) :
11
11
  NativeListRendererRowView(context) {
12
+ private val details = NativeListActivityDetailsView(context)
12
13
  private val content =
13
14
  LinearLayout(context).apply {
14
15
  orientation = HORIZONTAL
@@ -33,7 +34,7 @@ internal class NativeListActivityRowView(context: ThemedReactContext) :
33
34
  private val values = List(2) { NativeListTextView(context) }
34
35
  private val actions = LinearLayout(context).apply { orientation = HORIZONTAL }
35
36
  private val buttons = List(3) { NativeListTextView(context) }
36
- override val assetFields = listOf("leading", "secondaryLeading")
37
+ override val assetFields = listOf("leading", "secondaryLeading", "amounts")
37
38
 
38
39
  init {
39
40
  titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
@@ -48,10 +49,13 @@ internal class NativeListActivityRowView(context: ThemedReactContext) :
48
49
  content.addView(amounts)
49
50
  addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
50
51
  addView(actions)
52
+ addView(details, LayoutParams(-1, -2))
53
+ details.visibility = GONE
54
+ details.onAction = { key, view, source, slot -> emitAction(key, view, source, slot) }
51
55
  }
52
56
 
53
57
  override fun defaultHeight(item: NativeListItem, layout: String) =
54
- if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60
58
+ if (item.json.has("amounts")) 0 else if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60
55
59
 
56
60
  override fun bindContent(
57
61
  item: NativeListItem,
@@ -60,6 +64,21 @@ internal class NativeListActivityRowView(context: ThemedReactContext) :
60
64
  checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String,
61
65
  ) {
62
66
  val data = item.json
67
+ val rich = data.has("amounts")
68
+ details.visibility = if (rich) VISIBLE else GONE
69
+ content.visibility = if (rich) GONE else VISIBLE
70
+ actions.visibility = if (rich) GONE else VISIBLE
71
+ if (rich) {
72
+ orientation = VERTICAL
73
+ val style = data.optJSONObject("style") ?: JSONObject()
74
+ val hp = if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) else dp(if (data.optString("presentation") == "table") 16 else 12)
75
+ val vp = if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8)
76
+ setPadding(hp, vp, hp, vp)
77
+ visual.recycle()
78
+ details.bind(item, theme, sourceScale)
79
+ return
80
+ }
81
+ details.recycle()
63
82
  val style = data.optJSONObject("style") ?: JSONObject()
64
83
  val image = style.optJSONObject("image") ?: JSONObject()
65
84
  fun color(name: String, fallback: String) =
@@ -233,11 +252,13 @@ internal class NativeListActivityRowView(context: ThemedReactContext) :
233
252
 
234
253
  override fun recycleContent() {
235
254
  visual.recycle()
255
+ details.recycle()
236
256
  buttons.forEach { it.setOnClickListener(null) }
237
257
  }
238
258
 
239
259
  override fun disposeContent() {
240
260
  visual.dispose()
261
+ details.dispose()
241
262
  buttons.forEach { it.setOnClickListener(null) }
242
263
  }
243
264
  }
@@ -0,0 +1,53 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import android.view.View
4
+ import android.view.ViewGroup
5
+ import android.widget.FrameLayout
6
+ import androidx.recyclerview.widget.RecyclerView
7
+
8
+ /** A persistent React container slot; never a template row or a selectable item. */
9
+ internal class NativeListContainerSlotAdapter : RecyclerView.Adapter<NativeListContainerSlotAdapter.Holder>() {
10
+ class Holder(val container: FrameLayout) : RecyclerView.ViewHolder(container)
11
+ private var content: View? = null
12
+ private var height = 0
13
+ private var enabled = true
14
+
15
+ fun update(view: View?, heightPx: Int, visible: Boolean) {
16
+ val previousCount = itemCount
17
+ val changed = content !== view || height != heightPx || enabled != visible
18
+ content = view
19
+ height = heightPx.coerceAtLeast(0)
20
+ enabled = visible
21
+ val nextCount = itemCount
22
+ if (previousCount == 0 && nextCount == 1) notifyItemInserted(0)
23
+ else if (previousCount == 1 && nextCount == 0) notifyItemRemoved(0)
24
+ else if (nextCount == 1 && changed) notifyItemChanged(0)
25
+ }
26
+
27
+ override fun getItemCount(): Int = if (enabled && content != null && height > 0) 1 else 0
28
+
29
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
30
+ val container = object : FrameLayout(parent.context) {
31
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
32
+ for (index in 0 until childCount) {
33
+ getChildAt(index).layout(0, 0, right - left, bottom - top)
34
+ }
35
+ }
36
+ }
37
+ container.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)
38
+ return Holder(container)
39
+ }
40
+
41
+ override fun onBindViewHolder(holder: Holder, position: Int) {
42
+ holder.container.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)
43
+ holder.container.removeAllViews()
44
+ content?.let { view ->
45
+ (view.parent as? ViewGroup)?.removeView(view)
46
+ holder.container.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height))
47
+ }
48
+ }
49
+
50
+ override fun onViewRecycled(holder: Holder) {
51
+ holder.container.removeAllViews()
52
+ }
53
+ }
@@ -0,0 +1,5 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ // Negative offsets are required when a container slot is taller than the viewport.
4
+ internal fun nativeListEndAlignmentOffset(viewportEnd: Int, contentEnd: Int): Int =
5
+ viewportEnd - contentEnd
@@ -0,0 +1,215 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import android.graphics.Rect
4
+ import android.view.ViewTreeObserver
5
+ import android.view.Gravity
6
+ import android.view.TextureView
7
+ import android.view.View
8
+ import android.widget.FrameLayout
9
+ import androidx.annotation.OptIn
10
+ import androidx.media3.common.MediaItem
11
+ import androidx.media3.common.PlaybackException
12
+ import androidx.media3.common.Player
13
+ import androidx.media3.common.VideoSize
14
+ import androidx.media3.common.util.UnstableApi
15
+ import androidx.media3.common.util.Util
16
+ import androidx.media3.datasource.DefaultDataSource
17
+ import androidx.media3.datasource.okhttp.OkHttpDataSource
18
+ import androidx.media3.exoplayer.ExoPlayer
19
+ import androidx.media3.exoplayer.DefaultLoadControl
20
+ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
21
+ import androidx.media3.ui.AspectRatioFrameLayout
22
+ import com.facebook.react.modules.network.ForwardingCookieHandler
23
+ import com.facebook.react.modules.network.OkHttpClientProvider
24
+ import com.facebook.react.uimanager.ThemedReactContext
25
+ import okhttp3.JavaNetCookieJar
26
+ import org.json.JSONObject
27
+
28
+ /** Uses Media3 like native Video, but never starts playback or requests audio focus. */
29
+ @OptIn(UnstableApi::class)
30
+ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContext) {
31
+ val view: FrameLayout = object : FrameLayout(context) {
32
+ override fun onAttachedToWindow() { super.onAttachedToWindow(); observeViewport(); updateVisibility() }
33
+ override fun onDetachedFromWindow() { stopObservingViewport(); releasePlayer(); super.onDetachedFromWindow() }
34
+ override fun onWindowVisibilityChanged(visibility: Int) { super.onWindowVisibilityChanged(visibility); updateVisibility() }
35
+ override fun onVisibilityAggregated(isVisible: Boolean) { super.onVisibilityAggregated(isVisible); if (isVisible) updateVisibility() else releasePlayer() }
36
+ }
37
+ private val image = NativeListImageSlot(context)
38
+ private val video = AspectRatioFrameLayout(context)
39
+ private val texture = TextureView(context)
40
+ private var identity: String? = null
41
+ private var epoch = 0
42
+ private var candidateGeneration = 0
43
+ private var playerGeneration = 0
44
+ private var result: Boolean? = null
45
+ private var completion: ((Boolean) -> Unit)? = null
46
+ private var pendingCompletion: Runnable? = null
47
+ private var player: ExoPlayer? = null
48
+ private var resumeVideo: (() -> Unit)? = null
49
+ private var viewportObserver: ViewTreeObserver? = null
50
+ private val visibleRect = Rect()
51
+ private val scrollListener = ViewTreeObserver.OnScrollChangedListener { updateVisibility() }
52
+ private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { updateVisibility() }
53
+ // Native property animations can move a retained pager page without a layout pass.
54
+ private val drawListener = ViewTreeObserver.OnPreDrawListener { updateVisibility(); true }
55
+
56
+ init {
57
+ view.isClickable = false
58
+ view.isFocusable = false
59
+ view.descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
60
+ video.addView(texture, FrameLayout.LayoutParams(-1, -1))
61
+ view.addView(image.view, FrameLayout.LayoutParams(-1, -1))
62
+ view.addView(video, FrameLayout.LayoutParams(-1, -1, Gravity.CENTER))
63
+ video.visibility = View.GONE
64
+ video.setBackgroundColor(android.graphics.Color.BLACK)
65
+ texture.isOpaque = false
66
+ }
67
+
68
+ fun bind(source: JSONObject, key: String, fit: String? = null,
69
+ placeholder: String = "#0000000F", probeOrder: List<String> = listOf("image"),
70
+ completion: ((Boolean) -> Unit)? = null) {
71
+ val next = nativeListCanonical(JSONObject().put("source", source).put("key", key)
72
+ .put("fit", fit ?: "").put("order", org.json.JSONArray(probeOrder)).put("placeholder", placeholder))
73
+ this.completion = completion
74
+ if (identity == next) {
75
+ result?.let { completion?.invoke(it) }
76
+ updateVisibility()
77
+ return
78
+ }
79
+ recycle()
80
+ identity = next
81
+ this.completion = completion
82
+ video.resizeMode = if ((fit ?: source.optString("contentFit")) == "cover") AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
83
+ probe(source, key, fit, placeholder, probeOrder, 0, epoch)
84
+ }
85
+
86
+ private fun probe(source: JSONObject, key: String, fit: String?, placeholder: String,
87
+ order: List<String>, attempt: Int, token: Int) {
88
+ if (token != epoch) return
89
+ candidateGeneration++
90
+ val candidate = candidateGeneration
91
+ resumeVideo = null
92
+ stopObservingViewport()
93
+ releasePlayer()
94
+ if (attempt >= order.size) {
95
+ result = false
96
+ completion?.invoke(false)
97
+ return
98
+ }
99
+ fun finished(loaded: Boolean, expectedPlayerGeneration: Int? = null) {
100
+ if (epoch != token || candidateGeneration != candidate) return
101
+ pendingCompletion?.let(view::removeCallbacks)
102
+ // Glide forbids clearing/rebinding a request from either terminal callback.
103
+ // Media3 callbacks also defer release so the emitting player is not reentered.
104
+ val task = Runnable {
105
+ pendingCompletion = null
106
+ if (epoch != token || candidateGeneration != candidate ||
107
+ (expectedPlayerGeneration != null && playerGeneration != expectedPlayerGeneration)) return@Runnable
108
+ if (loaded) {
109
+ if (result != true) { result = true; completion?.invoke(true) }
110
+ } else probe(source, key, fit, placeholder, order, attempt + 1, token)
111
+ }
112
+ pendingCompletion = task
113
+ view.post(task)
114
+ }
115
+ if (order[attempt] == "image") {
116
+ video.visibility = View.GONE
117
+ image.view.visibility = View.VISIBLE
118
+ image.bind(source, key, fit = fit, placeholder = placeholder, completion = { loaded -> finished(loaded) })
119
+ return
120
+ }
121
+ image.recycle()
122
+ image.view.visibility = View.GONE
123
+ video.visibility = View.VISIBLE
124
+ val uri = source.optString("uri")
125
+ if (uri.isBlank()) { finished(false); return }
126
+ val headers = source.optJSONObject("headers")?.let { values -> values.keys().asSequence().associateWith { values.optString(it) } } ?: emptyMap()
127
+ resumeVideo = start@{
128
+ if (epoch != token || candidateGeneration != candidate || player != null) return@start
129
+ playerGeneration++
130
+ val generation = playerGeneration
131
+ try {
132
+ val client = OkHttpClientProvider.getOkHttpClient().newBuilder()
133
+ .cookieJar(JavaNetCookieJar(ForwardingCookieHandler(context))).build()
134
+ val http = OkHttpDataSource.Factory(client).setDefaultRequestProperties(headers)
135
+ if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) http.setUserAgent(Util.getUserAgent(context, context.packageName))
136
+ val sources = DefaultMediaSourceFactory(DefaultDataSource.Factory(context, http))
137
+ val buffers = DefaultLoadControl.Builder()
138
+ .setBufferDurationsMs(250, 1000, 250, 250)
139
+ .setTargetBufferBytes(2 * 1024 * 1024)
140
+ .setPrioritizeTimeOverSizeThresholds(false)
141
+ .setBackBuffer(0, false).build()
142
+ val next = ExoPlayer.Builder(context.applicationContext)
143
+ .setMediaSourceFactory(sources).setLoadControl(buffers).build()
144
+ player = next
145
+ next.volume = 0f
146
+ next.playWhenReady = false
147
+ next.setAudioAttributes(androidx.media3.common.AudioAttributes.DEFAULT, false)
148
+ next.setVideoTextureView(texture)
149
+ next.addListener(object : Player.Listener {
150
+ override fun onRenderedFirstFrame() { if (playerGeneration == generation) finished(true, generation) }
151
+ override fun onPlayerError(error: PlaybackException) { if (playerGeneration == generation) finished(false, generation) }
152
+ override fun onVideoSizeChanged(size: VideoSize) {
153
+ if (playerGeneration == generation && size.height > 0) video.setAspectRatio(size.width * size.pixelWidthHeightRatio / size.height)
154
+ }
155
+ })
156
+ next.setMediaItem(MediaItem.fromUri(uri))
157
+ next.prepare()
158
+ next.pause()
159
+ } catch (_: Exception) {
160
+ releasePlayer()
161
+ finished(false)
162
+ }
163
+ }
164
+ observeViewport()
165
+ updateVisibility()
166
+ }
167
+
168
+ private fun observeViewport() {
169
+ if (viewportObserver != null || resumeVideo == null || !view.isAttachedToWindow) return
170
+ view.viewTreeObserver.takeIf { it.isAlive }?.let {
171
+ it.addOnScrollChangedListener(scrollListener)
172
+ it.addOnGlobalLayoutListener(layoutListener)
173
+ it.addOnPreDrawListener(drawListener)
174
+ viewportObserver = it
175
+ }
176
+ }
177
+ private fun stopObservingViewport() {
178
+ viewportObserver?.takeIf { it.isAlive }?.let {
179
+ it.removeOnScrollChangedListener(scrollListener)
180
+ it.removeOnGlobalLayoutListener(layoutListener)
181
+ it.removeOnPreDrawListener(drawListener)
182
+ }
183
+ viewportObserver = null
184
+ }
185
+ private fun updateVisibility() {
186
+ var ancestor: View? = view
187
+ var opaque = true
188
+ while (ancestor != null) {
189
+ if (ancestor.alpha <= 0.01f) { opaque = false; break }
190
+ ancestor = ancestor.parent as? View
191
+ }
192
+ if (opaque && view.isAttachedToWindow && view.windowVisibility == View.VISIBLE && view.isShown &&
193
+ view.getGlobalVisibleRect(visibleRect) && !visibleRect.isEmpty) resumeVideo?.invoke()
194
+ else releasePlayer()
195
+ }
196
+ private fun releasePlayer() {
197
+ playerGeneration++
198
+ player?.let { it.pause(); it.clearVideoTextureView(texture); it.release() }
199
+ player = null
200
+ }
201
+ fun recycle() {
202
+ epoch++; candidateGeneration++
203
+ pendingCompletion?.let(view::removeCallbacks)
204
+ pendingCompletion = null
205
+ resumeVideo = null
206
+ stopObservingViewport()
207
+ releasePlayer()
208
+ image.recycle()
209
+ image.view.visibility = View.VISIBLE
210
+ video.visibility = View.GONE
211
+ video.setAspectRatio(0f)
212
+ identity = null; result = null; completion = null
213
+ }
214
+ fun dispose() { recycle(); image.dispose() }
215
+ }
@@ -14,7 +14,7 @@ import org.json.JSONObject
14
14
  internal class NativeListMediaTileRowView(context: ThemedReactContext) :
15
15
  NativeListRendererRowView(context) {
16
16
  private val picture = FrameLayout(context)
17
- private val image = NativeListImageSlot(context)
17
+ private val image = NativeListMediaPreviewSlot(context)
18
18
  private val network = NativeListImageSlot(context)
19
19
  private val errorIcon = OneKeyIconView(context)
20
20
  private val title = NativeListTextView(context)
@@ -32,7 +32,7 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) :
32
32
  override val defaultCornerRadius = 16
33
33
  override val showsSelection = false
34
34
  override val pressChangesBackground = false
35
- override val assetFields = listOf("image", "networkImage")
35
+ override val assetFields = listOf("image", "media", "networkImage")
36
36
 
37
37
  override fun horizontalWidth(item: NativeListItem) = dp(200)
38
38
 
@@ -141,15 +141,24 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) :
141
141
  errorIcon.tintColor = parseNativeListColor("#00000044")
142
142
  errorIcon.useSourceScale = sourceScale
143
143
  errorIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER)
144
- if (state != "empty" && state != "error" && item.json.has("image"))
144
+ val media = item.json.optJSONObject("media")
145
+ val source = media?.optJSONObject("source") ?: item.json.optJSONObject("image")
146
+ val order = media?.optJSONArray("probeOrder")?.let { values ->
147
+ (0 until values.length()).map { values.optString(it) }
148
+ } ?: listOf("image")
149
+ if (state != "empty" && state != "error" && source != null)
145
150
  image.bind(
146
- item.json.getJSONObject("image"),
151
+ source,
147
152
  "${item.key}:media",
148
153
  fit = imageStyle.optString("contentFit").takeIf { it.isNotEmpty() },
149
154
  placeholder =
150
155
  theme?.optString("strongBackground", "#0000000F")?.takeIf(String::isNotEmpty)
151
156
  ?: "#0000000F",
152
- )
157
+ probeOrder = order,
158
+ ) { loaded ->
159
+ errorIcon.visibility = if (loaded) GONE else VISIBLE
160
+ if (!loaded) image.view.setBackgroundColor(tint("strongBackground", "#0000000F"))
161
+ }
153
162
  else image.recycle()
154
163
  // Legacy: only the empty/error states paint a slot fill; loaded images show
155
164
  // their own placeholder without a white backing.
@@ -157,7 +166,7 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) :
157
166
  when (state) {
158
167
  "error" -> GradientDrawable().apply { setColor(tint("strongBackground", "#0000000F")) }
159
168
  "empty" -> GradientDrawable().apply { setColor(Color.WHITE) }
160
- else -> null
169
+ else -> if (errorIcon.visibility == VISIBLE) GradientDrawable().apply { setColor(tint("strongBackground", "#0000000F")) } else null
161
170
  }
162
171
  image.view.outlineProvider =
163
172
  object : ViewOutlineProvider() {
@@ -156,6 +156,7 @@ internal data class NativeListConfig(
156
156
  val selectedKeys: LinkedHashSet<String>,
157
157
  val reorderable: Boolean,
158
158
  val pullToRefresh: Boolean,
159
+ val refreshTriggerDistance: Double? = null,
159
160
  val refreshing: Boolean,
160
161
  val loadMore: Boolean,
161
162
  val endReachedThreshold: Double,
@@ -194,7 +195,7 @@ internal data class NativeListConfig(
194
195
  generation = root.getInt("generation"),
195
196
  layout = layout.getString("kind"),
196
197
  orientation = layout.optString("orientation", "vertical"),
197
- gridColumns = layout.optInt("gridColumns", 1).coerceIn(1, 4),
198
+ gridColumns = layout.optInt("gridColumns", 1).coerceIn(1, 7),
198
199
  stickyHeaders = layout.optBoolean("stickyHeaders", false),
199
200
  contentPadding = layout.optInt("contentPadding", 0).coerceAtLeast(0),
200
201
  contentPaddingHorizontal = layout.optionalNonNegativeInt("contentPaddingHorizontal"),
@@ -206,6 +207,7 @@ internal data class NativeListConfig(
206
207
  selectedKeys = selectedKeys,
207
208
  reorderable = capabilities?.optBoolean("reorderable", false) ?: false,
208
209
  pullToRefresh = capabilities?.optBoolean("pullToRefresh", false) ?: false,
210
+ refreshTriggerDistance = capabilities?.optDouble("refreshTriggerDistance")?.takeIf { it.isFinite() && it > 0 },
209
211
  refreshing = capabilities?.optBoolean("refreshing", false) ?: false,
210
212
  loadMore = capabilities?.optBoolean("loadMore", false) ?: false,
211
213
  endReachedThreshold = capabilities?.optDouble("endReachedThreshold", 0.2) ?: 0.2,