pythonnative 0.21.0__py3-none-any.whl → 0.22.0__py3-none-any.whl

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.
pythonnative/animated.py CHANGED
@@ -1,10 +1,11 @@
1
- """Animated values + animation drivers + animated component wrappers.
1
+ """Animated values, native-driven animation, and animated components.
2
2
 
3
- Modeled on React Native's ``Animated`` API but with an
4
- ``async``-aware completion contract. The core primitives are:
3
+ Modeled on React Native's ``Animated`` API with an ``async``-aware
4
+ completion contract. The core primitives are:
5
5
 
6
6
  - [`AnimatedValue`][pythonnative.animated.AnimatedValue]: a numeric
7
- cell with subscribers; animations mutate it over time.
7
+ cell attached to native view properties; animations drive it over
8
+ time.
8
9
  - ``Animated.timing`` / ``Animated.spring`` / ``Animated.decay``:
9
10
  animation factories. The objects they return implement
10
11
  ``__await__``, so you can write ``await Animated.timing(v, to=1.0)``
@@ -14,14 +15,24 @@ Modeled on React Native's ``Animated`` API but with an
14
15
  - ``Animated.View`` / ``Animated.Text`` / ``Animated.Image``:
15
16
  components whose ``style`` may contain ``AnimatedValue`` instances.
16
17
 
17
- Driver:
18
-
19
- - A single background thread ticks at ~60 Hz, advancing every active
20
- animation by ``dt``.
21
- - Animations expose two APIs:
22
- - ``handle.start()`` — fire-and-forget. Returns ``self``.
23
- - ``await handle`` (or ``await handle.run()``) — wait for the
24
- animation to complete; cancellation cancels the animation.
18
+ Driver architecture (the **native driver**):
19
+
20
+ When an animation starts, PythonNative compiles its spec (curve,
21
+ duration, target value) and offers it to the platform handler of every
22
+ native view the value is attached to
23
+ ([`ViewHandler.start_animation`][pythonnative.native_views.base.ViewHandler.start_animation]).
24
+
25
+ - **Accepted** (iOS Core Animation, Android ``ViewPropertyAnimator`` /
26
+ ``DynamicAnimation``): the platform animates the property entirely
27
+ natively — no Python code runs per frame. Python receives exactly one
28
+ callback when the animation settles, updates the
29
+ [`AnimatedValue`][pythonnative.animated.AnimatedValue], and resolves
30
+ any awaiting tasks.
31
+ - **Declined** (desktop preview, unattached values, callable easings,
32
+ values feeding Python-side listeners): a single background thread
33
+ ticks the animation at ~60 Hz from Python, pushing each frame through
34
+ ``set_animated_property``. Semantics are identical; only the frame
35
+ source differs.
25
36
 
26
37
  Example:
27
38
  ```python
@@ -48,6 +59,7 @@ Example:
48
59
  from __future__ import annotations
49
60
 
50
61
  import asyncio
62
+ import itertools
51
63
  import math
52
64
  import threading
53
65
  import time
@@ -58,11 +70,13 @@ from .hooks import use_effect, use_ref
58
70
  from .runtime import resolve_future
59
71
  from .style import StyleProp, resolve_style
60
72
 
61
- # Maximum frame rate at which the Python ticker drives animations.
73
+ # Maximum frame rate at which the Python fallback ticker drives
74
+ # animations (native-driven animations run at the display's refresh
75
+ # rate, managed by the platform).
62
76
  _TARGET_FPS = 60.0
63
77
  _FRAME_DT = 1.0 / _TARGET_FPS
64
78
 
65
- # Upper bound on how much wall-clock time the animation loop will try to
79
+ # Upper bound on how much wall-clock time the fallback loop will try to
66
80
  # catch up on in a single iteration after thread starvation. At 60 fps
67
81
  # this is ~333 ms of simulated motion; further drift is dropped to keep
68
82
  # the loop responsive.
@@ -98,30 +112,48 @@ def _resolve_easing(name: Any) -> Callable[[float], float]:
98
112
  return _EASINGS.get(str(name), _EASINGS["ease_in_out"])
99
113
 
100
114
 
115
+ def _backend() -> Any:
116
+ """Return the active native-view registry (the animation backend)."""
117
+ from .native_views import get_registry
118
+
119
+ return get_registry()
120
+
121
+
122
+ # Process-unique ids for native animations, so completion callbacks can
123
+ # be routed without holding references on the native side.
124
+ _anim_id_counter = itertools.count(1)
125
+
126
+
101
127
  # ======================================================================
102
128
  # AnimatedValue
103
129
  # ======================================================================
104
130
 
105
131
 
106
132
  class AnimatedValue:
107
- """A subscribable numeric cell driven by animations.
108
-
109
- Direct mutation via
110
- [`set_value`][pythonnative.animated.AnimatedValue.set_value]
111
- fires subscribers immediately; animations call ``set_value`` from
112
- the ticker thread.
113
-
114
- Subscribers are ``(prop_name, callback)`` tuples. Each animated
115
- component (e.g., ``Animated.View``) subscribes once per
116
- ``AnimatedValue`` prop in its style during mount.
133
+ """A numeric cell that can be attached to native view properties.
134
+
135
+ Animated components (``Animated.View`` et al.) **attach** the value
136
+ to ``(tag, prop)`` bindings after mount. Setting the value pushes
137
+ the new number to every attached native view through the registry's
138
+ ``set_animated_property`` and when an animation can be driven
139
+ natively, the platform animates those same bindings directly.
140
+
141
+ Python-side listeners registered via
142
+ [`add_listener`][pythonnative.animated.AnimatedValue.add_listener]
143
+ observe every Python-driven change. Natively-driven animations
144
+ intentionally skip per-frame Python callbacks (that's the point);
145
+ listeners see the final settled value.
117
146
  """
118
147
 
119
- __slots__ = ("_value", "_subscribers", "_lock")
148
+ __slots__ = ("_value", "_subscribers", "_attachments", "_lock", "_native_group")
120
149
 
121
150
  def __init__(self, initial: float = 0.0) -> None:
122
151
  self._value = float(initial)
123
152
  self._subscribers: List[Tuple[str, Callable[[float], None]]] = []
153
+ self._attachments: List[Tuple[int, str]] = []
124
154
  self._lock = threading.Lock()
155
+ # The in-flight native animation group driving this value, if any.
156
+ self._native_group: Optional["_NativeAnimationGroup"] = None
125
157
 
126
158
  @property
127
159
  def value(self) -> float:
@@ -129,23 +161,65 @@ class AnimatedValue:
129
161
  return self._value
130
162
 
131
163
  def set_value(self, new_value: float) -> None:
132
- """Set the value immediately and fire all subscribers."""
133
- new_value = float(new_value)
164
+ """Set the value immediately, pushing to native views and listeners."""
165
+ self._apply(float(new_value), push_native=True)
166
+
167
+ def _apply(self, new_value: float, push_native: bool) -> None:
134
168
  with self._lock:
135
169
  self._value = new_value
136
170
  subs = list(self._subscribers)
171
+ attachments = list(self._attachments)
172
+ if push_native and attachments:
173
+ try:
174
+ backend = _backend()
175
+ for tag, prop in attachments:
176
+ backend.set_animated_property(tag, prop, new_value)
177
+ except Exception:
178
+ pass
137
179
  for prop, cb in subs:
138
180
  try:
139
181
  cb(new_value)
140
182
  except Exception:
141
183
  pass
142
184
 
185
+ # -- bindings ------------------------------------------------------
186
+
187
+ def attach(self, tag: int, prop: str) -> Callable[[], None]:
188
+ """Bind this value to ``prop`` of the native view under ``tag``.
189
+
190
+ The current value is pushed immediately so the view reflects it
191
+ even if no animation is running. Returns a detach callable.
192
+ """
193
+ binding = (tag, prop)
194
+ with self._lock:
195
+ self._attachments.append(binding)
196
+ try:
197
+ _backend().set_animated_property(tag, prop, self._value)
198
+ except Exception:
199
+ pass
200
+
201
+ def _detach() -> None:
202
+ with self._lock:
203
+ try:
204
+ self._attachments.remove(binding)
205
+ except ValueError:
206
+ pass
207
+
208
+ return _detach
209
+
210
+ def attachments(self) -> List[Tuple[int, str]]:
211
+ """Snapshot of the current ``(tag, prop)`` bindings."""
212
+ with self._lock:
213
+ return list(self._attachments)
214
+
215
+ # -- listeners -----------------------------------------------------
216
+
143
217
  def add_listener(self, prop: str, callback: Callable[[float], None]) -> Callable[[], None]:
144
- """Register ``callback`` for changes to this value.
218
+ """Register ``callback`` for Python-driven changes to this value.
145
219
 
146
- Returns an unsubscribe callable. ``prop`` is metadata only —
147
- it lets the subscriber differentiate this binding from others
148
- on the same ``AnimatedValue``.
220
+ Returns an unsubscribe callable. ``prop`` is metadata only — it
221
+ lets the subscriber differentiate this binding from others on
222
+ the same ``AnimatedValue``.
149
223
  """
150
224
  with self._lock:
151
225
  self._subscribers.append((prop, callback))
@@ -159,6 +233,24 @@ class AnimatedValue:
159
233
 
160
234
  return _unsubscribe
161
235
 
236
+ def has_listeners(self) -> bool:
237
+ """Whether any Python-side listeners are registered."""
238
+ with self._lock:
239
+ return bool(self._subscribers)
240
+
241
+ # -- native handoff ------------------------------------------------
242
+
243
+ def _adopt_native_group(self, group: Optional["_NativeAnimationGroup"]) -> None:
244
+ previous = self._native_group
245
+ self._native_group = group
246
+ if previous is not None and previous is not group:
247
+ previous.cancel()
248
+
249
+ def stop_animation(self) -> None:
250
+ """Cancel any in-flight animation on this value (native or Python)."""
251
+ self._adopt_native_group(None)
252
+ _manager.cancel_for_value(self)
253
+
162
254
  def __float__(self) -> float:
163
255
  return self._value
164
256
 
@@ -167,16 +259,16 @@ class AnimatedValue:
167
259
 
168
260
 
169
261
  # ======================================================================
170
- # Animation driver
262
+ # Python fallback driver
171
263
  # ======================================================================
172
264
 
173
265
 
174
266
  class _AnimationManager:
175
- """Single-threaded driver for all currently-running animations.
267
+ """Single-threaded fallback driver for Python-ticked animations.
176
268
 
177
269
  Holds a list of ``_RunningAnimation`` instances and ticks them at
178
270
  ~60 Hz. The thread starts on first use and idles when nothing is
179
- active.
271
+ active. Native-driven animations never touch this loop.
180
272
  """
181
273
 
182
274
  def __init__(self) -> None:
@@ -197,6 +289,15 @@ class _AnimationManager:
197
289
  except ValueError:
198
290
  pass
199
291
 
292
+ def cancel_for_value(self, value: AnimatedValue) -> None:
293
+ """Cancel every queued/running Python-driven animation on ``value``."""
294
+ with self._lock:
295
+ stale = [a for a in self._animations if a.value is value]
296
+ for anim in stale:
297
+ self._animations.remove(anim)
298
+ for anim in stale:
299
+ anim._finish()
300
+
200
301
  def _ensure_thread_locked(self) -> None:
201
302
  if self._thread is not None and self._thread.is_alive():
202
303
  return
@@ -207,16 +308,16 @@ class _AnimationManager:
207
308
  last = time.monotonic()
208
309
  # Clamping the per-tick dt is important for numerical stability:
209
310
  # an underdamped spring with a 0.3 s step explodes immediately,
210
- # and on iOS/Android the animation thread can be starved for
211
- # several frames during render bursts. We integrate physics on a
212
- # clamped dt (max 2 target frames) and sub-step when wall-clock
213
- # has advanced more than that, so the perceived motion still
214
- # tracks real time at most a couple of frames behind. After an
215
- # extreme starvation (e.g. the app was backgrounded for seconds)
216
- # we cap the catch-up at ``_MAX_CATCHUP_FRAMES`` worth of
217
- # physics; any further wall-clock drift is dropped on the floor,
218
- # which keeps the loop responsive instead of spinning forward
219
- # through hundreds of substeps.
311
+ # and the animation thread can be starved for several frames
312
+ # during render bursts. We integrate physics on a clamped dt
313
+ # (max 2 target frames) and sub-step when wall-clock has
314
+ # advanced more than that, so the perceived motion still tracks
315
+ # real time at most a couple of frames behind. After an extreme
316
+ # starvation (e.g. the app was backgrounded for seconds) we cap
317
+ # the catch-up at ``_MAX_CATCHUP_FRAMES`` worth of physics; any
318
+ # further wall-clock drift is dropped on the floor, which keeps
319
+ # the loop responsive instead of spinning forward through
320
+ # hundreds of substeps.
220
321
  max_step = _FRAME_DT * 2.0
221
322
  max_catchup = _FRAME_DT * _MAX_CATCHUP_FRAMES
222
323
  while not self._stopped:
@@ -249,12 +350,12 @@ _manager = _AnimationManager()
249
350
 
250
351
 
251
352
  # ======================================================================
252
- # Animation primitives
353
+ # Python-driven animation primitives (the fallback path)
253
354
  # ======================================================================
254
355
 
255
356
 
256
357
  class _RunningAnimation:
257
- """Base class for in-flight animations; ``advance()`` returns True when done."""
358
+ """Base class for Python-ticked animations; ``advance()`` returns True when done."""
258
359
 
259
360
  def __init__(self, value: AnimatedValue) -> None:
260
361
  self.value = value
@@ -315,10 +416,11 @@ class _SpringAnimation(_RunningAnimation):
315
416
  stiffness: float,
316
417
  damping: float,
317
418
  mass: float,
419
+ initial_velocity: float = 0.0,
318
420
  ) -> None:
319
421
  super().__init__(value)
320
422
  self._to = float(to)
321
- self._velocity = 0.0
423
+ self._velocity = float(initial_velocity)
322
424
  self._stiffness = float(stiffness)
323
425
  self._damping = float(damping)
324
426
  self._mass = float(mass)
@@ -368,6 +470,163 @@ class _DelayAnimation(_RunningAnimation):
368
470
  return False
369
471
 
370
472
 
473
+ # ======================================================================
474
+ # Native-driven animation group
475
+ # ======================================================================
476
+
477
+
478
+ class _NativeAnimationGroup:
479
+ """One logical animation fanned out to N natively-animated views.
480
+
481
+ Each attached ``(tag, prop)`` binding gets its own ``anim_id``; the
482
+ group completes when the platform reports completion for all of
483
+ them. Cancellation asks each platform handler for the current
484
+ presentation value so the ``AnimatedValue`` lands wherever the view
485
+ visually was.
486
+ """
487
+
488
+ def __init__(self, value: AnimatedValue, final_value: float) -> None:
489
+ self.value = value
490
+ self.final_value = final_value
491
+ self._targets: Dict[int, Tuple[int, str]] = {} # anim_id -> (tag, prop)
492
+ self._pending: set = set()
493
+ self._completion_futures: List[asyncio.Future[None]] = []
494
+ self._completed = False
495
+ self._lock = threading.Lock()
496
+
497
+ def add_target(self, anim_id: int, tag: int, prop: str) -> None:
498
+ with self._lock:
499
+ self._targets[anim_id] = (tag, prop)
500
+ self._pending.add(anim_id)
501
+ _native_groups[anim_id] = self
502
+
503
+ def add_completion_future(self, future: asyncio.Future[None]) -> None:
504
+ with self._lock:
505
+ done = self._completed
506
+ if not done:
507
+ self._completion_futures.append(future)
508
+ if done:
509
+ resolve_future(future, None)
510
+
511
+ def target_completed(self, anim_id: int, finished: bool) -> None:
512
+ with self._lock:
513
+ self._pending.discard(anim_id)
514
+ remaining = len(self._pending)
515
+ _native_groups.pop(anim_id, None)
516
+ if remaining == 0:
517
+ self._settle(self.final_value if finished else None)
518
+
519
+ def cancel(self) -> None:
520
+ """Cancel all in-flight native animations, syncing to presentation values."""
521
+ with self._lock:
522
+ targets = dict(self._targets)
523
+ self._pending.clear()
524
+ presentation: Optional[float] = None
525
+ try:
526
+ backend = _backend()
527
+ for anim_id, (tag, _prop) in targets.items():
528
+ _native_groups.pop(anim_id, None)
529
+ current = backend.cancel_animation(tag, anim_id)
530
+ if current is not None:
531
+ try:
532
+ presentation = float(current)
533
+ except (TypeError, ValueError):
534
+ pass
535
+ except Exception:
536
+ pass
537
+ self._settle(presentation)
538
+
539
+ def _settle(self, end_value: Optional[float]) -> None:
540
+ with self._lock:
541
+ if self._completed:
542
+ return
543
+ self._completed = True
544
+ futures = list(self._completion_futures)
545
+ self._completion_futures.clear()
546
+ if self.value._native_group is self:
547
+ self.value._native_group = None
548
+ if end_value is not None:
549
+ # The native side already shows this value; update the
550
+ # Python cell (and listeners) without re-pushing.
551
+ self.value._apply(end_value, push_native=False)
552
+ for fut in futures:
553
+ resolve_future(fut, None)
554
+
555
+
556
+ # anim_id -> group, for routing completion callbacks from platform handlers.
557
+ _native_groups: Dict[int, _NativeAnimationGroup] = {}
558
+
559
+
560
+ def native_animation_completed(anim_id: int, finished: bool = True) -> None:
561
+ """Report a natively-driven animation as settled.
562
+
563
+ Called by platform handlers from their completion callbacks (iOS
564
+ ``UIView`` completion blocks, Android ``withEndAction`` /
565
+ ``DynamicAnimation.OnAnimationEndListener``). Safe to call from any
566
+ thread; unknown ids are ignored (e.g. an animation cancelled
567
+ moments before its completion fired).
568
+
569
+ Args:
570
+ anim_id: The id passed to ``ViewHandler.start_animation``.
571
+ finished: ``False`` when the platform reports the animation was
572
+ interrupted rather than running to completion.
573
+ """
574
+ group = _native_groups.get(anim_id)
575
+ if group is not None:
576
+ group.target_completed(anim_id, finished)
577
+
578
+
579
+ def _projected_final_value(spec: Dict[str, Any]) -> float:
580
+ """Compute where an animation will settle, from its spec."""
581
+ kind = spec.get("kind")
582
+ if kind == "decay":
583
+ # v(t) = v0 · e^(−k·1000·t) ⇒ ∫v dt = v0 / (k·1000)
584
+ v0 = float(spec.get("velocity", 0.0))
585
+ k = max(1e-6, float(spec.get("deceleration", 0.997)))
586
+ return float(spec.get("from", 0.0)) + v0 / (k * 1000.0)
587
+ return float(spec.get("to", spec.get("from", 0.0)))
588
+
589
+
590
+ def _start_native(value: AnimatedValue, spec: Dict[str, Any]) -> Optional[_NativeAnimationGroup]:
591
+ """Offer ``spec`` to the platform for every binding of ``value``.
592
+
593
+ Returns the live group when **all** bindings accepted the native
594
+ animation; otherwise rolls back any accepted targets and returns
595
+ ``None`` so the caller falls back to the Python ticker.
596
+ """
597
+ targets = value.attachments()
598
+ if not targets:
599
+ return None
600
+ if value.has_listeners():
601
+ # Python listeners want per-frame values; only the ticker
602
+ # provides those.
603
+ return None
604
+ try:
605
+ backend = _backend()
606
+ except Exception:
607
+ return None
608
+
609
+ group = _NativeAnimationGroup(value, _projected_final_value(spec))
610
+ accepted: List[Tuple[int, int]] = [] # (anim_id, tag)
611
+ for tag, prop in targets:
612
+ anim_id = next(_anim_id_counter)
613
+ try:
614
+ ok = backend.start_animation(tag, anim_id, prop, spec)
615
+ except Exception:
616
+ ok = False
617
+ if not ok:
618
+ for prev_id, prev_tag in accepted:
619
+ _native_groups.pop(prev_id, None)
620
+ try:
621
+ backend.cancel_animation(prev_tag, prev_id)
622
+ except Exception:
623
+ pass
624
+ return None
625
+ group.add_target(anim_id, tag, prop)
626
+ accepted.append((anim_id, tag))
627
+ return group
628
+
629
+
371
630
  # ======================================================================
372
631
  # Public animation handles
373
632
  # ======================================================================
@@ -425,37 +684,66 @@ class _AwaitableAnimation:
425
684
  class _AnimationHandle(_AwaitableAnimation):
426
685
  """Public handle returned by ``Animated.timing`` / ``.spring`` / ``.decay``.
427
686
 
428
- Wraps a ``_RunningAnimation`` factory so each ``.start()`` call
429
- creates a fresh in-flight animation (matches React Native the
430
- ``Animated.timing`` return value is reusable).
687
+ Each ``.start()`` call snapshots the value's current state, prefers
688
+ the native driver, and falls back to a fresh Python-ticked
689
+ animation otherwise (matches React Native — the ``Animated.timing``
690
+ return value is reusable).
431
691
  """
432
692
 
433
- def __init__(self, factory: Callable[[], _RunningAnimation]) -> None:
434
- self._factory = factory
435
- self._current: Optional[_RunningAnimation] = None
693
+ def __init__(
694
+ self,
695
+ value: Optional[AnimatedValue],
696
+ spec_factory: Callable[[], Dict[str, Any]],
697
+ fallback_factory: Callable[[], _RunningAnimation],
698
+ native_eligible: bool = True,
699
+ ) -> None:
700
+ self._value = value
701
+ self._spec_factory = spec_factory
702
+ self._fallback_factory = fallback_factory
703
+ self._native_eligible = native_eligible
704
+ self._python_anim: Optional[_RunningAnimation] = None
705
+ self._native_group: Optional[_NativeAnimationGroup] = None
436
706
 
437
707
  def start(self) -> "_AnimationHandle":
438
708
  """Begin the animation. Returns ``self`` for chaining."""
439
709
  self.stop()
440
- anim = self._factory()
441
- self._current = anim
710
+ if self._value is not None and self._native_eligible:
711
+ spec = self._spec_factory()
712
+ group = _start_native(self._value, spec)
713
+ if group is not None:
714
+ self._native_group = group
715
+ self._value._adopt_native_group(group)
716
+ return self
717
+ anim = self._fallback_factory()
718
+ self._python_anim = anim
442
719
  _manager.add(anim)
443
720
  return self
444
721
 
445
722
  def stop(self) -> None:
446
723
  """Cancel the running instance (no-op if not running)."""
447
- if self._current is not None:
448
- self._current._finish()
449
- _manager.remove(self._current)
450
- self._current = None
724
+ if self._native_group is not None:
725
+ group = self._native_group
726
+ self._native_group = None
727
+ if self._value is not None and self._value._native_group is group:
728
+ self._value._native_group = None
729
+ group.cancel()
730
+ if self._python_anim is not None:
731
+ anim = self._python_anim
732
+ self._python_anim = None
733
+ anim._finish()
734
+ _manager.remove(anim)
451
735
 
452
736
  async def _drive(self) -> None:
453
- if self._current is None:
737
+ if self._native_group is None and self._python_anim is None:
454
738
  self.start()
455
739
  loop = asyncio.get_running_loop()
456
740
  future: asyncio.Future[None] = loop.create_future()
457
- assert self._current is not None
458
- self._current.add_completion_future(future)
741
+ if self._native_group is not None:
742
+ self._native_group.add_completion_future(future)
743
+ elif self._python_anim is not None:
744
+ self._python_anim.add_completion_future(future)
745
+ else:
746
+ return
459
747
  await future
460
748
 
461
749
 
@@ -491,12 +779,9 @@ class _CompositeAnimation(_AwaitableAnimation):
491
779
  async def _await_item(item: Any) -> None:
492
780
  if item is None:
493
781
  return
494
- if isinstance(item, _AwaitableAnimation):
495
- await item
496
- else:
497
- # Plain awaitables and coroutines are supported too — lets
498
- # users mix in ``asyncio.sleep`` or other awaitables.
499
- await item
782
+ # ``_AwaitableAnimation`` and plain awaitables/coroutines are
783
+ # both supported — lets users mix in ``asyncio.sleep``.
784
+ await item
500
785
 
501
786
 
502
787
  # ======================================================================
@@ -509,7 +794,7 @@ def _resolve_style_with_values(style: StyleProp) -> Tuple[Dict[str, Any], Dict[s
509
794
 
510
795
  AnimatedValue entries in the style are replaced with their current
511
796
  numeric value in ``plain_style`` and recorded in
512
- ``animated_bindings`` so the wrapping component can subscribe
797
+ ``animated_bindings`` so the wrapping component can attach them
513
798
  after mount.
514
799
  """
515
800
  flat = resolve_style(style)
@@ -537,36 +822,25 @@ def _make_animated_factory(
537
822
  from .components import Text as _Text
538
823
  from .components import View as _View
539
824
 
825
+ # ``@component`` packs positional children into the ``children``
826
+ # prop (this function declares ``*args``), and the reconciler
827
+ # re-invokes it with keyword props only — so at render time the
828
+ # payload arrives in ``kwargs``, never in ``args``.
829
+ children = list(args) or list(kwargs.pop("children", ()) or ())
830
+
540
831
  style = kwargs.pop("style", None)
541
832
  plain_style, bindings = _resolve_style_with_values(style)
542
833
 
543
834
  ref = use_ref(None)
544
835
 
545
- def _subscribe() -> Callable[[], None]:
546
- view = ref["current"]
547
- unsubs: List[Callable[[], None]] = []
548
- if view is None:
836
+ def _attach_bindings() -> Callable[[], None]:
837
+ tag = ref.get("_pn_tag")
838
+ if tag is None:
549
839
  return lambda: None
550
-
551
- for prop, value in bindings.items():
552
-
553
- def _on_change(new_val: float, _prop: str = prop, _view: Any = view) -> None:
554
- handler = _get_handler_for(_view)
555
- if handler is None:
556
- return
557
- setter = getattr(handler, "set_animated_property", None)
558
- if setter is None:
559
- return
560
- try:
561
- setter(_view, _animated_prop_name(_prop), new_val)
562
- except Exception:
563
- pass
564
-
565
- unsub = value.add_listener(prop, _on_change)
566
- unsubs.append(unsub)
840
+ detachers = [value.attach(tag, _animated_prop_name(prop)) for prop, value in bindings.items()]
567
841
 
568
842
  def _cleanup() -> None:
569
- for fn in unsubs:
843
+ for fn in detachers:
570
844
  try:
571
845
  fn()
572
846
  except Exception:
@@ -574,50 +848,27 @@ def _make_animated_factory(
574
848
 
575
849
  return _cleanup
576
850
 
577
- # Re-subscribe whenever bindings change identity.
578
- use_effect(_subscribe, [tuple(sorted((k, id(v)) for k, v in bindings.items()))])
851
+ # Re-attach whenever the binding set changes identity.
852
+ use_effect(_attach_bindings, [tuple(sorted((k, id(v)) for k, v in bindings.items()))])
579
853
 
580
854
  if element_type == "Text":
581
- text = args[0] if args else kwargs.pop("text", "")
582
- return _Text(text, style=plain_style, ref=ref)
855
+ text = children[0] if children else kwargs.pop("text", "")
856
+ return _Text(text, style=plain_style, ref=ref, **kwargs)
583
857
  if element_type == "Image":
584
- source = args[0] if args else kwargs.pop("source", "")
585
- return _Image(source, style=plain_style, ref=ref)
586
- children = list(args) if accept_children else []
587
- return _View(*children, style=plain_style, ref=ref)
858
+ source = children[0] if children else kwargs.pop("source", "")
859
+ return _Image(source, style=plain_style, ref=ref, **kwargs)
860
+ if not accept_children:
861
+ children = []
862
+ return _View(*children, style=plain_style, ref=ref, **kwargs)
588
863
 
589
864
  return _animated
590
865
 
591
866
 
592
867
  def _animated_prop_name(prop: str) -> str:
593
868
  """Map a style key to the name expected by ``set_animated_property``."""
594
- if prop == "opacity":
595
- return "opacity"
596
- if prop == "background_color":
597
- return "background_color"
598
- if prop in ("translate_x", "translate_y", "scale", "scale_x", "scale_y", "rotate"):
599
- return prop
600
869
  return prop
601
870
 
602
871
 
603
- def _get_handler_for(native_view: Any) -> Any:
604
- """Best-effort lookup of the registered handler for ``native_view``."""
605
- del native_view
606
- try:
607
- from .native_views import get_registry
608
-
609
- registry = get_registry()
610
- handlers = getattr(registry, "_handlers", {})
611
- handler = handlers.get("View")
612
- if handler is not None:
613
- return handler
614
- if handlers:
615
- return next(iter(handlers.values()))
616
- return None
617
- except Exception:
618
- return None
619
-
620
-
621
872
  # ======================================================================
622
873
  # Public API
623
874
  # ======================================================================
@@ -640,12 +891,22 @@ class _AnimatedNamespace:
640
891
  duration: float = 300.0,
641
892
  easing: Any = "ease_in_out",
642
893
  ) -> _AnimationHandle:
643
- """Linearly interpolate ``value`` to ``to`` over ``duration`` ms."""
644
-
645
- def _factory() -> _RunningAnimation:
894
+ """Interpolate ``value`` to ``to`` over ``duration`` ms with ``easing``."""
895
+
896
+ def _spec() -> Dict[str, Any]:
897
+ return {
898
+ "kind": "timing",
899
+ "from": value.value,
900
+ "to": float(to),
901
+ "duration_ms": float(duration),
902
+ "easing": str(easing),
903
+ }
904
+
905
+ def _fallback() -> _RunningAnimation:
646
906
  return _TimingAnimation(value, to, duration, _resolve_easing(easing))
647
907
 
648
- return _AnimationHandle(_factory)
908
+ # Callable easings can't cross the bridge; tick them in Python.
909
+ return _AnimationHandle(value, _spec, _fallback, native_eligible=not callable(easing))
649
910
 
650
911
  @staticmethod
651
912
  def spring(
@@ -655,13 +916,25 @@ class _AnimatedNamespace:
655
916
  stiffness: float = 100.0,
656
917
  damping: float = 10.0,
657
918
  mass: float = 1.0,
919
+ initial_velocity: float = 0.0,
658
920
  ) -> _AnimationHandle:
659
921
  """Run a damped harmonic spring toward ``to``."""
660
922
 
661
- def _factory() -> _RunningAnimation:
662
- return _SpringAnimation(value, to, stiffness, damping, mass)
923
+ def _spec() -> Dict[str, Any]:
924
+ return {
925
+ "kind": "spring",
926
+ "from": value.value,
927
+ "to": float(to),
928
+ "stiffness": float(stiffness),
929
+ "damping": float(damping),
930
+ "mass": float(mass),
931
+ "initial_velocity": float(initial_velocity),
932
+ }
663
933
 
664
- return _AnimationHandle(_factory)
934
+ def _fallback() -> _RunningAnimation:
935
+ return _SpringAnimation(value, to, stiffness, damping, mass, initial_velocity)
936
+
937
+ return _AnimationHandle(value, _spec, _fallback)
665
938
 
666
939
  @staticmethod
667
940
  def decay(
@@ -670,12 +943,20 @@ class _AnimatedNamespace:
670
943
  velocity: float,
671
944
  deceleration: float = 0.997,
672
945
  ) -> _AnimationHandle:
673
- """Decelerate ``value`` from its current velocity until it rests."""
946
+ """Decelerate ``value`` from ``velocity`` (units/ms) until it rests."""
947
+
948
+ def _spec() -> Dict[str, Any]:
949
+ return {
950
+ "kind": "decay",
951
+ "from": value.value,
952
+ "velocity": float(velocity),
953
+ "deceleration": float(deceleration),
954
+ }
674
955
 
675
- def _factory() -> _RunningAnimation:
956
+ def _fallback() -> _RunningAnimation:
676
957
  return _DecayAnimation(value, velocity, deceleration)
677
958
 
678
- return _AnimationHandle(_factory)
959
+ return _AnimationHandle(value, _spec, _fallback)
679
960
 
680
961
  @staticmethod
681
962
  def parallel(animations: List[Any]) -> _CompositeAnimation:
@@ -691,10 +972,13 @@ class _AnimatedNamespace:
691
972
  def delay(duration: float) -> _AnimationHandle:
692
973
  """Wait ``duration`` ms before continuing in a sequence."""
693
974
 
694
- def _factory() -> _RunningAnimation:
975
+ def _spec() -> Dict[str, Any]:
976
+ return {"kind": "delay", "duration_ms": float(duration)}
977
+
978
+ def _fallback() -> _RunningAnimation:
695
979
  return _DelayAnimation(duration)
696
980
 
697
- return _AnimationHandle(_factory)
981
+ return _AnimationHandle(None, _spec, _fallback)
698
982
 
699
983
  View = staticmethod(_make_animated_factory("View", accept_children=True))
700
984
  Text = staticmethod(_make_animated_factory("Text", accept_children=False))
@@ -746,4 +1030,5 @@ __all__ = [
746
1030
  "AnimatedValue",
747
1031
  "Animated",
748
1032
  "use_animated_value",
1033
+ "native_animation_completed",
749
1034
  ]