Table Of Contents

Source code for kivy.uix.lottie

'''
LottieWidget
============

A Kivy widget for displaying and controlling Lottie animations.

.. rubric:: Features

- Lottie JSON loading from :attr:`~LottieWidget.source` (local file path)
- Playback control via :attr:`state` (``'play'``, ``'pause'``, ``'stop'``),
  :meth:`seek`, and :attr:`progress` (0.0-1.0 normalised position)
- Standard image display properties: :attr:`fit_mode`, :attr:`color`,
  :attr:`norm_image_size`

- **Runtime DOM manipulation**
- **Segments and markers** - divide an animation into named or custom ranges
  and play them independently: :attr:`markers`, :meth:`play_segment`,
  :meth:`play_marker`
- **Slot overrides** - change colours, opacity, text, and images on named
  slots at runtime without reloading the file: :attr:`slots`,
  :meth:`has_slot`, :meth:`set_color`, :meth:`set_opacity`,
  :meth:`set_text`, :meth:`set_image`, :meth:`apply_slot`,
  :meth:`reset_slot`
- **Rendering quality** - tune the fidelity of blur and shadow effects:
  :meth:`set_quality`

.. rubric:: Basic usage

::

    from kivy.uix.lottie import LottieWidget

    w = LottieWidget(source='animation.json', size_hint=(None, None),
                     size=(400, 400), state='play')

KV::

    LottieWidget:
        source: 'animation.json'
        fit_mode: 'contain'
        state: 'play'
        eos: 'loop'
        on_load: print('ready', self.duration, 'seconds')
        on_eos: print('loop!')

.. rubric:: Slot colour override

::

    w = LottieWidget(source='branded.json')
    w.bind(on_load=lambda *a: w.set_color('brand_color', (0.2, 0.6, 1.0)))

.. versionadded:: 3.0.0
'''

from kivy.clock import Clock
from kivy.logger import Logger
from kivy.properties import (
    AliasProperty,
    BooleanProperty,
    ColorProperty,
    ListProperty,
    NumericProperty,
    ObjectProperty,
    OptionProperty,
    StringProperty,
)
from kivy.resources import resource_find
from kivy.uix.widget import Widget

__all__ = ('LottieWidget',)

_Lottie = None


def _get_lottie():
    # lazy load and cache the Lottie class
    global _Lottie
    if _Lottie is None:
        from kivy.core.lottie import Lottie
        _Lottie = Lottie
    return _Lottie


[docs] class LottieWidget(Widget): '''Widget that displays a Lottie animation. The animation is rasterized to a :class:`~kivy.graphics.texture.Texture` by the configured Lottie provider (:mod:`kivy.core.lottie`). The texture is shared with the canvas drawing instructions added by the KV rule in ``kivy/data/style.kv`` - the same rule that drives :class:`~kivy.uix.image.Image` and :class:`~kivy.uix.svg.SvgWidget`. All DOM manipulation methods delegate transparently to the underlying :class:`~kivy.core.lottie.LottieBase` provider. ''' # ------------------------------------------------------------------ # Display properties (mirrors Image / SvgWidget) # ------------------------------------------------------------------ source = StringProperty('') '''Path to the Lottie JSON file. Changing this property automatically loads the new animation. :attr:`source` is a :class:`~kivy.properties.StringProperty` and defaults to ``''``. ''' texture = ObjectProperty(None, allownone=True) '''Current rasterized :class:`~kivy.graphics.texture.Texture`. Updated on every :attr:`on_frame` event dispatched by the provider. :attr:`texture` is an :class:`~kivy.properties.ObjectProperty` and defaults to ``None``. ''' texture_size = ListProperty([0, 0]) '''Pixel dimensions ``[w, h]`` of the current :attr:`texture`. Updated whenever :attr:`texture` changes. :attr:`texture_size` is a :class:`~kivy.properties.ListProperty` and defaults to ``[0, 0]``. ''' fit_mode = OptionProperty( 'contain', options=('scale-down', 'fill', 'contain', 'cover'), ) '''Scaling mode for display within the widget bounds. Identical in meaning to :attr:`~kivy.uix.image.Image.fit_mode`. :attr:`fit_mode` is an :class:`~kivy.properties.OptionProperty` and defaults to ``'contain'``. ''' color = ColorProperty([1, 1, 1, 1]) '''Tint colour applied via the canvas ``Color`` instruction. :attr:`color` is a :class:`~kivy.properties.ColorProperty` and defaults to ``[1, 1, 1, 1]`` (opaque white - no tint). ''' # ------------------------------------------------------------------ # Status / state # ------------------------------------------------------------------ loaded = BooleanProperty(False) '''``True`` once the animation has loaded successfully. Use this as a guard before calling DOM manipulation methods:: if widget.loaded: widget.set_color('brand', (0.2, 0.6, 1.0)) :attr:`loaded` is a :class:`~kivy.properties.BooleanProperty` and defaults to ``False``. ''' # ------------------------------------------------------------------ # Playback properties # ------------------------------------------------------------------ state = OptionProperty('stop', options=('play', 'pause', 'stop')) '''String, indicates whether to play, pause, or stop the animation:: # start playing immediately at construction w = LottieWidget(source='anim.json', state='play') # start later w = LottieWidget(source='anim.json') w.state = 'play' :attr:`state` is an :class:`~kivy.properties.OptionProperty` and defaults to ``'stop'``. ''' position = NumericProperty(0.0) '''Current playback position in seconds. Setting this property seeks the animation. :attr:`position` is a :class:`~kivy.properties.NumericProperty` and defaults to ``0.0``. ''' duration = NumericProperty(0.0) '''Total animation duration in seconds (read-only after load). :attr:`duration` is a :class:`~kivy.properties.NumericProperty` and defaults to ``0.0``. ''' total_frames = NumericProperty(0.0) '''Total number of frames in the animation (read-only after load). :attr:`total_frames` is a :class:`~kivy.properties.NumericProperty` and defaults to ``0.0``. ''' def _get_progress(self): if self.duration <= 0: return 0.0 return max(0.0, min(1.0, self.position / self.duration)) def _set_progress(self, value): value = max(0.0, min(1.0, float(value))) if self.duration > 0: self.position = value * self.duration return True progress = AliasProperty( _get_progress, _set_progress, bind=('position', 'duration'), cache=True, ) '''Normalised playback position in the range 0.0-1.0. A computed view of :attr:`position` / :attr:`duration`. Setting it seeks the animation to the corresponding time. Useful for binding directly to a :class:`~kivy.uix.slider.Slider`:: Slider: min: 0 max: 1 value: anim.progress on_value: anim.progress = self.value :attr:`progress` is an :class:`~kivy.properties.AliasProperty` and defaults to ``0.0``. ''' eos = OptionProperty('loop', options=('loop', 'stop', 'pause')) '''End-of-animation behaviour. * ``'loop'`` - restart from the beginning (default). * ``'stop'`` - halt and rewind to frame 0. Use this when the first frame is the natural idle state of the animation (e.g. a spinner that should disappear or reset when done). * ``'pause'`` - halt and remain on the last frame. Use this for one-shot animations that end in a new visual state (e.g. a character settling into a new pose). Both ``'stop'`` and ``'pause'`` cancel the playback clock; the only difference is whether the playhead rewinds to frame 0 (``'stop'``) or stays at the final frame (``'pause'``). :attr:`eos` is an :class:`~kivy.properties.OptionProperty` and defaults to ``'loop'``. ''' # ------------------------------------------------------------------ # Events # ------------------------------------------------------------------ __events__ = ('on_load', 'on_eos', 'on_frame', 'on_error') # ------------------------------------------------------------------ # Image display helpers (mirrors SvgWidget) # ------------------------------------------------------------------ def _get_image_ratio(self): if self.texture: w, h = self.texture.size return w / max(1.0, h) return 1.0 image_ratio = AliasProperty( _get_image_ratio, bind=('texture',), cache=True, ) '''Aspect ratio of the current :attr:`texture` (width / height). :attr:`image_ratio` is a read-only :class:`~kivy.properties.AliasProperty`. '''
[docs] def get_norm_image_size(self): '''Return the display size of the animation after applying :attr:`fit_mode`. Mirrors :meth:`~kivy.uix.image.Image.get_norm_image_size` exactly. ''' if not self.texture: return list(self.size) ratio = self.image_ratio w, h = self.size tw, th = self.texture.size if self.fit_mode == 'cover': widget_ratio = w / max(1, h) if widget_ratio > ratio: return [w, (w * th) / tw] else: return [(h * tw) / th, h] elif self.fit_mode == 'fill': return [w, h] elif self.fit_mode == 'contain': iw = w else: # scale-down iw = min(w, tw) ih = iw / ratio if ih > h: if self.fit_mode == 'contain': ih = h else: ih = min(h, th) iw = ih * ratio return [iw, ih]
norm_image_size = AliasProperty( get_norm_image_size, bind=('texture', 'size', 'image_ratio', 'fit_mode'), cache=True, ) '''The portion of the widget area actually occupied by the animation after :attr:`fit_mode` geometry is applied. :attr:`norm_image_size` is a read-only :class:`~kivy.properties.AliasProperty`. ''' # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def __init__(self, **kwargs): self._lottie = None self._updating_position = False # flag to ignore position updates from provider fbind = self.fbind fbind('source', self._on_source) fbind('position', self._on_position_prop) fbind('eos', self._on_eos_prop) fbind('texture', self._on_texture) super().__init__(**kwargs) # ------------------------------------------------------------------ # Property change handlers # ------------------------------------------------------------------ def _on_source(self, *largs): source = self.source if not source: self._clear() return self.loaded = False path = resource_find(source) or source try: provider = _get_lottie()( filename=path, size=None, eos=self.eos, ) except Exception as exc: msg = f'LottieWidget: failed to create provider for {source!r}: {exc}' Logger.error(msg) self._set_error(msg) return if provider is None: msg = f'LottieWidget: no Lottie provider available for {source!r}' Logger.error(msg) self._set_error(msg) return self._install_provider(provider) def _install_provider(self, provider): '''Store *provider* and hook up event bindings.''' if self._lottie is not None: self._lottie.unbind( on_load=self._on_provider_load, on_eos=self._on_provider_eos, on_frame=self._on_provider_frame, ) self._lottie = provider provider.bind( on_load=self._on_provider_load, on_eos=self._on_provider_eos, on_frame=self._on_provider_frame, ) # ThorVG loads synchronously: by the time we reach here _duration is # already set but _texture is still None (texture is created lazily on # the first _render_frame call to avoid requiring a GL context at load # time). Always defer by one frame so callers have time to bind their # own on_load handlers after construction before the event fires. Clock.schedule_once(lambda dt: self._check_provider_ready(), 0) def _check_provider_ready(self): # Use _duration > 0 as the "successfully loaded" signal. _texture is # intentionally None until the first render tick; if self._lottie and self._lottie._duration > 0 and not self.loaded: self._on_provider_load(self._lottie) def _on_provider_load(self, provider, *largs): if provider is not self._lottie: return self.texture = provider._texture self.duration = provider._duration self.total_frames = provider._total_frames self.loaded = True # on_state fired when state was set but _lottie was None at that point, # so playback was not started. Re-apply the current state now that the # provider is ready. self.on_state(self, self.state) self.dispatch('on_load') def _on_provider_eos(self, provider, *largs): if provider is not self._lottie: return # For ``eos`` ``'stop'`` the provider will halt after this dispatch; mirror # that on :attr:`state` so the next ``state = 'play'`` is observed and # :meth:`on_state` runs (otherwise ``state`` would stay ``'play'`` and # playback would not restart). if self.eos == 'stop': self.state = 'stop' self.dispatch('on_eos') def _on_provider_frame(self, provider, *largs): if provider is not self._lottie: return self.texture = provider._texture # The texture pixels are updated in-place via blit_buffer every frame, # so the object reference never changes after the first render. Kivy's # property binding therefore never fires after frame 1. Ask the canvas # to redraw explicitly - same approach as kivy.uix.video.Video. self.canvas.ask_update() # Mirror the provider's current position back onto the widget property # so that derived properties (progress, on_position bindings, KV rules) # reflect playback. The _updating_position flag stops _on_position_prop # from treating this as a user seek request. self._updating_position = True self.position = provider._position self._updating_position = False def _on_texture(self, instance, texture): self.texture_size = list(texture.size) if texture else [0, 0]
[docs] def on_state(self, instance, value): '''Kivy property event - sync the provider whenever ``state`` changes.''' if self._lottie is None: return if value == 'play': self._lottie.play() elif value == 'pause': self._lottie.pause() else: self._lottie.stop()
def _on_position_prop(self, instance, value): '''Seek when ``position`` property is changed externally.''' # Ignore updates that originate from _on_provider_frame mirroring the # provider's position back onto the widget - those are read-backs, not # user seek requests. if self._updating_position: return if self._lottie is None or self._lottie._duration <= 0: return pct = max(0.0, min(1.0, value / self._lottie._duration)) self._lottie.seek(pct) def _on_eos_prop(self, instance, value): if self._lottie is not None: self._lottie.eos = value # ------------------------------------------------------------------ # Playback control (public API) # ------------------------------------------------------------------
[docs] def seek(self, percent): '''Seek to *percent* (0.0-1.0) through the animation. :param float percent: Position as a fraction of the total duration. ''' if self._lottie is not None: self._lottie.seek(float(percent)) if self._lottie is not None and self._lottie._duration > 0: self.position = float(percent) * self._lottie._duration
# ------------------------------------------------------------------ # Event handlers (defaults) # ------------------------------------------------------------------ def on_load(self, *args): pass def on_eos(self, *args): pass def on_frame(self, *args): pass def on_error(self, error, *args): pass # ------------------------------------------------------------------ # Segments and markers # ------------------------------------------------------------------ @property def markers(self): '''List of marker names in the loaded animation. :rtype: list[str] ''' if self._lottie is None: return [] return self._lottie.markers
[docs] def play_segment(self, begin, end): '''Restrict playback to the frame range [*begin*, *end*]. After the call, :attr:`duration`, :attr:`total_frames`, and :attr:`position` are updated to reflect the new segment so that progress reporting and EOS detection remain accurate. :param float begin: First frame (inclusive). :param float end: Last frame (inclusive). ''' if self._lottie is None: return self._lottie.play_segment(begin, end) self._sync_segment_props()
[docs] def play_marker(self, name): '''Restrict playback to the named marker range. After the call, :attr:`duration`, :attr:`total_frames`, and :attr:`position` are updated to reflect the marker's frame range so that progress reporting and EOS detection remain accurate. :param str name: Marker name (see :attr:`markers`). :raises ValueError: If the marker does not exist. ''' if self._lottie is None: return self._lottie.play_marker(name) self._sync_segment_props()
def _sync_segment_props(self): '''Pull duration/total_frames/position from the provider after a segment or marker change and update the corresponding widget properties so that EOS detection, progress, and UI bindings stay consistent with the new playback range. ''' p = self._lottie if p is None: return self.duration = p._duration self.total_frames = p._total_frames self.position = p._position # ------------------------------------------------------------------ # Slot overrides # ------------------------------------------------------------------ @property def slots(self): '''Frozenset of slot IDs in the loaded animation. :rtype: frozenset[str] ''' if self._lottie is None: return frozenset() return self._lottie.slots
[docs] def has_slot(self, slot_id): '''Return ``True`` if *slot_id* is present in the loaded animation. :param str slot_id: Slot ID to check. :rtype: bool ''' if self._lottie is None: return False return self._lottie.has_slot(slot_id)
[docs] def apply_slot(self, slot_data): '''Apply a raw slot override from a JSON string and return the slot handle. Prefer the typed helpers (:meth:`set_color`, :meth:`set_opacity`, :meth:`set_text`, :meth:`set_image`) for common cases. Use this method when you need to apply a slot structure that the helpers do not cover. :param str slot_data: Slot override as a JSON string. :returns: Slot handle (pass to :meth:`reset_slot` to remove), or ``None`` on failure or when no animation is loaded. :rtype: int or None ''' if self._lottie is None: return None return self._lottie.apply_slot(slot_data)
[docs] def reset_slot(self, slot_id=None): '''Remove a slot override or clear all overrides. :param slot_id: Slot ID string, or ``None`` to clear all. :type slot_id: str or None ''' if self._lottie is not None: self._lottie.reset_slot(slot_id)
[docs] def set_color(self, slot_id, color): '''Override the fill colour of a slot. :param str slot_id: Slot ID. :param color: ``(r, g, b)`` or ``(r, g, b, a)`` floats 0.0-1.0. :returns: Slot handle or ``None``. :rtype: int or None ''' if self._lottie is None: return None return self._lottie.set_color(slot_id, color)
[docs] def set_opacity(self, slot_id, opacity): '''Override the opacity of a slot. :param str slot_id: Slot ID. :param float opacity: 0.0 (transparent) - 1.0 (opaque). :returns: Slot handle or ``None``. :rtype: int or None ''' if self._lottie is None: return None return self._lottie.set_opacity(slot_id, opacity)
[docs] def set_text(self, slot_id, text): '''Override the text content of a text slot. :param str slot_id: Slot ID. :param str text: New text string. :returns: Slot handle or ``None``. :rtype: int or None ''' if self._lottie is None: return None return self._lottie.set_text(slot_id, text)
[docs] def set_image(self, slot_id, source): '''Override the image of an image slot. :param str slot_id: Slot ID. :param str source: Absolute path to the replacement image. :returns: Slot handle or ``None``. :rtype: int or None ''' if self._lottie is None: return None return self._lottie.set_image(slot_id, source)
# ------------------------------------------------------------------ # Rendering quality # ------------------------------------------------------------------
[docs] def set_quality(self, value): '''Set the rendering quality level (0-100). :param int value: 0 = fastest, 100 = best. ''' if self._lottie is not None: self._lottie.set_quality(value)
[docs] def refresh(self): '''Force an immediate re-render of the current frame.''' if self._lottie is not None: self._lottie.refresh()
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _clear(self): if self._lottie is not None: self._lottie.unbind( on_load=self._on_provider_load, on_eos=self._on_provider_eos, on_frame=self._on_provider_frame, ) self._lottie.unload() self._lottie = None self.texture = None self.duration = 0.0 self.total_frames = 0.0 self.loaded = False self.state = 'stop' self.position = 0.0 def _set_error(self, msg): self.loaded = False self.dispatch('on_error', msg)