react-native-smooth-clip-view 0.2.6 → 0.2.8

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.
@@ -32,6 +32,7 @@ internal object SmoothClipBindings {
32
32
  density: Double,
33
33
  widthPx: Double,
34
34
  heightPx: Double,
35
+ lifecycleVisible: Boolean,
35
36
  )
36
37
 
37
38
  /**
@@ -49,11 +50,14 @@ internal object SmoothClipBindings {
49
50
  external fun nativeUnregisterView(driverId: Double, view: SmoothClipView)
50
51
 
51
52
  /**
52
- * Notifies the registry that a registered view attached to a window; a
53
- * latched animation may only start once a view can produce a visible
54
- * frame (attached and with real host geometry).
53
+ * Pushes both positive and negative attachment/window-visibility state.
54
+ * The registry combines it with real host geometry before a latch can run.
55
55
  */
56
- external fun nativeViewBecameDisplayable(driverId: Double, view: SmoothClipView)
56
+ external fun nativeSetViewLifecycleVisibility(
57
+ driverId: Double,
58
+ view: SmoothClipView,
59
+ lifecycleVisible: Boolean,
60
+ )
57
61
 
58
62
  /**
59
63
  * Releases runtime-bound listener state during host teardown; must run
@@ -41,6 +41,7 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
41
41
  private var userTranslationY = 0f
42
42
  private var requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
43
43
  private var acceptingTouchStream = false
44
+ private var retainedTouchEvent: MotionEvent? = null
44
45
 
45
46
  /** Driver this view is registered with in the native registry (0 = none). */
46
47
  internal var boundDriverId: Double = 0.0
@@ -212,13 +213,13 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
212
213
  applyClipPlacement()
213
214
 
214
215
  if (isEmpty != clipIsEmpty) {
216
+ if (isEmpty) cancelAcceptedTouchStream()
215
217
  clipIsEmpty = isEmpty
216
218
  visibility = clipVisibility(isEmpty)
217
219
  importantForAccessibility = clipAccessibility(
218
220
  isEmpty,
219
221
  requestedImportantForAccessibility,
220
222
  )
221
- if (isEmpty) acceptingTouchStream = false
222
223
  }
223
224
 
224
225
  // An emptiness transition necessarily changes at least one emitted
@@ -299,12 +300,24 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
299
300
 
300
301
  override fun onAttachedToWindow() {
301
302
  super.onAttachedToWindow()
303
+ pushLifecycleVisibility()
304
+ }
305
+
306
+ override fun onDetachedFromWindow() {
302
307
  if (boundDriverId != 0.0) {
303
- // A latched animation may only start once this view can produce a
304
- // visible frame; window attach is that signal for views that
305
- // registered from a detached subtree.
306
- SmoothClipBindings.nativeViewBecameDisplayable(boundDriverId, this)
308
+ SmoothClipBindings.nativeSetViewLifecycleVisibility(
309
+ boundDriverId,
310
+ this,
311
+ false,
312
+ )
307
313
  }
314
+ clearTouchState()
315
+ super.onDetachedFromWindow()
316
+ }
317
+
318
+ override fun onWindowVisibilityChanged(visibility: Int) {
319
+ super.onWindowVisibilityChanged(visibility)
320
+ pushLifecycleVisibility()
308
321
  }
309
322
 
310
323
  override fun onConfigurationChanged(newConfig: Configuration) {
@@ -324,6 +337,18 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
324
337
 
325
338
  internal fun densityScale(): Double = PixelUtil.toPixelFromDIP(1f).toDouble()
326
339
 
340
+ internal fun isHostLifecycleVisible(): Boolean =
341
+ isAttachedToWindow && windowVisibility == VISIBLE
342
+
343
+ private fun pushLifecycleVisibility() {
344
+ if (boundDriverId == 0.0) return
345
+ SmoothClipBindings.nativeSetViewLifecycleVisibility(
346
+ boundDriverId,
347
+ this,
348
+ isHostLifecycleVisible(),
349
+ )
350
+ }
351
+
327
352
  private fun containsRoundedPoint(x: Float, y: Float): Boolean {
328
353
  // The event arrives in this view's local space, which the parent has
329
354
  // already inverse-transformed by the residual translation. Adding it
@@ -345,8 +370,16 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
345
370
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
346
371
  when (event.actionMasked) {
347
372
  MotionEvent.ACTION_DOWN -> {
373
+ clearTouchState()
348
374
  acceptingTouchStream = containsRoundedPoint(event.x, event.y)
349
375
  if (!acceptingTouchStream) return false
376
+ val accepted = super.dispatchTouchEvent(event)
377
+ if (accepted) {
378
+ retainTouchEvent(event)
379
+ } else {
380
+ clearTouchState()
381
+ }
382
+ return accepted
350
383
  }
351
384
  MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
352
385
  val result = if (acceptingTouchStream) {
@@ -354,12 +387,46 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
354
387
  } else {
355
388
  false
356
389
  }
357
- acceptingTouchStream = false
390
+ clearTouchState()
358
391
  return result
359
392
  }
360
393
  }
361
394
 
362
- return acceptingTouchStream && super.dispatchTouchEvent(event)
395
+ if (!acceptingTouchStream) return false
396
+ val result = super.dispatchTouchEvent(event)
397
+ retainTouchEvent(event)
398
+ return result
399
+ }
400
+
401
+ private fun retainTouchEvent(event: MotionEvent) {
402
+ retainedTouchEvent?.recycle()
403
+ retainedTouchEvent = MotionEvent.obtain(event)
404
+ }
405
+
406
+ private fun clearTouchState() {
407
+ acceptingTouchStream = false
408
+ retainedTouchEvent?.recycle()
409
+ retainedTouchEvent = null
410
+ }
411
+
412
+ private fun cancelAcceptedTouchStream() {
413
+ val latest = retainedTouchEvent
414
+ if (!acceptingTouchStream || latest == null) {
415
+ clearTouchState()
416
+ return
417
+ }
418
+ val cancel = MotionEvent.obtain(latest)
419
+ cancel.action = MotionEvent.ACTION_CANCEL
420
+ // Terminal BEFORE dispatch: a child's synchronous cancel handler can
421
+ // re-enter geometry application, and a nested emptiness transition
422
+ // must observe an already-ended stream instead of synthesizing a
423
+ // second cancel for it.
424
+ clearTouchState()
425
+ try {
426
+ super.dispatchTouchEvent(cancel)
427
+ } finally {
428
+ cancel.recycle()
429
+ }
363
430
  }
364
431
 
365
432
  fun setRequestedImportantForAccessibility(value: Int) {
@@ -383,7 +450,7 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
383
450
  }
384
451
 
385
452
  fun resetClipState() {
386
- acceptingTouchStream = false
453
+ clearTouchState()
387
454
  commandIsAuthoritative = false
388
455
  requestedX = 0f
389
456
  requestedY = 0f
@@ -167,6 +167,7 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
167
167
  view.densityScale(),
168
168
  view.width.toDouble(),
169
169
  view.height.toDouble(),
170
+ view.isHostLifecycleVisible(),
170
171
  )
171
172
  } else if (!view.commandIsAuthoritative) {
172
173
  // Command-driven mode (driverId 0): the initial props are the only
@@ -147,6 +147,11 @@ struct TimingRemainder {
147
147
  bool representable;
148
148
  };
149
149
 
150
+ struct TimingContinuation {
151
+ Presentation start;
152
+ TimingAnimation animation;
153
+ };
154
+
150
155
  // Exact right-hand segment of a CSS/CoreAnimation cubic Bezier after a raw
151
156
  // time cutoff. Restarting the original controls from a frozen presentation
152
157
  // resets its slope and creates a visible velocity cusp. De Casteljau splitting
@@ -321,6 +326,39 @@ inline Presentation interpolate(
321
326
  mix(from.contentTranslateY, to.contentTranslateY)};
322
327
  }
323
328
 
329
+ // Freezes a timing animation at one already-rendered raw-time phase and
330
+ // returns the exact right-hand Bezier segment. Keeping the presentation and
331
+ // residual curve in one helper prevents lifecycle code from sampling one
332
+ // timestamp for the geometry and another for the duration.
333
+ inline TimingContinuation timingContinuation(
334
+ const TimingAnimation &original,
335
+ const Presentation &start,
336
+ const Presentation &target,
337
+ double rawProgress) {
338
+ const TimingRemainder remainder = timingRemainder(original, rawProgress);
339
+ return {
340
+ interpolate(start, target, remainder.easedProgress),
341
+ remainder.animation};
342
+ }
343
+
344
+ // Android lifecycle callbacks can arrive long after the frame whose
345
+ // presentation was actually delivered. Keep the clock inputs at this shared
346
+ // boundary so callers cannot accidentally trim from callback wall time while
347
+ // calculating geometry from the last rendered frame.
348
+ inline TimingContinuation timingContinuationAtFrame(
349
+ const TimingAnimation &original,
350
+ const Presentation &start,
351
+ const Presentation &target,
352
+ double lastRenderedS,
353
+ double startedAtS,
354
+ double durationS) {
355
+ return timingContinuation(
356
+ original,
357
+ start,
358
+ target,
359
+ timingFraction(lastRenderedS, startedAtS, durationS));
360
+ }
361
+
324
362
  // --- Keyframe curve -------------------------------------------------------
325
363
 
326
364
  // Keyframes sampled from a smooth path, evaluated with monotone cubic Hermite
@@ -465,4 +503,58 @@ class KeyframeCurve {
465
503
  std::size_t cursor_ = 1;
466
504
  };
467
505
 
506
+ struct KeyframeContinuation {
507
+ Presentation start;
508
+ std::vector<Keyframe> frames;
509
+ double durationMs;
510
+ };
511
+
512
+ // Trims keyframes at the same raw-time phase that produced `frozen`. The new
513
+ // zero frame is the rendered presentation, not an analytically re-sampled
514
+ // neighbor, so a pause/re-latch cannot jump at the seam. Strictly increasing
515
+ // remapped offsets avoid duplicate/division-by-zero segments after repeated
516
+ // trims or floating-point collisions near the cutoff.
517
+ inline KeyframeContinuation keyframeContinuation(
518
+ const std::vector<Keyframe> &frames,
519
+ const Presentation &frozen,
520
+ const Presentation &target,
521
+ double durationMs,
522
+ double rawProgress) {
523
+ const double progress = clamp01(rawProgress);
524
+ KeyframeContinuation result{
525
+ frozen, {{0.0, frozen}}, std::max(0.0, durationMs * (1 - progress))};
526
+ if (progress < 1.0) {
527
+ for (const Keyframe &frame : frames) {
528
+ if (frame.offset <= progress) continue;
529
+ const double offset = clamp01(
530
+ (frame.offset - progress) / (1 - progress));
531
+ if (offset <= result.frames.back().offset + 1e-12) continue;
532
+ result.frames.push_back({offset, frame.presentation});
533
+ }
534
+ }
535
+ if (result.frames.size() == 1 ||
536
+ result.frames.back().offset < 1.0 - 1e-12) {
537
+ result.frames.push_back({1.0, target});
538
+ } else {
539
+ result.frames.back().offset = 1.0;
540
+ result.frames.back().presentation = target;
541
+ }
542
+ return result;
543
+ }
544
+
545
+ inline KeyframeContinuation keyframeContinuationAtFrame(
546
+ const std::vector<Keyframe> &frames,
547
+ const Presentation &frozen,
548
+ const Presentation &target,
549
+ double lastRenderedS,
550
+ double startedAtS,
551
+ double durationS) {
552
+ return keyframeContinuation(
553
+ frames,
554
+ frozen,
555
+ target,
556
+ std::max(0.0, durationS * 1000.0),
557
+ timingFraction(lastRenderedS, startedAtS, durationS));
558
+ }
559
+
468
560
  } // namespace smoothclip
@@ -67,13 +67,11 @@ inline void recordVelocitySample(
67
67
  history.latestTimeS = nowS;
68
68
  }
69
69
 
70
- // beginInteraction on both platforms records the frozen presentation as a
71
- // plain sample: a grab mid-animation pairs the frozen value with the last
72
- // real sample, so an instant refling inherits bounded recent motion instead
73
- // of launching dead, and a grab at an unchanged value dedupes to a no-op
74
- // whose staleness keeps aging. There is deliberately no reset primitive —
75
- // the staleness decay below is the forgetting mechanism, and it forgets
76
- // gradually rather than all at once.
70
+ // Only explicit interactive writes route through this recorder. Native
71
+ // freeze/join/resume/static-finalization paths deliberately do not, so an
72
+ // animation cannot manufacture a later inherited velocity. During ordinary
73
+ // interaction, the staleness decay below is the forgetting mechanism and it
74
+ // forgets gradually rather than all at once.
77
75
 
78
76
  inline void clearVelocitySamples(VelocitySampleHistory &history) {
79
77
  history = VelocitySampleHistory{};