/ 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))