From 8a65f99fee3c40706cc2026d7d290707cf361a37 Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Thu, 18 Apr 2024 22:46:14 +0530 Subject: [PATCH] add `MDAnimationTransition` --- examples/md_axis_transition.py | 5 +- examples/md_transitions.py | 77 ++++++++++ kivymd/__init__.py | 1 + kivymd/animation.py | 219 ++++++++++++++++++++++++++++ kivymd/uix/transition/transition.py | 25 +++- 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 examples/md_transitions.py create mode 100644 kivymd/animation.py diff --git a/examples/md_axis_transition.py b/examples/md_axis_transition.py index 47249083f..7548c60e0 100644 --- a/examples/md_axis_transition.py +++ b/examples/md_axis_transition.py @@ -41,8 +41,7 @@ text:root.subtext font_style:"Label" role:"large" - theme_text_color:"Custom" - text_color:app.theme_cls.surfaceContainerLowestColor[:-1] + [0.5] + text_color:app.theme_cls.onSurfaceVariantColor : name:"main" @@ -96,7 +95,7 @@ font_style:"Body" role:"large" theme_text_color:"Custom" - text_color:app.theme_cls.surfaceContainerLowestColor[:-1] + [0.5] + text_color:app.theme_cls.onSurfaceVariantColor Image: size_hint_y:1 source:app.image_path diff --git a/examples/md_transitions.py b/examples/md_transitions.py new file mode 100644 index 000000000..ab55d72d4 --- /dev/null +++ b/examples/md_transitions.py @@ -0,0 +1,77 @@ +from kivy.lang import Builder +from kivy.animation import Animation +from kivy.uix.boxlayout import BoxLayout +from kivy.clock import Clock +from kivy.metrics import dp +from kivy.properties import ListProperty + +from kivymd.app import MDApp + + +class AnimBox(BoxLayout): + obj_pos = ListProperty([0, 0]) + + +UI = """ +: + transition:"in_out_bounce" + size_hint_y:None + height:dp(100) + obj_pos:[dp(40), self.pos[-1] + dp(40)] + canvas: + Color: + rgba:app.theme_cls.primaryContainerColor + Rectangle: + size:[self.size[0], dp(5)] + pos:self.pos[0], self.pos[-1] + dp(50) + Color: + rgba:app.theme_cls.primaryColor + Rectangle: + size:[dp(30)] * 2 + pos:root.obj_pos + MDLabel: + adaptive_height:True + text:root.transition + padding:[dp(10), 0] + halign:"center" + +MDGridLayout: + orientation:"lr-tb" + cols:1 + md_bg_color:app.theme_cls.backgroundColor + spacing:dp(10) +""" + + +class MotionApp(MDApp): + + def build(self): + return Builder.load_string(UI) + + def on_start(self): + for transition in [ + "easing_linear", + "easing_accelerated", + "easing_decelerated", + "easing_standard", + "in_out_cubic" + ]: # Add more here for comparison + print(transition) + widget = AnimBox() + widget.transition = transition + self.root.add_widget(widget) + Clock.schedule_once(self.run_animation, 1) + + _inverse = True + + def run_animation(self, dt): + x = (self.root.children[0].width - dp(30)) if self._inverse else 0 + for widget in self.root.children: + Animation( + obj_pos=[x, widget.obj_pos[-1]], t=widget.transition, d=3 + ).start(widget) + self._inverse = not self._inverse + Clock.schedule_once(self.run_animation, 3.1) + + +MotionApp().run() diff --git a/kivymd/__init__.py b/kivymd/__init__.py index 52f969ccc..40f670c06 100644 --- a/kivymd/__init__.py +++ b/kivymd/__init__.py @@ -60,4 +60,5 @@ import kivymd.factory_registers # NOQA import kivymd.font_definitions # NOQA +import kivymd.animation # NOQA from kivymd.tools.packaging.pyinstaller import hooks_path # NOQA diff --git a/kivymd/animation.py b/kivymd/animation.py new file mode 100644 index 000000000..9bcce8340 --- /dev/null +++ b/kivymd/animation.py @@ -0,0 +1,219 @@ +""" +Animation +========= + +.. versionadded:: 2.0.0 + +Adds new transitions to the :class:`~kivy.animation.AnimationTransition` class: +- "easing_standard" +- "easing_decelerated" +- "easing_accelerated" +- "easing_linear" + + +.. code-block:: python + + + from kivy.lang import Builder + from kivy.animation import Animation + from kivy.uix.boxlayout import BoxLayout + from kivy.clock import Clock + from kivy.metrics import dp + from kivy.properties import ListProperty + + from kivymd.app import MDApp + + + class AnimBox(BoxLayout): + obj_pos = ListProperty([0, 0]) + + + UI = ''' + : + transition:"in_out_bounce" + size_hint_y:None + height:dp(100) + obj_pos:[dp(40), self.pos[-1] + dp(40)] + canvas: + Color: + rgba:app.theme_cls.primaryContainerColor + Rectangle: + size:[self.size[0], dp(5)] + pos:self.pos[0], self.pos[-1] + dp(50) + Color: + rgba:app.theme_cls.primaryColor + Rectangle: + size:[dp(30)] * 2 + pos:root.obj_pos + MDLabel: + adaptive_height:True + text:root.transition + padding:[dp(10), 0] + halign:"center" + + MDGridLayout: + orientation:"lr-tb" + cols:1 + md_bg_color:app.theme_cls.backgroundColor + spacing:dp(10) + ''' + + + class MotionApp(MDApp): + + def build(self): + return Builder.load_string(UI) + + def on_start(self): + for transition in [ + "easing_linear", + "easing_accelerated", + "easing_decelerated", + "easing_standard", + "in_out_cubic" + ]: # Add more here for comparison + print(transition) + widget = AnimBox() + widget.transition = transition + self.root.add_widget(widget) + Clock.schedule_once(self.run_animation, 1) + + _inverse = True + def run_animation(self, dt): + x = (self.root.children[0].width - dp(30)) if self._inverse else 0 + for widget in self.root.children: + Animation( + obj_pos=[x, widget.obj_pos[-1]], t=widget.transition, d=3 + ).start(widget) + self._inverse = not self._inverse + Clock.schedule_once(self.run_animation, 3.1) + + MotionApp().run() + +.. image:: https://github.com/kivymd/KivyMD/assets/68729523/21c847b0-284a-4796-b704-e4a2531fbb1b + :align: center +""" + +import math +import kivy.animation + +float_epsilon = 8.3446500e-7 + + +class CubicBezier: + """Ported from Android source code""" + + p0 = 0 + p1 = 0 + p2 = 0 + p3 = 0 + + def __init__(self, *args): + self.p0, self.p1, self.p2, self.p3 = args + + def evaluate_cubic(self, p1, p2, t): + a = 1.0 / 3.0 + (p1 - p2) + b = p2 - 2.0 * p1 + c = p1 + return 3.0 * ((a * t + b) * t + c) * t + + def clamp_range(self, r): + if r < 0.0: + if -float_epsilon <= r < 0.0: + return 0.0 + else: + return math.nan + elif r > 1.0: + if 1.0 <= r <= 1.0 + float_epsilon: + return 1.0 + else: + return math.nan + else: + return r + + def close_to(self, x, y): + return abs(x - y) < float_epsilon + + def find_first_cubic_root(self, p0, p1, p2, p3): + a = 3.0 * (p0 - 2.0 * p1 + p2) + b = 3.0 * (p1 - p0) + c = p0 + d = -p0 + 3.0 * (p1 - p2) + p3 + if self.close_to(d, 0.0): + if self.close_to(a, 0.0): + if self.close_to(b, 0.0): + return math.nan + return self.clamp_range(-c / b) + else: + q = math.sqrt(b * b - 4.0 * a * c) + a2 = 2.0 * a + root = self.clamp_range((q - b) / a2) + if not math.isnan(root): + return root + return self.clamp_range((-b - q) / a2) + a /= d + b /= d + c /= d + o3 = (3.0 * b - a * a) / 9.0 + q2 = (2.0 * a * a * a - 9.0 * a * b + 27.0 * c) / 54.0 + discriminant = q2 * q2 + o3 * o3 * o3 + a3 = a / 3.0 + + if discriminant < 0.0: + mp33 = -(o3 * o3 * o3) + r = math.sqrt(mp33) + t = -q2 / r + cos_phi = max(-1.0, min(t, 1.0)) + phi = math.acos(cos_phi) + t1 = 2.0 * math.cbrt(r) + root = self.clamp_range(t1 * math.cos(phi / 3.0) - a3) + if not math.isnan(root): + return root + root = self.clamp_range( + t1 * math.cos((phi + 2.0 * math.pi) / 3.0) - a3 + ) + if not math.isnan(root): + return root + return self.clamp_range( + t1 * math.cos((phi + 4.0 * math.pi) / 3.0) - a3 + ) + + elif self.close_to(discriminant, 0.0): + u1 = -math.cbrt(q2) + root = self.clamp_range(2.0 * u1 - a3) + if not math.isnan(root): + return root + return self.clamp_range(-u1 - a3) + + sd = math.sqrt(discriminant) + u1 = math.cbrt(-q2 + sd) + v1 = math.cbrt(q2 + sd) + return self.clamp_range(u1 - v1 - a3) + + def t(self, value: float): + return self.evaluate_cubic( + self.p1, + self.p3, + self.find_first_cubic_root( + -value, + self.p0 - value, + self.p2 - value, + 1.0 - value, + ), + ) + + +class MDAnimationTransition(kivy.animation.AnimationTransition): + """KivyMD's equivalent of kivy's `AnimationTransition`""" + + easing_standard = CubicBezier(0.4, 0.0, 0.2, 1.0).t + easing_decelerated = CubicBezier(0.0, 0.0, 0.2, 1.0).t + easing_accelerated = CubicBezier(0.4, 0.0, 1.0, 1.0).t + easing_linear = CubicBezier(0.0, 0.0, 1.0, 1.0).t + +# TODO: add `easing_emphasized` here +# it's defination is +# path(M 0,0 C 0.05, 0, 0.133333, 0.06, 0.166666, 0.4 C 0.208333, 0.82, 0.25, 1, 1, 1) + +# Monkey patch kivy's animation module +kivy.animation.AnimationTransition = MDAnimationTransition diff --git a/kivymd/uix/transition/transition.py b/kivymd/uix/transition/transition.py index 78e062aaa..f8c9d13e0 100644 --- a/kivymd/uix/transition/transition.py +++ b/kivymd/uix/transition/transition.py @@ -56,6 +56,7 @@ from kivymd.uix.hero import MDHeroFrom, MDHeroTo from kivymd.uix.screenmanager import MDScreenManager +from kivymd.animation import MDAnimationTransition class MDTransitionBase(TransitionBase): @@ -351,6 +352,22 @@ class MDSharedAxisTransition(MDTransitionBase): defaults to 0.15 (= 150ms). """ + switch_animation = OptionProperty( + "easing_decelerated", + options=[ + "easing_standard", + "easing_decelerated", + "easing_accelerated", + "easing_linear", + ], + ) + """ + Custom material design animation transition. + + :attr:`switch_animation` is a :class:`~kivy.properties.OptionProperty` and + defaults to `"easing_decelerated"`. + """ + slide_distance = NumericProperty(dp(15)) """ Distance to which it slides left, right, bottom or up depending on axis. @@ -386,6 +403,10 @@ def start(self, manager): self.ih = hash(self.screen_in) self.oh = hash(self.screen_out) + # Init pos + self.screen_in.pos = manager.pos + self.screen_out.pos = manager.pos + if self.transition_axis == "z": if self.ih not in self._s_map.keys(): # Save scale instructions @@ -418,7 +439,7 @@ def start(self, manager): def on_progress(self, progress): # This code could be simplyfied with setattr, but it's slow - progress = AnimationTransition.out_cubic(progress) + progress = getattr(MDAnimationTransition, self.switch_animation)(progress) progress_i = progress - 1 progress_d = progress * 2 # first half @@ -464,6 +485,8 @@ def on_progress(self, progress): def on_complete(self): self.screen_in.pos = self.manager.pos self.screen_out.pos = self.manager.pos + self.screen_out.opacity = 1 + self.screen_in.opacity = 1 if self.oh in self._s_map.keys(): self._s_map[self.oh].xyz = (1, 1, 1) if self.ih in self._s_map.keys():