"""Animated values, derived animated nodes, and native-driven animation.
Modeled on React Native's ``Animated`` API with an ``async``-aware
completion contract. The core primitives are:
- [`AnimatedValue`][pythonnative.animated.AnimatedValue]: a numeric
cell attached to native view properties; animations drive it over
time.
- **Derived nodes**: every animated node supports
[`interpolate`][pythonnative.animated.AnimatedNode.interpolate]
(range mapping with numeric, color, and angle outputs) and Python
arithmetic (``opacity * 0.5``, ``x + y``, ``-value``), producing
read-only [`AnimatedNode`][pythonnative.animated.AnimatedNode]
instances that update whenever their inputs change.
- ``Animated.timing`` / ``Animated.spring`` / ``Animated.decay``:
animation factories. The objects they return implement
``__await__``, so you can write ``await Animated.timing(v, to=1.0)``
to suspend until the animation finishes. Awaiting yields an
[`AnimationResult`][pythonnative.animated.AnimationResult] whose
``finished`` flag is ``False`` when the animation was stopped or
interrupted; ``handle.start(callback)`` passes the same value.
- ``Animated.sequence`` / ``Animated.parallel`` / ``Animated.stagger``
/ ``Animated.delay`` / ``Animated.loop``: composition; also
awaitable. A composite is ``finished`` only if every child finished.
- [`Easing`][pythonnative.animated.Easing]: serializable easing
descriptors for ``Animated.timing`` (``Easing.ease_in_out``,
``Easing.bezier(0.2, 0.8, 0.2, 1.0)``, ...). Every renderer evaluates
the same curve definitions; a plain Python callable is still accepted
but ticks in Python.
- ``Animated.event``: build an event-prop callback that copies event
fields into animated values (``on_scroll=pn.Animated.event(y=v)``).
- ``Animated.diff_clamp``: accumulate an input's *deltas* into a
clamped range (the collapsing-header primitive).
- ``Animated.View`` / ``Animated.Text`` / ``Animated.Image``:
components whose ``style`` may contain animated nodes, including
inside ``transform`` entries.
Driver architecture (the **native driver**):
Mounted bindings install a serialized graph of connected values, arithmetic,
interpolation, and view properties. When an animation starts, PythonNative
offers its timing, spring, or decay specification to the renderer through
the backend's ``start_animation`` hook (see
[`BridgeBackend`][pythonnative.native_views.bridge_backend.BridgeBackend]).
- **Accepted**: the renderer evaluates the graph and applies its bindings
without Python work on every frame. Completion callbacks settle the
[`AnimatedValue`][pythonnative.animated.AnimatedValue] and resolve
awaiting tasks.
- **Declined** (unattached values, callable easings,
values feeding Python-side listeners, or an unsupported backend): a single
background thread ticks the animation at ~60 Hz from Python, pushing
each frame through ``set_animated_property``. Semantics are
identical; only the frame source differs.
On mobile, scroll and gesture bindings feed graph values before input is
queued to Python. Derived expressions can run in the renderer alongside their
source drivers. Backends without graph support use Python for derived bindings.
Example:
```python
import pythonnative as pn
@pn.component
def FadeIn():
opacity = pn.use_animated_value(0.0)
async def fade_in():
await pn.Animated.timing(opacity, to=1.0, duration=400)
await pn.Animated.timing(opacity, to=0.5, duration=200)
pn.use_effect(fade_in, [])
return pn.Animated.View(
pn.Text("Hello!"),
style={"opacity": opacity, "padding": 20},
)
```
"""
from __future__ import annotations
import asyncio
import bisect
import functools
import itertools
import math
import threading
import time
import weakref
from dataclasses import dataclass
from typing import Any, Callable, Dict, FrozenSet, Generator, List, Mapping, Optional, Sequence, Tuple, Union, cast
from .element import Element
from .hooks import Ref, use_effect, use_ref
from .runtime import resolve_future
from .style import Style, StyleProp, resolve_style
# Maximum frame rate at which the Python fallback ticker drives
# animations (native-driven animations run at the display's refresh
# rate, managed by the platform).
_TARGET_FPS = 60.0
_FRAME_DT = 1.0 / _TARGET_FPS
# Upper bound on how much wall-clock time the fallback loop will try to
# catch up on in a single iteration after thread starvation. At 60 fps
# this is ~333 ms of simulated motion; further drift is dropped to keep
# the loop responsive.
_MAX_CATCHUP_FRAMES = 20
# Velocity (points per millisecond) below which a decay animation is at
# rest. React Native's ``DecayAnimation`` stops when a frame moves the
# value by less than 0.1 points; at 60 Hz that is about 0.006 pt/ms. We
# use the tighter 0.001 pt/ms so the tail of a fling is never clipped
# visibly, and every native decay driver shares this constant.
DECAY_REST_VELOCITY = 0.001
# React Native's default ``deceleration`` for ``Animated.decay``.
DEFAULT_DECAY_DECELERATION = 0.998
# ======================================================================
# Easing
# ======================================================================
_BEZIER_EPSILON = 1e-7
_BEZIER_NEWTON_ITERATIONS = 8
_BEZIER_BISECTION_ITERATIONS = 40
def _bezier_component(t: float, p1: float, p2: float) -> float:
"""Evaluate one axis of a cubic bezier anchored at ``0`` and ``1``."""
inv = 1.0 - t
return 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t
def _bezier_slope(t: float, p1: float, p2: float) -> float:
"""Derivative of ``_bezier_component`` with respect to ``t``."""
inv = 1.0 - t
return 3.0 * inv * inv * p1 + 6.0 * inv * t * (p2 - p1) + 3.0 * t * t * (1.0 - p2)
@functools.lru_cache(maxsize=64)
def _bezier_function(x1: float, y1: float, x2: float, y2: float) -> Callable[[float], float]:
"""Build a ``progress -> eased`` function for a CSS-style cubic bezier.
The curve is parametric, so evaluating it at a given horizontal
``progress`` means solving ``bezier_x(t) = progress`` for ``t`` first.
Newton-Raphson from a linear guess converges in a handful of steps
for well-behaved curves; when the slope is too flat for Newton to be
trusted, bisection on ``[0, 1]`` finishes the job.
"""
if x1 == y1 and x2 == y2:
return lambda progress: min(1.0, max(0.0, progress))
def solve_t(x: float) -> float:
t = x
for _ in range(_BEZIER_NEWTON_ITERATIONS):
error = _bezier_component(t, x1, x2) - x
if abs(error) < _BEZIER_EPSILON:
return t
slope = _bezier_slope(t, x1, x2)
if abs(slope) < 1e-6:
break
t -= error / slope
lo, hi = 0.0, 1.0
for _ in range(_BEZIER_BISECTION_ITERATIONS):
t = (lo + hi) / 2.0
if _bezier_component(t, x1, x2) < x:
lo = t
else:
hi = t
if hi - lo < _BEZIER_EPSILON:
break
return t
def evaluate(progress: float) -> float:
if progress = 1.0:
return 1.0
return _bezier_component(solve_t(progress), y1, y2)
return evaluate
def _bounce(t: float) -> float:
"""Robert Penner's bounce-out, the curve React Native ships as ``Easing.bounce``."""
if t < 1 / 2.75:
return 7.5625 * t * t
if t < 2 / 2.75:
t -= 1.5 / 2.75
return 7.5625 * t * t + 0.75
if t < 2.5 / 2.75:
t -= 2.25 / 2.75
return 7.5625 * t * t + 0.9375
t -= 2.625 / 2.75
return 7.5625 * t * t + 0.984375
# Canonical definitions of the named easings. Every renderer (the Python
# ticker, Swift, Kotlin, and the browser preview) implements exactly these
# curves under these names; they follow React Native's ``Easing`` module,
# where ``ease`` is ``bezier(0.42, 0, 1, 1)`` and ``ease_in`` /
# ``ease_out`` / ``ease_in_out`` are ``Easing.in`` / ``out`` / ``inOut``
# of it, which coincide with the CSS ``ease-in`` / ``ease-out`` /
# ``ease-in-out`` keywords.
_NAMED_EASING_BEZIERS: Dict[str, Tuple[float, float, float, float]] = {
"ease": (0.42, 0.0, 1.0, 1.0),
"ease_in": (0.42, 0.0, 1.0, 1.0),
"ease_out": (0.0, 0.0, 0.58, 1.0),
"ease_in_out": (0.42, 0.0, 0.58, 1.0),
}
_NAMED_EASING_FUNCTIONS: Dict[str, Callable[[float], float]] = {
"linear": lambda t: t,
"ease": _bezier_function(*_NAMED_EASING_BEZIERS["ease"]),
"ease_in": _bezier_function(*_NAMED_EASING_BEZIERS["ease_in"]),
"ease_out": _bezier_function(*_NAMED_EASING_BEZIERS["ease_out"]),
"ease_in_out": _bezier_function(*_NAMED_EASING_BEZIERS["ease_in_out"]),
"quad": lambda t: t * t,
"cubic": lambda t: t * t * t,
"bounce": _bounce,
}
EASING_NAMES: Tuple[str, ...] = tuple(_NAMED_EASING_FUNCTIONS)
"""The easing names ``Animated.timing`` accepts as strings."""
@dataclass(frozen=True)
class EasingSpec:
"""A serializable easing curve for ``Animated.timing``.
Build instances through the [`Easing`][pythonnative.animated.Easing]
namespace (``Easing.ease_in_out``, ``Easing.bezier(...)``) rather
than directly. The descriptor crosses the bridge as data, so the
native drivers can run the curve on the UI thread, and it evaluates
identically in Python for the fallback ticker and for tests.
Attributes:
name: One of the named curves (``"linear"``, ``"ease"``,
``"ease_in"``, ``"ease_out"``, ``"ease_in_out"``,
``"quad"``, ``"cubic"``, ``"bounce"``) or ``"bezier"``.
points: The ``(x1, y1, x2, y2)`` control points when ``name`` is
``"bezier"``; ``None`` otherwise.
"""
name: str
points: Optional[Tuple[float, float, float, float]] = None
def __post_init__(self) -> None:
if self.name == "bezier":
if self.points is None or len(self.points) != 4:
raise ValueError("Easing.bezier() needs exactly four control-point coordinates")
x1, _y1, x2, _y2 = self.points
if not (0.0 EasingSpec:
"""Return a cubic-bezier easing with the CSS ``cubic-bezier(x1, y1, x2, y2)`` control points.
``x1`` and ``x2`` must lie in ``[0, 1]``; ``y1`` and ``y2`` may
overshoot for anticipation or bounce effects.
"""
return EasingSpec("bezier", (float(x1), float(y1), float(x2), float(y2)))
Easing = _EasingNamespace()
EasingLike = Union[EasingSpec, str, Callable[[float], float]]
"""What ``Animated.timing(easing=...)`` accepts."""
def _resolve_easing(easing: Any) -> Union[EasingSpec, Callable[[float], float]]:
"""Normalize an ``easing`` argument to an ``EasingSpec`` or a Python callable.
Raises:
ValueError: For a string that is not one of ``EASING_NAMES``.
TypeError: For anything that is not a spec, a name, or a callable.
"""
if isinstance(easing, EasingSpec):
return easing
if isinstance(easing, str):
if easing in _NAMED_EASING_FUNCTIONS:
return EasingSpec(easing)
raise ValueError(
f"Unknown easing {easing!r}; expected one of {', '.join(EASING_NAMES)}, "
"an Easing.* descriptor, or a callable"
)
if callable(easing):
return easing
raise TypeError(f"easing must be an Easing descriptor, a name, or a callable (got {type(easing).__name__})")
def _easing_function(easing: Union[EasingSpec, Callable[[float], float]]) -> Callable[[float], float]:
"""Return the ``progress -> eased`` callable for a resolved easing."""
if isinstance(easing, EasingSpec):
if easing.name == "bezier":
assert easing.points is not None
return _bezier_function(*easing.points)
return _NAMED_EASING_FUNCTIONS[easing.name]
return easing
def _backend() -> Any:
"""Return the active view backend (the animation backend), or ``None`` off device.
Headless code that never installed a backend (plain ``AnimatedValue``
arithmetic in a unit test) still works: every caller treats ``None``
as "no renderer to push to."
"""
from .native_views import get_backend
try:
return get_backend()
except Exception:
return None
# Process-unique ids for native animations, so completion callbacks can
# be routed without holding references on the native side.
_anim_id_counter = itertools.count(1)
# ======================================================================
# AnimatedNode: the shared graph-node base
# ======================================================================
class AnimatedNode:
"""Base class for every animated node (settable leaves and derived nodes).
An animated node holds a current output value and a set of
``(tag, prop)`` **attachments** binding it to native view
properties. Whenever the node's output changes (a leaf was set or
animated, or an input of a derived node changed), the new value is
pushed to every attachment through the registry's
``set_animated_property`` and to every Python-side listener, then
propagated to derived nodes built from this one.
Derived nodes are constructed with
[`interpolate`][pythonnative.animated.AnimatedNode.interpolate],
with Python arithmetic operators (``+``, ``-``, ``*``, ``/``,
``%``, unary ``-``), or with ``Animated.diff_clamp``. They are
read-only: only [`AnimatedValue`][pythonnative.AnimatedValue]
leaves can be set or animated directly.
"""
__slots__ = ("_subscribers", "_attachments", "_lock", "_children", "__weakref__")
def __init__(self) -> None:
self._subscribers: List[Tuple[str, Callable[[Any], None]]] = []
self._attachments: List[Tuple[int, str]] = []
self._lock = threading.Lock()
# Derived nodes built from this one. Weak so a discarded
# interpolation doesn't keep receiving pushes forever.
self._children: "weakref.WeakSet[AnimatedNode]" = weakref.WeakSet()
# -- value -----------------------------------------------------------
@property
def value(self) -> Any:
"""Return the node's current output value."""
raise NotImplementedError
def __float__(self) -> float:
try:
return float(self.value)
except (TypeError, ValueError):
return 0.0
# -- graph -----------------------------------------------------------
def _adopt_child(self, child: "AnimatedNode") -> None:
with self._lock:
self._children.add(child)
def _has_dependents(self) -> bool:
"""Whether any derived node consumes this node's output."""
with self._lock:
return len(self._children) > 0
def _refresh(self) -> None:
"""Hook for stateful derived nodes to update from their inputs."""
def _propagate(self, push_native: bool = True) -> None:
"""Push the current output to attachments/listeners and descend.
Args:
push_native: When ``False`` the renderer already shows this
frame (a native-evaluated graph sample), so only Python
state, listeners, and dependents are updated.
"""
self._refresh()
current = self.value
with self._lock:
subs = list(self._subscribers)
attachments = list(self._attachments)
children = list(self._children)
if push_native and attachments:
backend = _backend()
if backend is not None:
try:
for tag, prop in attachments:
backend.set_animated_property(tag, prop, current)
except Exception:
pass
for _prop, cb in subs:
try:
cb(current)
except Exception:
pass
for child in children:
child._propagate(push_native)
# -- bindings ----------------------------------------------------------
def attach(self, tag: int, prop: str) -> Callable[[], None]:
"""Bind this node to ``prop`` of the native view under ``tag``.
The current value is pushed immediately so the view reflects it
even if no animation is running. Returns a detach callable.
"""
binding = (tag, prop)
with self._lock:
self._attachments.append(binding)
backend = _backend()
if backend is not None:
try:
backend.set_animated_property(tag, prop, self.value)
except Exception:
pass
from .animation_graph import install
install(self)
def _detach() -> None:
with self._lock:
try:
self._attachments.remove(binding)
except ValueError:
pass
install(self, detached_tag=tag)
return _detach
def attachments(self) -> List[Tuple[int, str]]:
"""Snapshot of the current ``(tag, prop)`` bindings."""
with self._lock:
return list(self._attachments)
# -- listeners ---------------------------------------------------------
def add_listener(self, prop: str, callback: Callable[[Any], None]) -> Callable[[], None]:
"""Register ``callback`` for Python-driven changes to this node.
Returns an unsubscribe callable. ``prop`` is metadata only; it
lets the subscriber differentiate this binding from others on
the same node.
"""
with self._lock:
self._subscribers.append((prop, callback))
def _unsubscribe() -> None:
with self._lock:
try:
self._subscribers.remove((prop, callback))
except ValueError:
pass
return _unsubscribe
def has_listeners(self) -> bool:
"""Whether any Python-side listeners are registered."""
with self._lock:
return bool(self._subscribers)
# -- derivation --------------------------------------------------------
def interpolate(
self,
input_range: Sequence[float],
output_range: Sequence[Any],
extrapolate: str = "extend",
extrapolate_left: Optional[str] = None,
extrapolate_right: Optional[str] = None,
) -> "AnimatedInterpolation":
"""Map this node's value through an input/output range.
Mirrors React Native's ``interpolate``. ``output_range`` may
contain numbers, colors (``"#RRGGBB"`` / ``"#AARRGGBB"``), or
angle strings (``"45deg"`` / ``"0.5rad"``, emitted as numeric
degrees for the ``rotate`` transform).
Args:
input_range: Monotonically non-decreasing breakpoints for
this node's value. At least two entries.
output_range: Output breakpoints, same length as
``input_range``.
extrapolate: Behavior outside the input range:
``"extend"`` (continue the edge segment's slope,
default), ``"clamp"`` (pin to the edge output), or
``"identity"`` (return the input unchanged).
extrapolate_left: Override ``extrapolate`` below the range.
extrapolate_right: Override ``extrapolate`` above the range.
Returns:
A derived, read-only animated node.
Example:
```python
header_height = scroll_y.interpolate(
input_range=[0, 120],
output_range=[160, 56],
extrapolate="clamp",
)
```
"""
return AnimatedInterpolation(
self,
input_range,
output_range,
extrapolate=extrapolate,
extrapolate_left=extrapolate_left,
extrapolate_right=extrapolate_right,
)
# -- arithmetic --------------------------------------------------------
def __add__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("add", lambda a, b: a + b, [self, other])
def __radd__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("add", lambda a, b: a + b, [other, self])
def __sub__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("subtract", lambda a, b: a - b, [self, other])
def __rsub__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("subtract", lambda a, b: a - b, [other, self])
def __mul__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("multiply", lambda a, b: a * b, [self, other])
def __rmul__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("multiply", lambda a, b: a * b, [other, self])
def __truediv__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("divide", lambda a, b: a / b if b else 0.0, [self, other])
def __rtruediv__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("divide", lambda a, b: a / b if b else 0.0, [other, self])
def __mod__(self, other: Any) -> "_AnimatedOperation":
return _AnimatedOperation("modulo", lambda a, b: math.fmod(a, b) if b else 0.0, [self, other])
def __neg__(self) -> "_AnimatedOperation":
return _AnimatedOperation("negate", lambda a: -a, [self])
# ======================================================================
# AnimatedValue: the settable leaf
# ======================================================================
class AnimatedValue(AnimatedNode):
"""A numeric cell that can be attached to native view properties.
Animated components (``Animated.View`` et al.) **attach** the value
to ``(tag, prop)`` bindings after mount. Setting the value pushes
the new number to every attached native view through the registry's
``set_animated_property`` (and through every derived node built
from this value), and when an animation can be driven natively, the
platform animates those same bindings directly.
Python-side listeners registered via
[`add_listener`][pythonnative.animated.AnimatedNode.add_listener]
observe every Python-driven change. Natively-driven animations
intentionally skip per-frame Python callbacks (that's the point);
listeners see the final settled value.
"""
__slots__ = ("_value", "_native_group")
def __init__(self, initial: float = 0.0) -> None:
super().__init__()
self._value = float(initial)
# The in-flight native animation group driving this value, if any.
self._native_group: Optional["_NativeAnimationGroup"] = None
@property
def value(self) -> float:
"""Return the current numeric value (without subscribing)."""
return self._value
def set_value(self, new_value: float) -> None:
"""Set the value immediately, pushing to native views and listeners."""
self._apply(float(new_value), push_native=True)
from .animation_graph import install
graph = install(self)
if graph and graph["bindings"]:
backend = _backend()
if backend is not None:
backend.set_animated_property(graph["bindings"][0][0], f"_pn_graph:{id(self)}", float(new_value))
def _apply(self, new_value: float, push_native: bool) -> None:
with self._lock:
self._value = new_value
subs = list(self._subscribers)
attachments = list(self._attachments)
children = list(self._children)
if push_native and attachments:
backend = _backend()
if backend is not None:
try:
for tag, prop in attachments:
backend.set_animated_property(tag, prop, new_value)
except Exception:
pass
for prop, cb in subs:
try:
cb(new_value)
except Exception:
pass
for child in children:
child._propagate(push_native)
# -- native handoff ------------------------------------------------
def _adopt_native_group(self, group: Optional["_NativeAnimationGroup"]) -> None:
previous = self._native_group
self._native_group = group
if previous is not None and previous is not group:
previous.cancel()
def stop_animation(self) -> None:
"""Cancel any in-flight animation on this value (native or Python)."""
self._adopt_native_group(None)
_manager.cancel_for_value(self)
def __repr__(self) -> str:
return f"AnimatedValue({self._value:g})"
# ======================================================================
# Derived nodes
# ======================================================================
def _parse_color_output(value: str) -> Optional[Tuple[int, int, int, int]]:
"""Parse ``"#RRGGBB"`` / ``"#AARRGGBB"`` into an ``(a, r, g, b)`` tuple."""
c = value.strip().lstrip("#")
if len(c) == 6:
c = "FF" + c
if len(c) != 8:
return None
try:
raw = int(c, 16)
except ValueError:
return None
return ((raw >> 24) & 0xFF, (raw >> 16) & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF)
def _parse_angle_output(value: str) -> Optional[float]:
"""Parse ``"45deg"`` / ``"0.5rad"`` into numeric degrees."""
text = value.strip()
try:
if text.endswith("deg"):
return float(text[:-3])
if text.endswith("rad"):
return math.degrees(float(text[:-3]))
except ValueError:
return None
return None
class AnimatedInterpolation(AnimatedNode):
"""Read-only node mapping a parent node through an input/output range.
Built via
[`AnimatedNode.interpolate`][pythonnative.animated.AnimatedNode.interpolate];
see that method for the semantics of the arguments.
"""
__slots__ = (
"_parent",
"_inputs",
"_outputs",
"_kind",
"_left",
"_right",
)
def __init__(
self,
parent: AnimatedNode,
input_range: Sequence[float],
output_range: Sequence[Any],
extrapolate: str = "extend",
extrapolate_left: Optional[str] = None,
extrapolate_right: Optional[str] = None,
) -> None:
super().__init__()
inputs = [float(v) for v in input_range]
outputs = list(output_range)
if len(inputs) < 2:
raise ValueError("interpolate() needs at least two input_range entries")
if len(inputs) != len(outputs):
raise ValueError("interpolate() input_range and output_range must have the same length")
for a, b in zip(inputs, inputs[1:]):
if b < a:
raise ValueError("interpolate() input_range must be monotonically non-decreasing")
kind = "number"
first = outputs[0]
if isinstance(first, str):
if _parse_color_output(first) is not None:
kind = "color"
outputs = [_parse_color_output(str(v)) for v in outputs]
if any(v is None for v in outputs):
raise ValueError("interpolate() color output_range entries must all be colors")
else:
angles = [_parse_angle_output(str(v)) for v in outputs]
if any(v is None for v in angles):
raise ValueError(f"interpolate() cannot parse output value {first!r}")
outputs = angles
else:
outputs = [float(v) for v in outputs]
self._parent = parent
self._inputs = inputs
self._outputs: List[Any] = outputs
self._kind = kind
self._left = extrapolate_left or extrapolate
self._right = extrapolate_right or extrapolate
parent._adopt_child(self)
@property
def value(self) -> Any:
"""Return the interpolated output for the parent's current value."""
return self._compute(float(self._parent))
def _compute(self, x: float) -> Any:
inputs = self._inputs
n = len(inputs)
if x < inputs[0]:
if self._left == "identity":
return x
if self._left == "clamp":
x = inputs[0]
i = 0
elif x > inputs[-1]:
if self._right == "identity":
return x
if self._right == "clamp":
x = inputs[-1]
i = n - 2
else:
i = max(0, min(n - 2, bisect.bisect_right(inputs, x) - 1))
x0, x1 = inputs[i], inputs[i + 1]
span = x1 - x0
t = 0.0 if span str:
return f"AnimatedInterpolation({self._inputs} -> {self._outputs})"
class _AnimatedOperation(AnimatedNode):
"""Read-only node computed from other nodes (and constants) by ``fn``."""
__slots__ = ("_op", "_fn", "_parents")
def __init__(self, op: str, fn: Callable[..., float], parents: List[Any]) -> None:
super().__init__()
self._op = op
self._fn = fn
self._parents = list(parents)
for parent in self._parents:
if isinstance(parent, AnimatedNode):
parent._adopt_child(self)
@property
def value(self) -> float:
args = [float(p) if isinstance(p, AnimatedNode) else float(p) for p in self._parents]
try:
return float(self._fn(*args))
except Exception:
return 0.0
def __repr__(self) -> str:
return f"AnimatedOperation({self._op})"
class _AnimatedDiffClamp(AnimatedNode):
"""Accumulate a parent's *deltas* into a clamped range.
Mirrors React Native's ``Animated.diffClamp``: the output moves by
the same amount as the input but is pinned to ``[min, max]``, so
scrolling far down then slightly up immediately re-reveals a
collapsing header regardless of absolute offset.
"""
__slots__ = ("_parent", "_min", "_max", "_last_input", "_current")
def __init__(self, parent: AnimatedNode, min_value: float, max_value: float) -> None:
super().__init__()
if max_value < min_value:
raise ValueError("diff_clamp() requires min_value eased`` callable. Descriptors and names run
on the native driver; a callable can't cross the bridge,
so it disables the native driver and ticks in Python.
Raises:
ValueError: For an unknown easing name.
TypeError: For an ``easing`` that is neither a descriptor,
a name, nor a callable.
"""
curve = _resolve_easing(easing)
native_eligible = isinstance(curve, EasingSpec)
def _spec() -> Dict[str, Any]:
return {
"kind": "timing",
"from": value.value,
"to": float(to),
"duration_ms": float(duration),
"easing": curve.to_wire() if isinstance(curve, EasingSpec) else "custom",
}
def _fallback() -> _RunningAnimation:
return _TimingAnimation(value, to, duration, _easing_function(curve))
return _AnimationHandle(value, _spec, _fallback, native_eligible=native_eligible)
@staticmethod
def spring(
value: AnimatedValue,
*,
to: float,
stiffness: float = 100.0,
damping: float = 10.0,
mass: float = 1.0,
initial_velocity: float = 0.0,
) -> _AnimationHandle:
"""Run a damped harmonic spring toward ``to``."""
def _spec() -> Dict[str, Any]:
return {
"kind": "spring",
"from": value.value,
"to": float(to),
"stiffness": float(stiffness),
"damping": float(damping),
"mass": float(mass),
"initial_velocity": float(initial_velocity),
}
def _fallback() -> _RunningAnimation:
return _SpringAnimation(value, to, stiffness, damping, mass, initial_velocity)
return _AnimationHandle(value, _spec, _fallback)
@staticmethod
def decay(
value: AnimatedValue,
*,
velocity: float,
deceleration: float = DEFAULT_DECAY_DECELERATION,
) -> _AnimationHandle:
"""Decelerate ``value`` from ``velocity`` until it rests (a fling).
Uses React Native's model on every platform: with ``t`` in
milliseconds, ``v(t) = velocity * deceleration ** t`` and the value
settles at ``value + velocity / (1 - deceleration)``.
Args:
value: The value to animate.
velocity: Initial velocity in points per millisecond. Gesture
events report points per second, so pass
``event.velocity_x / 1000``.
deceleration: Per-millisecond velocity retention in ``(0, 1)``;
``0.998`` (the default) is React Native's, ``0.99`` stops
quickly.
Raises:
ValueError: When ``deceleration`` is not strictly between
``0`` and ``1``.
Example:
```python
def on_pan_end(event):
pn.Animated.decay(tx, velocity=event.velocity_x / 1000).start()
```
"""
deceleration = float(deceleration)
if not 0.0 < deceleration < 1.0:
raise ValueError(f"deceleration must be strictly between 0 and 1 (got {deceleration!r})")
def _spec() -> Dict[str, Any]:
return {
"kind": "decay",
"from": value.value,
"velocity": float(velocity),
"deceleration": float(deceleration),
}
def _fallback() -> _RunningAnimation:
return _DecayAnimation(value, velocity, deceleration)
return _AnimationHandle(value, _spec, _fallback)
@staticmethod
def parallel(animations: List[Any]) -> _CompositeAnimation:
"""Run all ``animations`` concurrently; complete when all finish."""
return _CompositeAnimation(animations, "parallel")
@staticmethod
def sequence(animations: List[Any]) -> _CompositeAnimation:
"""Run ``animations`` one after another."""
return _CompositeAnimation(animations, "sequence")
@staticmethod
def stagger(delay: float, animations: List[Any]) -> _CompositeAnimation:
"""Run ``animations`` in parallel, each starting ``delay`` ms after the previous.
Args:
delay: Milliseconds between successive starts.
animations: Animation handles (or awaitables) to run.
Example:
```python
pn.Animated.stagger(80, [
pn.Animated.timing(v, to=1.0) for v in card_opacities
]).start()
```
"""
return _CompositeAnimation(animations, "stagger", stagger_ms=delay)
@staticmethod
def loop(animation: Any, *, iterations: int = -1, reset: bool = True) -> _LoopAnimation:
"""Repeat ``animation``, optionally forever.
Values driven by the animation are captured when the loop
starts and restored before each iteration (matching React
Native's ``resetBeforeIteration``), so ``timing`` loops replay
the same motion instead of animating in place.
Args:
animation: A handle from ``timing`` / ``spring`` / ``decay``
or a ``sequence`` / ``parallel`` / ``stagger`` composite.
iterations: Number of repetitions; ``-1`` (default) loops
until [`stop`][pythonnative.animated._LoopAnimation.stop]
is called or the awaiting task is cancelled.
reset: When ``False``, values continue from wherever the
previous iteration ended.
Example:
```python
pulse = pn.Animated.loop(
pn.Animated.sequence([
pn.Animated.timing(scale, to=1.15, duration=350),
pn.Animated.timing(scale, to=1.0, duration=350),
]),
).start()
# later: pulse.stop()
```
"""
return _LoopAnimation(animation, iterations=iterations, reset=reset)
@staticmethod
def delay(duration: float) -> _AnimationHandle:
"""Wait ``duration`` ms before continuing in a sequence."""
def _spec() -> Dict[str, Any]:
return {"kind": "delay", "duration_ms": float(duration)}
def _fallback() -> _RunningAnimation:
return _DelayAnimation(duration)
return _AnimationHandle(None, _spec, _fallback)
@staticmethod
def event(listener: Optional[Callable[..., None]] = None, **bindings: AnimatedValue) -> AnimatedEvent:
"""Build a callback that copies event fields into animated values.
Pass the result to any event prop. Each keyword maps a payload
field (dict key or dataclass attribute) onto an
[`AnimatedValue`][pythonnative.AnimatedValue]:
```python
scroll_y = pn.use_animated_value(0.0)
pn.ScrollView(..., on_scroll=pn.Animated.event(y=scroll_y))
tx = pn.use_animated_value(0.0)
gestures.Pan(on_change=pn.Animated.event(translation_x=tx))
```
Args:
listener: Optional plain callback invoked with the raw
event after the values update.
**bindings: ``field_name=animated_value`` pairs.
Returns:
A callable [`AnimatedEvent`][pythonnative.animated.AnimatedEvent].
"""
return AnimatedEvent(listener, **bindings)
@staticmethod
def diff_clamp(node: AnimatedNode, min_value: float, max_value: float) -> AnimatedNode:
"""Accumulate ``node``'s deltas into ``[min_value, max_value]``.
The classic collapsing-header primitive: unlike ``interpolate``
with ``"clamp"`` (which pins the *absolute* input), the output
tracks input *movement*, so a small scroll upward immediately
re-reveals the header no matter how far down the list is.
```python
header_shift = pn.Animated.diff_clamp(scroll_y, 0, 56)
```
"""
return _AnimatedDiffClamp(node, min_value, max_value)
View = staticmethod(_make_animated_factory("View", accept_children=True))
Text = staticmethod(_make_animated_factory("Text", accept_children=False))
Image = staticmethod(_make_animated_factory("Image", accept_children=False))
Animated = _AnimatedNamespace()
def use_animated_value(initial: float = 0.0) -> AnimatedValue:
"""Return an [`AnimatedValue`][pythonnative.AnimatedValue] that is stable across renders.
Convenience wrapper for the common pattern
``pn.use_memo(lambda: AnimatedValue(initial), [])``. The same
instance is returned on every render of the same component, so
you can drive it from event handlers without recreating it.
Args:
initial: The starting numeric value.
Returns:
A mount-stable [`AnimatedValue`][pythonnative.AnimatedValue].
Example:
```python
import pythonnative as pn
@pn.component
def FadeIn():
opacity = pn.use_animated_value(0.0)
async def fade_in():
await pn.Animated.timing(opacity, to=1.0, duration=300)
pn.use_effect(fade_in, [])
return pn.Animated.View(
pn.Text("Hello"),
style=pn.style(opacity=opacity),
)
```
"""
from .hooks import use_memo
return use_memo(
lambda: AnimatedValue(initial),
[], # pn: ignore[PN103] the initial value is read once, like use_state
)
__all__ = [
"ANIMATABLE_PROPS",
"AnimatedNode",
"AnimatedValue",
"AnimatedInterpolation",
"AnimatedEvent",
"Animated",
"AnimationResult",
"DECAY_REST_VELOCITY",
"DEFAULT_DECAY_DECELERATION",
"EASING_NAMES",
"Easing",
"EasingSpec",
"use_animated_value",
"native_animation_completed",
]