/ client_code / animation.py
animation.py
  1  # SPDX-License-Identifier: MIT
  2  #
  3  # Copyright (c) 2021 The Anvil Extras project team members listed at
  4  # https://github.com/anvilistas/anvil-extras/graphs/contributors
  5  #
  6  # This software is published at https://github.com/anvilistas/anvil-extras
  7  
  8  from anvil.js import await_promise as _await_promise
  9  from anvil.js import get_dom_node as _dom_node
 10  from anvil.js import window as _window
 11  
 12  __version__ = "3.1.0"
 13  
 14  
 15  class _Easing:
 16      def __init__(self):
 17          # really should be class variables
 18          # but anvil autocomplete prefers instance variables
 19          self.ease_in_out = "ease-in-out"
 20          self.ease_out = "ease-out"
 21          self.ease_in = "ease-in"
 22          self.ease = "ease"
 23          self.linear = "linear"
 24  
 25      def cubic_bezier(self, po, p1, p2, p3):
 26          """creates a cubic-bezier easing value from 4 numerical values"""
 27          return f"cubic-bezier({po}, {p1}, {p2}, {p3})"
 28  
 29  
 30  Easing = _Easing()
 31  
 32  
 33  _transforms = {
 34      "matrix",
 35      "translate",
 36      "translateX",
 37      "translateY",
 38      "scale",
 39      "scaleX",
 40      "scaleY",
 41      "rotate",
 42      "skew",
 43      "skewX",
 44      "skewY",
 45      "matrix3d",
 46      "translate3d",
 47      "translateZ",
 48      "scale3d",
 49      "scaleZ",
 50      "rotate3d",
 51      "rotateX",
 52      "rotateY",
 53      "rotateZ",
 54      "perspective",
 55  }
 56  
 57  
 58  class Transition(dict):
 59      """Create a transtion object.
 60      Takes CSS/transform property names as keyword arguments and each value should be a list of frames for that property.
 61      The number of frames must match across all properties.
 62  
 63      e.g. slide_right = Transition(translateX=[0, "100%"])
 64  
 65      Each list item represents a CSS value to be applied across the transition.
 66      Typically the first value is the start of the transition and the last value is the end.
 67      Lists can be more than 2 values in which case the transition will be split across the values evenly.
 68      You can customize the even split by setting an offset that has values from 0, 1
 69  
 70      e.g. fade_in_slow = Transition(opacity=[0, 0.25, 1], offset=[0, 0.75, 1])
 71  
 72      Transition objects can be combined with the | operator (which behaves like merging dictionaries)
 73      e.g. t = reversed(slide_right) | zoom_in | fade_in | Transition.height_in(component)
 74      """
 75  
 76      def __new__(cls, **transitions):
 77          t_keys = set()
 78          t_len = None
 79          for key, val in transitions.items():
 80              assert (
 81                  type(val) is list or type(val) is tuple
 82              ), "all transitions must be lists"
 83              if key not in _transforms:
 84                  continue
 85              t_keys.add(key)
 86              if t_len is None:
 87                  t_len = len(val)
 88              else:
 89                  assert t_len == len(
 90                      val
 91                  ), "transform based transitions must all have the same frame length"
 92          return cls._create(transitions, frozenset(t_keys), t_len)
 93  
 94      def __init__(
 95          self,
 96          *,
 97          opacity=None,
 98          scale=None,
 99          translateX=None,
100          translateY=None,
101          rotate=None,
102          backgroundColor=None,
103          offset=None,
104          **css_transitions,
105      ):
106          # just for the autocomplete - some common CSS transitions
107          pass
108  
109      @classmethod
110      def _create(cls, transitions, transform_keys, transform_len):
111          self = dict.__new__(cls)
112          dict.__init__(self, **transitions)
113          self._t_keys = transform_keys
114          self._t_len = transform_len
115          return self
116  
117      def __repr__(self):
118          return f"Transition({dict.__repr__(self)})"
119  
120      @staticmethod
121      def _check_other(other):
122          if isinstance(other, Transition):
123              return other
124          elif isinstance(other, dict):
125              return Transition(**other)
126          else:
127              return NotImplemented
128  
129      def __or__(self, other):
130          other = self._check_other(other)
131          if other is NotImplemented:
132              return NotImplemented
133  
134          self_len, other_len = self._t_len, other._t_len
135  
136          merged = dict.__or__(self, other)
137  
138          if self_len is None:
139              return self._create(merged, other._t_keys, other_len)
140          elif other_len is None:
141              return self._create(merged, self._t_keys, self_len)
142          elif other_len != self_len:
143              raise ValueError(
144                  "can't combine Transition objects with different frame lengths for transform based transitions"
145              )
146  
147          return self._create(merged, self._t_keys | other._t_keys, self_len)
148  
149      def __ror__(self, other):
150          other = self._check_other(other)
151          if other is NotImplemented:
152              return NotImplemented
153          return other.__or__(self)
154  
155      def __reversed__(self):
156          reverse = {}
157          for key, val in self.items():
158              reverse[key] = list(reversed(val))
159          return self._create(reverse, self._t_keys, self._t_len)
160  
161      @classmethod
162      def _h_w(cls, component, attr, out=True):
163          hw = get_bounding_rect(component)[attr]
164          return cls(**{attr: [f"{hw}px", 0] if out else [0, f"{hw}px"]})
165  
166      @classmethod
167      def height_out(cls, component):
168          return cls._h_w(component, "height", True)
169  
170      @classmethod
171      def width_out(cls, component):
172          return cls._h_w(component, "width", True)
173  
174      @classmethod
175      def height_in(cls, component):
176          return cls._h_w(component, "height", False)
177  
178      @classmethod
179      def width_in(cls, component):
180          return cls._h_w(component, "width", False)
181  
182      def _compute(self):
183          # combines transforms into a single string
184          copy = self.copy()
185          if self._t_len is None:
186              return copy
187          transform = [""] * self._t_len
188  
189          for key in self._t_keys:
190              frames = copy.pop(key, None)
191              if frames is None:
192                  # This shouldn't happen
193                  continue
194              for i, val in enumerate(frames):
195                  transform[i] += f"{key}({val}) "
196          copy["transform"] = transform
197  
198          return copy
199  
200  
201  # Pre-computed styles:
202  # https://web-animations.github.io/web-animations-demos/#animate_CSS/
203  pulse = Transition(scale=[1, 1.05, 1])
204  bounce = Transition(
205      translateY=[0, 0, "-30px", "-30px", 0, "-15px", 0, "-15px", 0],
206      offset=[0, 0.2, 0.4, 0.43, 0.53, 0.7, 0.8, 0.9, 1],
207  )
208  shake = Transition(translateX=[0] + ["10px", "-10px"] * 4 + [0])
209  
210  fade_in = Transition(opacity=[0, 1])
211  fade_in_slow = Transition(opacity=[0, 0.25, 1], offset=[0, 0.75, 1])
212  fade_out = reversed(fade_in)
213  
214  slide_in_up = Transition(translateY=["100%", 0])
215  slide_in_down = Transition(translateY=["-100%", 0])
216  slide_in_left = Transition(translateX=["-100%", 0])
217  slide_in_right = Transition(translateX=["100%", 0])
218  
219  slide_out_up = reversed(slide_in_down)
220  slide_out_down = reversed(slide_in_up)
221  slide_out_left = reversed(slide_in_left)
222  slide_out_right = reversed(slide_in_right)
223  
224  rotate = Transition(rotate=[0, "360deg"])
225  
226  zoom_in = Transition(scale=[0.3, 1])
227  zoom_out = reversed(zoom_in)
228  
229  fly_in_up = slide_in_up | zoom_in | fade_in
230  fly_in_down = slide_in_down | zoom_in | fade_in
231  fly_in_left = slide_in_left | zoom_in | fade_in
232  fly_in_right = slide_in_right | zoom_in | fade_in
233  
234  fly_out_up = reversed(fly_in_down)
235  fly_out_down = reversed(fly_in_up)
236  fly_out_left = reversed(fly_in_left)
237  fly_out_right = reversed(fly_in_right)
238  
239  
240  # add a method to the window.Animation class for our convenience
241  _window.Function(
242      """
243  Animation.prototype.wait = function() {
244      return this.finished;
245  };
246  """
247  )()
248  
249  
250  class Animation:
251      """This is a wrapper around the Browser Animation object.
252      It is the return value from animate(), or Effect.animate()
253      Can be created in code with a component and an Effect"""
254  
255      def __new__(cls, component=None, effect=None, *, _a=None):
256          # we just return the animation object
257          # the only job of this class is to provide autocompletions
258          if _a is not None:
259              # we're already an animation
260              return _a
261          elif component is None or effect is None:
262              raise TypeError(
263                  "An Animation can only be created with a Component (or DOM node) and an Effect"
264              )
265          el = _dom_node(component)
266          keyframes = effect.getKeyframes()
267          timings = effect.getTimings()
268          return _window.Animation(_a=_window.KeyframeEffect(el, keyframes, timings))
269  
270      def __init__(self, component, effect):
271          pass
272  
273      def cancel(self) -> None:
274          "abort animation playback"
275  
276      def commitStyles(self) -> None:
277          "Commits the end styling state of an animation to the element"
278  
279      def finish(self) -> None:
280          "Seeks the end of an animation"
281  
282      def pause(self) -> None:
283          "Suspends playing of an animation"
284  
285      def play(self) -> None:
286          "Starts or resumes playing of an animation, or begins the animation again if it previously finished."
287  
288      def persist(self) -> None:
289          "Explicitly persists an animation, when it would otherwise be removed."
290  
291      def reverse(self) -> None:
292          "Reverses playback direction and plays"
293  
294      def updatePlaybackRate(self, playback_rate) -> None:
295          "The new speed to set. A positive number (to speed up or slow down the animation), a negative number (to reverse), or zero (to pause)."
296  
297      def wait(self) -> None:
298          "Animations are not blocking. Call the wait function to wait for an animation to finish in a blocking way"
299  
300      @property
301      def playbackRate(self) -> int or float:
302          "gets or sets the playback rate"
303  
304      @property
305      def onfinish(self):
306          "set a callback for when the animation finishes"
307  
308      @property
309      def oncancel(self):
310          "set a callback for when the animation is cancelled"
311  
312      @property
313      def onremove(self):
314          "set a callback for when the animation is removed"
315  
316  
317  def _animate(component, keyframes, options, use_ghost=False):
318      if isinstance(keyframes, Transition):
319          keyframes = keyframes._compute()
320      el = _dom_node(component)
321  
322      if use_ghost:
323          _animate_ghost(el, keyframes, options)
324  
325      return Animation(_a=el.animate(keyframes, options))
326  
327  
328  _window.Function(
329      "_animate",
330      """
331  KeyframeEffect.prototype.animate = function(component, ghost=false) {
332      const keyframes = this.getKeyframes();
333      const timing = this.getTiming();
334      return _animate(component, keyframes, timing, ghost);
335  }
336  """,
337  )(_animate)
338  
339  
340  class Effect:
341      """Create an effect that can later be used to animate a component.
342      The first argument should be a Transition object.
343      The remainder of the values are timing options"""
344  
345      def __new__(cls, transition=None, duration=333, **timings):
346          if isinstance(transition, Transition):
347              transition = transition._compute()
348          timings["duration"] = duration
349          return _window.KeyframeEffect(None, transition, timings)
350  
351      def __init__(
352          self,
353          transition=None,
354          duration=333,
355          *,
356          delay=0,
357          direction="normal",
358          easing="linear",
359          endDelay=0,
360          fill="none",
361          iterations=1,
362          iterationStart=0,
363          composite="replace",
364      ):
365          pass
366  
367      def animate(self, component, use_ghost=False) -> Animation:
368          """animate a component using an effect.
369          If use_ghost is True a ghost element will be animated.
370          use_ghoste allows components to be animated outside of their container
371          """
372          return Animation(_a=None)  # just so the autocompleter knows the return type
373  
374      def getKeyframes(self, component):
375          "Returns the computed keyframes that make up this effect"
376  
377      def getTiming(self, component):
378          "The EffectTiming object associated with the animation"
379  
380  
381  def animate(
382      component,
383      transition=None,
384      duration=333,
385      *,
386      start_at=None,
387      end_at=None,
388      use_ghost=False,
389      delay=0,
390      direction="normal",
391      easing="linear",
392      endDelay=0,
393      fill="none",
394      iterations=1,
395      iterationStart=0,
396      composite="replace",
397  ):
398      """a wrapper around the browser's Animation API. see MDN docs for full details
399      component: an anvil Component or Javascript HTMLElement
400      transition: Transition object
401      **effect_timing: various options to change the behaviour of the animation e.g. duration.
402  
403      Anvil specific arguments:
404      use_ghost: when set to True will allow the component to be animated outside of its container
405  
406      start_at, end_at: Can be set to a component or DOMRect (i.e. a computed position of a component from get_bounding_rect)
407      If either start_at or end_at are set this will determine the start/end position of the animation
408      If one value is set and the other omitted the omitted value will be assumed to be the current position of the componenent.
409      A ghost element is always used when start_at/end_at are set.
410      """
411      effect_timing = {
412          "duration": duration,
413          "delay": delay,
414          "direction": direction,
415          "easing": easing,
416          "endDelay": endDelay,
417          "fill": fill,
418          "iterations": iterations,
419          "iterationStart": iterationStart,
420          "composite": composite,
421      }
422      if start_at is not None or end_at is not None:
423          # we use a ghost here regardless
424          return _animate_from_to(
425              component,
426              start_at or component,
427              end_at or component,
428              transition,
429              effect_timing,
430          )
431      return _animate(component, transition, effect_timing, use_ghost)
432  
433  
434  def is_animating(component, include_children=False) -> bool:
435      """Determines whether a component is currently animating"""
436      el = _dom_node(component)
437      return any(
438          a.playState == "running"
439          for a in el.getAnimations({"subtree": include_children})
440      )
441  
442  
443  def wait_for(animation_or_component, include_children=False):
444      """If given an animation equivalent to animateion.wait().
445      If given a component, will wait for all running animations on the component to finish
446      """
447      if hasattr(animation_or_component, "finished"):
448          _await_promise(animation_or_component.finished)
449          return
450      el = _dom_node(animation_or_component)
451      animations = el.getAnimations({"subtree": include_children})
452      _window.Promise.all(list(map(lambda a: a.finished, animations)))
453  
454  
455  class DOMRect:
456      # For autocompletions only
457      def __new__(cls, *, x=None, y=None, width=None, height=None, obj=None):
458          if obj is not None:
459              return obj
460          else:
461              return _window.DOMRect(x, y, width, height)
462  
463      def __init__(self, *, x, y, width, height):
464          # another just for the autocomplete
465          pass
466  
467      @property
468      def x(self) -> int or float:
469          "x position on the page"
470  
471      @property
472      def y(self) -> int or float:
473          "y position on the page"
474  
475      @property
476      def height(self) -> int or float:
477          pass
478  
479      @property
480      def width(self) -> int or float:
481          pass
482  
483      @property
484      def left(self) -> int or float:
485          "equivalent to x"
486  
487      @property
488      def top(self) -> int or float:
489          "equivalent to y"
490  
491  
492  def get_bounding_rect(component) -> DOMRect:
493      """returns an object with attributes relating to the position of the component on the page: x, y, width, height"""
494      if component.__class__ == _window.DOMRect:
495          return component
496      el = _dom_node(component)
497      return DOMRect(obj=el.getBoundingClientRect())
498  
499  
500  def _animate_ghost(el, keyframes, options):
501      # TODO if web animations support GroupAnimations in the future we should use that here
502      ghost = el.cloneNode(True)
503      pos = el.getBoundingClientRect(True)
504      _window.Object.assign(
505          ghost.style,
506          {
507              "position": "absolute",
508              "left": f"{pos.x}px",
509              "top": f"{pos.y}px",
510              "width": f"{pos.width}px",
511              "height": f"{pos.height}px",
512              "margin": "0",
513          },
514      )
515      _window.document.body.append(ghost)
516  
517      el.style.visibility = "hidden"
518  
519      def ghost_finish(e):
520          el.style.visibility = "visible"
521          ghost.remove()
522  
523      ghost.animate(keyframes, options).addEventListener("finish", ghost_finish)
524  
525  
526  def _animate_from_to(component, c1, c2, t, options):
527      el = _dom_node(component)
528      pos = el.getBoundingClientRect()
529      pos1, pos2 = get_bounding_rect(c1), get_bounding_rect(c2)
530      t_fromto = Transition(
531          translateX=[f"{pos1.x - pos.x}px", f"{pos2.x - pos.x}px"],
532          translateY=[f"{pos1.y - pos.y}px", f"{pos2.y - pos.y}px"],
533      )
534      if pos1.width != pos2.width:
535          t_fromto["width"] = [pos1.width, pos2.width]
536      if pos1.height != pos2.height:
537          t_fromto["height"] = [pos1.height, pos2.height]
538  
539      t = (t or {}) | t_fromto
540  
541      # we create a ghost node
542      return Animation(_a=_animate(component, t, options, use_ghost=True))