@thelacanians/vue-native-cli 0.4.13 → 0.4.15

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.
@@ -119,7 +119,7 @@ afterEvaluate {
119
119
  create<MavenPublication>("release") {
120
120
  groupId = "com.vuenative"
121
121
  artifactId = "core"
122
- version = "0.4.13"
122
+ version = "0.4.14"
123
123
  from(components["release"])
124
124
  }
125
125
  }
@@ -23,6 +23,20 @@ class NativeBridge(private val context: Context) {
23
23
  "create", "createText", "appendChild", "insertBefore", "removeChild",
24
24
  "setRootView", "setText", "setElementText"
25
25
  )
26
+
27
+ /** Style properties that affect layout and require a layout pass when changed. */
28
+ private val layoutAffectingStyles = setOf(
29
+ "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
30
+ "flex", "flexGrow", "flexShrink", "flexBasis", "flexDirection",
31
+ "flexWrap", "alignItems", "alignSelf", "alignContent", "justifyContent",
32
+ "padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
33
+ "paddingHorizontal", "paddingVertical", "paddingStart", "paddingEnd",
34
+ "margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
35
+ "marginHorizontal", "marginVertical", "marginStart", "marginEnd",
36
+ "gap", "rowGap", "columnGap",
37
+ "position", "top", "right", "bottom", "left", "start", "end",
38
+ "aspectRatio", "display", "overflow", "direction",
39
+ )
26
40
  }
27
41
 
28
42
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -81,20 +95,34 @@ class NativeBridge(private val context: Context) {
81
95
  }
82
96
 
83
97
  mainHandler.post {
84
- var hasMutations = false
98
+ var needsLayout = false
85
99
  for (op in ops) {
86
100
  try {
87
101
  val opName = op.optString("op")
88
- if (!hasMutations && opName in treeMutationOps) {
89
- hasMutations = true
102
+ if (!needsLayout && opName in treeMutationOps) {
103
+ needsLayout = true
104
+ }
105
+ // Check if updateStyle changes any layout-affecting property
106
+ if (!needsLayout && opName == "updateStyle") {
107
+ val args = op.optJSONArray("args")
108
+ val styleObj = args?.optJSONObject(1)
109
+ if (styleObj != null) {
110
+ val keys = styleObj.keys()
111
+ while (keys.hasNext()) {
112
+ if (keys.next() in layoutAffectingStyles) {
113
+ needsLayout = true
114
+ break
115
+ }
116
+ }
117
+ }
90
118
  }
91
119
  handleOperation(op)
92
120
  } catch (e: Exception) {
93
121
  Log.e(TAG, "Error handling op '${op.optString("op")}': ${e.message}")
94
122
  }
95
123
  }
96
- // Only trigger layout when the batch mutated the view tree
97
- if (hasMutations) {
124
+ // Trigger layout when tree was mutated or layout-affecting styles changed
125
+ if (needsLayout) {
98
126
  triggerLayout()
99
127
  }
100
128
  }
@@ -380,6 +408,11 @@ class NativeBridge(private val context: Context) {
380
408
 
381
409
  /** Clear all registries. Called during hot reload after JS teardown. Must run on main thread. */
382
410
  fun clearAllRegistries() {
411
+ // Destroy all views via their factories before clearing maps
412
+ for ((_, view) in nodeViews) {
413
+ val factory = componentRegistry.factoryForView(view)
414
+ factory?.destroyView(view)
415
+ }
383
416
  nodeViews.clear()
384
417
  nodeTypes.clear()
385
418
  eventHandlers.clear()
@@ -98,9 +98,16 @@ class VListFactory : NativeComponentFactory {
98
98
  cumulativeX += dx
99
99
  cumulativeY += dy
100
100
 
101
- // Dispatch scroll event with cumulative position
101
+ // Dispatch scroll event with cumulative position and dimensions
102
102
  scrollHandlers[recyclerView]?.invoke(
103
- mapOf("x" to cumulativeX, "y" to cumulativeY)
103
+ mapOf(
104
+ "x" to cumulativeX,
105
+ "y" to cumulativeY,
106
+ "contentWidth" to (recyclerView.computeHorizontalScrollRange()),
107
+ "contentHeight" to (recyclerView.computeVerticalScrollRange()),
108
+ "layoutWidth" to recyclerView.width,
109
+ "layoutHeight" to recyclerView.height,
110
+ )
104
111
  )
105
112
 
106
113
  // endReached detection (threshold = 20% from bottom)
@@ -167,20 +174,33 @@ class VListFactory : NativeComponentFactory {
167
174
  }
168
175
 
169
176
  class VListAdapter(private val items: List<View>) : RecyclerView.Adapter<VListAdapter.VH>() {
170
- class VH(val view: View) : RecyclerView.ViewHolder(view)
177
+ class VH(itemView: View) : RecyclerView.ViewHolder(itemView)
171
178
 
172
179
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
173
- val v = items.getOrNull(viewType) ?: android.widget.FrameLayout(parent.context)
174
- // Remove from any existing parent
175
- (v.parent as? ViewGroup)?.removeView(v)
176
- v.layoutParams = RecyclerView.LayoutParams(
177
- RecyclerView.LayoutParams.MATCH_PARENT,
178
- RecyclerView.LayoutParams.WRAP_CONTENT
179
- )
180
- return VH(v)
180
+ val container = android.widget.FrameLayout(parent.context).apply {
181
+ layoutParams = RecyclerView.LayoutParams(
182
+ RecyclerView.LayoutParams.MATCH_PARENT,
183
+ RecyclerView.LayoutParams.WRAP_CONTENT
184
+ )
185
+ }
186
+ return VH(container)
187
+ }
188
+
189
+ override fun onBindViewHolder(holder: VH, position: Int) {
190
+ val container = holder.itemView as android.widget.FrameLayout
191
+ // Remove the previous item view from this recycled container
192
+ container.removeAllViews()
193
+ val itemView = items.getOrNull(position) ?: return
194
+ // Remove from any existing parent (previous container or direct parent)
195
+ (itemView.parent as? ViewGroup)?.removeView(itemView)
196
+ container.addView(itemView, ViewGroup.LayoutParams(
197
+ ViewGroup.LayoutParams.MATCH_PARENT,
198
+ ViewGroup.LayoutParams.WRAP_CONTENT
199
+ ))
181
200
  }
182
201
 
183
- override fun onBindViewHolder(holder: VH, position: Int) {}
184
202
  override fun getItemCount(): Int = items.size
185
- override fun getItemViewType(position: Int): Int = position
203
+
204
+ // Use a single view type so RecyclerView can reuse all ViewHolders
205
+ override fun getItemViewType(position: Int): Int = 0
186
206
  }
@@ -64,6 +64,9 @@ class VScrollViewFactory : NativeComponentFactory {
64
64
  }
65
65
  }
66
66
 
67
+ private val scrollThrottles = mutableMapOf<SwipeRefreshLayout, EventThrottle>()
68
+ private val scrollListeners = mutableMapOf<SwipeRefreshLayout, android.view.ViewTreeObserver.OnScrollChangedListener>()
69
+
67
70
  override fun addEventListener(view: View, event: String, handler: (Any?) -> Unit) {
68
71
  val srf = view as? SwipeRefreshLayout ?: return
69
72
  val scroll = states[srf]?.scrollView
@@ -73,20 +76,38 @@ class VScrollViewFactory : NativeComponentFactory {
73
76
  srf.setOnRefreshListener { handler(null) }
74
77
  }
75
78
  "scroll" -> {
76
- scroll?.viewTreeObserver?.addOnScrollChangedListener {
77
- handler(mapOf(
78
- "contentOffset" to mapOf("x" to scroll.scrollX, "y" to scroll.scrollY)
79
+ val throttle = EventThrottle(intervalMs = 16L, handler = handler)
80
+ scrollThrottles[srf] = throttle
81
+ val listener = android.view.ViewTreeObserver.OnScrollChangedListener {
82
+ val child = if (scroll != null && scroll.childCount > 0) scroll.getChildAt(0) else null
83
+ throttle.fire(mapOf(
84
+ "x" to (scroll?.scrollX ?: 0),
85
+ "y" to (scroll?.scrollY ?: 0),
86
+ "contentWidth" to (child?.width ?: scroll?.width ?: 0),
87
+ "contentHeight" to (child?.height ?: scroll?.height ?: 0),
88
+ "layoutWidth" to (scroll?.width ?: 0),
89
+ "layoutHeight" to (scroll?.height ?: 0),
79
90
  ))
80
91
  }
92
+ scrollListeners[srf] = listener
93
+ scroll?.viewTreeObserver?.addOnScrollChangedListener(listener)
81
94
  }
82
95
  }
83
96
  }
84
97
 
85
98
  override fun removeEventListener(view: View, event: String) {
86
99
  val srf = view as? SwipeRefreshLayout ?: return
87
- if (event == "refresh") {
88
- srf.setOnRefreshListener(null)
89
- srf.isEnabled = false
100
+ when (event) {
101
+ "refresh" -> {
102
+ srf.setOnRefreshListener(null)
103
+ srf.isEnabled = false
104
+ }
105
+ "scroll" -> {
106
+ scrollListeners.remove(srf)?.let { listener ->
107
+ states[srf]?.scrollView?.viewTreeObserver?.removeOnScrollChangedListener(listener)
108
+ }
109
+ scrollThrottles.remove(srf)?.cancel()
110
+ }
90
111
  }
91
112
  }
92
113
 
@@ -48,18 +48,24 @@ class VSliderFactory : NativeComponentFactory {
48
48
  }
49
49
  }
50
50
 
51
+ private val throttles = mutableMapOf<SeekBar, EventThrottle>()
52
+
51
53
  override fun addEventListener(view: View, event: String, handler: (Any?) -> Unit) {
52
54
  val sb = view as? SeekBar ?: return
53
55
  when (event) {
54
56
  "change", "valueChange" -> {
55
57
  changeHandlers[sb] = handler
58
+ val throttle = EventThrottle(intervalMs = 16L, handler = handler)
59
+ throttles[sb] = throttle
56
60
  sb.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
57
61
  override fun onProgressChanged(s: SeekBar, progress: Int, fromUser: Boolean) {
58
- if (fromUser) changeHandlers[s]?.invoke(mapOf("value" to progress.toFloat()))
62
+ if (fromUser) throttle.fire(mapOf("value" to progress.toFloat() / s.max.toFloat()))
59
63
  }
60
64
  override fun onStartTrackingTouch(s: SeekBar) {}
61
65
  override fun onStopTrackingTouch(s: SeekBar) {
62
- changeHandlers[s]?.invoke(mapOf("value" to s.progress.toFloat()))
66
+ // Always deliver the final value immediately on release
67
+ throttle.cancel()
68
+ changeHandlers[s]?.invoke(mapOf("value" to s.progress.toFloat() / s.max.toFloat()))
63
69
  }
64
70
  })
65
71
  }
@@ -69,6 +75,7 @@ class VSliderFactory : NativeComponentFactory {
69
75
  override fun removeEventListener(view: View, event: String) {
70
76
  val sb = view as? SeekBar ?: return
71
77
  changeHandlers.remove(sb)
78
+ throttles.remove(sb)?.cancel()
72
79
  sb.setOnSeekBarChangeListener(null)
73
80
  }
74
81
  }
@@ -0,0 +1,57 @@
1
+ package com.vuenative.core
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.os.SystemClock
6
+
7
+ /**
8
+ * Throttles high-frequency event handlers to avoid flooding the JS bridge.
9
+ *
10
+ * When a high-frequency event (scroll, slider drag) fires many times per frame,
11
+ * each invocation becomes a bridge round-trip. This utility ensures at most one
12
+ * call per [intervalMs] milliseconds, with a trailing call to deliver the latest value.
13
+ *
14
+ * Default interval: 16ms (~60 FPS).
15
+ */
16
+ class EventThrottle(
17
+ private val intervalMs: Long = 16L,
18
+ private val handler: (Any?) -> Unit
19
+ ) {
20
+ private var lastFireTime: Long = 0
21
+ private var pendingTrailing = false
22
+ private var latestPayload: Any? = null
23
+ private val mainHandler = Handler(Looper.getMainLooper())
24
+
25
+ private val trailingRunnable = Runnable {
26
+ lastFireTime = SystemClock.uptimeMillis()
27
+ pendingTrailing = false
28
+ handler(latestPayload)
29
+ }
30
+
31
+ /**
32
+ * Call this from the native event callback instead of the original handler.
33
+ * Fires immediately if enough time has elapsed, otherwise schedules a trailing call.
34
+ */
35
+ fun fire(payload: Any?) {
36
+ val now = SystemClock.uptimeMillis()
37
+ val elapsed = now - lastFireTime
38
+ latestPayload = payload
39
+
40
+ if (elapsed >= intervalMs) {
41
+ lastFireTime = now
42
+ pendingTrailing = false
43
+ mainHandler.removeCallbacks(trailingRunnable)
44
+ handler(payload)
45
+ } else if (!pendingTrailing) {
46
+ pendingTrailing = true
47
+ mainHandler.postDelayed(trailingRunnable, intervalMs - elapsed)
48
+ }
49
+ // If trailing is already pending, latestPayload is updated above
50
+ }
51
+
52
+ /** Cancel any pending trailing call. */
53
+ fun cancel() {
54
+ mainHandler.removeCallbacks(trailingRunnable)
55
+ pendingTrailing = false
56
+ }
57
+ }
@@ -0,0 +1,79 @@
1
+ #if canImport(UIKit)
2
+ import Foundation
3
+
4
+ /// Throttles high-frequency event handlers to avoid flooding the JS bridge.
5
+ ///
6
+ /// When a high-frequency event (scroll, slider drag, text input) fires many
7
+ /// times per frame, each invocation becomes a bridge round-trip. This utility
8
+ /// ensures at most one call per `interval` seconds, with a trailing call
9
+ /// to deliver the latest value.
10
+ ///
11
+ /// Default interval: 16ms (~60 FPS). Callers can customize via `interval`.
12
+ final class EventThrottle {
13
+
14
+ /// Minimum time between handler invocations (seconds).
15
+ let interval: TimeInterval
16
+
17
+ /// The throttled handler to call.
18
+ private let handler: (Any?) -> Void
19
+
20
+ /// Timestamp of the last invocation.
21
+ private var lastFireTime: CFTimeInterval = 0
22
+
23
+ /// Whether a trailing call is pending.
24
+ private var pendingTrailing = false
25
+
26
+ /// The most recent payload, used for the trailing call.
27
+ private var latestPayload: Any?
28
+
29
+ /// Timer for delivering the trailing call.
30
+ private var trailingTimer: DispatchSourceTimer?
31
+
32
+ /// Create a throttled event handler.
33
+ /// - Parameters:
34
+ /// - interval: Minimum seconds between invocations. Default 0.016 (~60fps).
35
+ /// - handler: The closure to invoke with the event payload.
36
+ init(interval: TimeInterval = 0.016, handler: @escaping (Any?) -> Void) {
37
+ self.interval = interval
38
+ self.handler = handler
39
+ }
40
+
41
+ deinit {
42
+ trailingTimer?.cancel()
43
+ }
44
+
45
+ /// Call this from the native event callback instead of the original handler.
46
+ /// Fires immediately if enough time has elapsed, otherwise schedules a trailing call.
47
+ func fire(_ payload: Any?) {
48
+ let now = CACurrentMediaTime()
49
+ let elapsed = now - lastFireTime
50
+
51
+ latestPayload = payload
52
+
53
+ if elapsed >= interval {
54
+ // Enough time has passed — fire immediately
55
+ lastFireTime = now
56
+ pendingTrailing = false
57
+ trailingTimer?.cancel()
58
+ trailingTimer = nil
59
+ handler(payload)
60
+ } else if !pendingTrailing {
61
+ // Schedule a trailing call for the remaining time
62
+ pendingTrailing = true
63
+ let remaining = interval - elapsed
64
+ let timer = DispatchSource.makeTimerSource(queue: .main)
65
+ timer.schedule(deadline: .now() + remaining)
66
+ timer.setEventHandler { [weak self] in
67
+ guard let self = self else { return }
68
+ self.lastFireTime = CACurrentMediaTime()
69
+ self.pendingTrailing = false
70
+ self.trailingTimer = nil
71
+ self.handler(self.latestPayload)
72
+ }
73
+ trailingTimer = timer
74
+ timer.resume()
75
+ }
76
+ // If a trailing call is already pending, just update latestPayload (done above)
77
+ }
78
+ }
79
+ #endif
@@ -704,8 +704,21 @@ enum JSPolyfills {
704
704
  /// Produce a JSON-safe string literal (with quotes) for embedding in JS eval strings.
705
705
  private enum JSPolyfillsJSON {
706
706
  static func encode(_ str: String) -> String {
707
- let data = (try? JSONSerialization.data(withJSONObject: str)) ?? Data()
708
- return String(data: data, encoding: .utf8) ?? "\"\""
707
+ // Wrap in array so JSONSerialization gets a valid top-level type.
708
+ // String alone causes an NSException that try? cannot catch.
709
+ if let data = try? JSONSerialization.data(withJSONObject: [str]),
710
+ let json = String(data: data, encoding: .utf8),
711
+ json.count >= 2 {
712
+ return String(json.dropFirst().dropLast())
713
+ }
714
+ // Fallback: manual escaping
715
+ let escaped = str
716
+ .replacingOccurrences(of: "\\", with: "\\\\")
717
+ .replacingOccurrences(of: "\"", with: "\\\"")
718
+ .replacingOccurrences(of: "\n", with: "\\n")
719
+ .replacingOccurrences(of: "\r", with: "\\r")
720
+ .replacingOccurrences(of: "\t", with: "\\t")
721
+ return "\"\(escaped)\""
709
722
  }
710
723
  }
711
724