@craft-native/android 0.0.123 → 0.0.125

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.
@@ -14,7 +14,7 @@ import kotlin.math.ceil
14
14
 
15
15
  internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
16
16
 
17
- /** A stable-key RecyclerView host for stx-native FlatList nodes. */
17
+ /** A stable-key RecyclerView host for stx native FlatList nodes. */
18
18
  internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
19
19
  private data class Item(
20
20
  val identity: String,
@@ -37,12 +37,16 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
37
37
  override fun getItemId(position: Int): Long = stableId(getItem(position).identity)
38
38
 
39
39
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(FrameLayout(parent.context).apply {
40
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
40
+ layoutParams = LayoutParams(
41
+ if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
42
+ LayoutParams.WRAP_CONTENT,
43
+ )
41
44
  })
42
45
 
43
46
  override fun onBindViewHolder(holder: Holder, position: Int) {
44
47
  val item = getItem(position)
45
48
  if (holder.identity != null && holder.identity != item.identity) release(holder)
49
+ holder.host.layoutParams = holderLayoutParams()
46
50
  val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
47
51
  val renderedLayout = view.layoutParams
48
52
  val hostedLayout = FrameLayout.LayoutParams(
@@ -83,19 +87,134 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
83
87
  private var endReached: (() -> Unit)? = null
84
88
  private var threshold = 0.1
85
89
  private var endReachedSignature: String? = null
90
+ private var dataContentSignature = ""
86
91
  private var horizontal = false
87
92
  private var columns = 1
93
+ private var keyboardShouldPersistTaps = "never"
88
94
  private var submissionGeneration = 0
89
95
  private var isSubmittingList = false
96
+ private var pendingThemeRefresh = false
97
+ var onScrollEvent: (() -> Unit)? = null
98
+ var onScrollBeginDrag: (() -> Unit)? = null
99
+ var onScrollEndDrag: (() -> Unit)? = null
100
+ var onMomentumScrollBegin: (() -> Unit)? = null
101
+ var onMomentumScrollEnd: (() -> Unit)? = null
102
+ var onLayoutChanged: (() -> Unit)? = null
103
+
104
+ private fun holderLayoutParams() = LayoutParams(
105
+ if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
106
+ LayoutParams.WRAP_CONTENT,
107
+ )
108
+
109
+ private fun updateVisibleHolderLayoutParams() {
110
+ for (index in 0 until childCount) {
111
+ (getChildViewHolder(getChildAt(index)) as? Holder)?.host?.layoutParams = holderLayoutParams()
112
+ }
113
+ }
114
+
115
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
116
+ super.onLayout(changed, left, top, right, bottom)
117
+ onLayoutChanged?.invoke()
118
+ }
90
119
 
91
120
  init {
92
121
  adapter = listAdapter
93
122
  itemAnimator = null
94
123
  addOnScrollListener(object : OnScrollListener() {
95
- override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = evaluateEndReached()
124
+ private var dragging = false
125
+ private var momentum = false
126
+
127
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
128
+ onScrollEvent?.invoke()
129
+ onLayoutChanged?.invoke()
130
+ evaluateEndReached()
131
+ }
132
+
133
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
134
+ when (newState) {
135
+ RecyclerView.SCROLL_STATE_DRAGGING -> if (!dragging) {
136
+ dragging = true
137
+ onScrollBeginDrag?.invoke()
138
+ }
139
+ RecyclerView.SCROLL_STATE_SETTLING -> {
140
+ if (dragging) {
141
+ dragging = false
142
+ onScrollEndDrag?.invoke()
143
+ }
144
+ if (!momentum) {
145
+ momentum = true
146
+ onMomentumScrollBegin?.invoke()
147
+ }
148
+ }
149
+ RecyclerView.SCROLL_STATE_IDLE -> {
150
+ if (dragging) {
151
+ dragging = false
152
+ onScrollEndDrag?.invoke()
153
+ }
154
+ if (momentum) {
155
+ momentum = false
156
+ onMomentumScrollEnd?.invoke()
157
+ }
158
+ }
159
+ }
160
+ }
96
161
  })
97
162
  }
98
163
 
164
+ fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
165
+ if (mode !in setOf("on-drag", "interactive") && keyboardShouldPersistTaps == "always") {
166
+ setOnTouchListener(null)
167
+ return
168
+ }
169
+ var dismissed = false
170
+ setOnTouchListener { _, event ->
171
+ when (event.actionMasked) {
172
+ android.view.MotionEvent.ACTION_DOWN -> dismissed = false
173
+ android.view.MotionEvent.ACTION_MOVE -> if (!dismissed) {
174
+ dismissed = true
175
+ dismissKeyboard()
176
+ }
177
+ android.view.MotionEvent.ACTION_UP -> if (shouldDismissKeyboard(event)) dismissKeyboard()
178
+ }
179
+ false
180
+ }
181
+ }
182
+
183
+ fun setKeyboardShouldPersistTaps(value: String?) {
184
+ keyboardShouldPersistTaps = value?.takeIf { it in setOf("always", "handled", "never") } ?: "never"
185
+ }
186
+
187
+ private fun tapTarget(view: View, x: Float, y: Float): View? {
188
+ if (x < 0 || y < 0 || x >= view.width || y >= view.height || view.visibility != View.VISIBLE) return null
189
+ if (view is ViewGroup) {
190
+ for (index in view.childCount - 1 downTo 0) {
191
+ val child = view.getChildAt(index)
192
+ tapTarget(child, x - child.left, y - child.top)?.let { return it }
193
+ }
194
+ }
195
+ return view
196
+ }
197
+
198
+ private fun shouldDismissKeyboard(event: android.view.MotionEvent): Boolean {
199
+ if (keyboardShouldPersistTaps == "always") return false
200
+ if (keyboardShouldPersistTaps == "never") return true
201
+ return tapTarget(this, event.x, event.y)?.isClickable != true
202
+ }
203
+
204
+ fun setContentContainerStyle(style: JSONObject?, density: Float) {
205
+ val padding = (style?.optDouble("padding", 0.0) ?: 0.0) * density
206
+ val horizontal = (style?.optDouble("paddingHorizontal", padding / density) ?: (padding / density)) * density
207
+ val vertical = (style?.optDouble("paddingVertical", padding / density) ?: (padding / density)) * density
208
+ setPadding(
209
+ ((style?.optDouble("paddingLeft", horizontal / density) ?: (horizontal / density)) * density).toInt(),
210
+ ((style?.optDouble("paddingTop", vertical / density) ?: (vertical / density)) * density).toInt(),
211
+ ((style?.optDouble("paddingRight", horizontal / density) ?: (horizontal / density)) * density).toInt(),
212
+ ((style?.optDouble("paddingBottom", vertical / density) ?: (vertical / density)) * density).toInt(),
213
+ )
214
+ clipToPadding = true
215
+ requestLayout()
216
+ }
217
+
99
218
  fun apply(
100
219
  nodes: List<JSONObject>,
101
220
  horizontal: Boolean,
@@ -116,7 +235,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
116
235
  val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
117
236
  this.horizontal = horizontal
118
237
  this.columns = nextColumns
119
- if (layoutChanged || layoutManager == null) configureLayoutManager()
238
+ if (layoutChanged || layoutManager == null) {
239
+ configureLayoutManager()
240
+ updateVisibleHolderLayoutParams()
241
+ }
120
242
 
121
243
  val seen = mutableMapOf<String, Int>()
122
244
  val next = nodes.mapIndexed { index, node ->
@@ -132,7 +254,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
132
254
  val nextIds = next.mapTo(mutableSetOf()) { it.identity }
133
255
  (oldIds - nextIds).forEach(recycleItem)
134
256
  val dataItems = next.filterNot { it.chrome }
135
- val contentSignature = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
257
+ val contentSignature = dataItems.joinToString("") {
258
+ "${it.identity.length}:${it.identity}${it.signature.length}:${it.signature}"
259
+ }
260
+ dataContentSignature = contentSignature
136
261
  if (contentSignature != endReachedSignature) endReachedSignature = null
137
262
  val generation = ++submissionGeneration
138
263
  isSubmittingList = true
@@ -140,6 +265,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
140
265
  if (generation != submissionGeneration) return@submitList
141
266
  retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
142
267
  isSubmittingList = false
268
+ if (pendingThemeRefresh) {
269
+ pendingThemeRefresh = false
270
+ refreshThemeDefaults()
271
+ }
143
272
  evaluateEndReached()
144
273
  }
145
274
  }
@@ -148,12 +277,28 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
148
277
  listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
149
278
  val generation = ++submissionGeneration
150
279
  isSubmittingList = true
280
+ pendingThemeRefresh = false
151
281
  listAdapter.submitList(emptyList()) {
152
282
  if (generation == submissionGeneration) isSubmittingList = false
153
283
  }
154
284
  renderItem = null
155
285
  recycleItem = null
156
286
  endReached = null
287
+ dataContentSignature = ""
288
+ onScrollEvent = null
289
+ onScrollBeginDrag = null
290
+ onScrollEndDrag = null
291
+ onMomentumScrollBegin = null
292
+ onMomentumScrollEnd = null
293
+ onLayoutChanged = null
294
+ }
295
+
296
+ fun refreshThemeDefaults() {
297
+ if (isSubmittingList) {
298
+ pendingThemeRefresh = true
299
+ return
300
+ }
301
+ if (listAdapter.itemCount > 0) listAdapter.notifyItemRangeChanged(0, listAdapter.itemCount)
157
302
  }
158
303
 
159
304
  private fun configureLayoutManager() {
@@ -179,8 +324,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
179
324
  val remaining = dataPositions.size - lastDataOrdinal - 1
180
325
  val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
181
326
  if (remaining > thresholdItems) return
182
- val lastIdentity = listAdapter.currentList[dataPositions.last()].identity
183
- val signature = "${dataPositions.size}:$lastIdentity"
327
+ val signature = dataContentSignature
184
328
  if (endReachedSignature == signature) return
185
329
  endReachedSignature = signature
186
330
  endReached?.invoke()
@@ -0,0 +1,29 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
5
+
6
+ /** Swipe-to-refresh host for a native FlatList. */
7
+ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLayout(context) {
8
+ val list = CraftNativeFlatList(context)
9
+ private var refreshHandler: (() -> Unit)? = null
10
+
11
+ init {
12
+ addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
13
+ setOnRefreshListener { refreshHandler?.invoke() }
14
+ isEnabled = false
15
+ }
16
+
17
+ fun configureRefresh(handler: (() -> Unit)?, refreshing: Boolean) {
18
+ refreshHandler = handler
19
+ isEnabled = handler != null
20
+ isRefreshing = handler != null && refreshing
21
+ if (handler == null) isRefreshing = false
22
+ }
23
+
24
+ override fun onDetachedFromWindow() {
25
+ refreshHandler = null
26
+ isRefreshing = false
27
+ super.onDetachedFromWindow()
28
+ }
29
+ }
@@ -17,6 +17,7 @@ import android.webkit.WebSettings
17
17
  import android.webkit.WebView
18
18
  import android.webkit.WebViewClient
19
19
  import androidx.appcompat.app.AppCompatActivity
20
+ import androidx.appcompat.app.AppCompatDelegate
20
21
  import androidx.core.view.WindowCompat
21
22
  import androidx.webkit.WebViewAssetLoader
22
23
  import org.json.JSONObject
@@ -75,6 +76,7 @@ class MainActivity : AppCompatActivity() {
75
76
 
76
77
  @SuppressLint("SetJavaScriptEnabled")
77
78
  override fun onCreate(savedInstanceState: Bundle?) {
79
+ applyConfiguredAppearance()
78
80
  super.onCreate(savedInstanceState)
79
81
 
80
82
  // Enable edge-to-edge
@@ -111,6 +113,21 @@ class MainActivity : AppCompatActivity() {
111
113
  setupNetworkMonitoring()
112
114
  }
113
115
 
116
+ private fun applyConfiguredAppearance() {
117
+ val configured = configJson()
118
+ val mode = when (configured?.optString("appearance")) {
119
+ "light" -> AppCompatDelegate.MODE_NIGHT_NO
120
+ "dark" -> AppCompatDelegate.MODE_NIGHT_YES
121
+ "system" -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
122
+ else -> if (configured?.optBoolean("darkMode", true) == false) {
123
+ AppCompatDelegate.MODE_NIGHT_NO
124
+ } else {
125
+ AppCompatDelegate.MODE_NIGHT_YES
126
+ }
127
+ }
128
+ AppCompatDelegate.setDefaultNightMode(mode)
129
+ }
130
+
114
131
  @SuppressLint("SetJavaScriptEnabled")
115
132
  private fun setupWebView() {
116
133
  webView.settings.apply {