From 39fadab2c2e237d158eba5e67979d7e664b0cffc Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Fri, 23 Feb 2024 18:43:05 +0530 Subject: [PATCH] (`uix/scrollview`): Implement material design specifications for `MDScrollView` --- examples/material_scroll.py | 124 +++++++++++++++++++++++++ kivymd/_version.py | 6 +- kivymd/uix/scrollview.py | 175 +++++++++++++++++++++++++++++++++--- 3 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 examples/material_scroll.py diff --git a/examples/material_scroll.py b/examples/material_scroll.py new file mode 100644 index 000000000..85ec92e02 --- /dev/null +++ b/examples/material_scroll.py @@ -0,0 +1,124 @@ +import os +import sys + +from kivy.core.window import Window +from kivy.metrics import dp +from kivy.uix.boxlayout import BoxLayout +from kivy.lang import Builder +from kivy import __version__ as kv__version__ +from kivymd import __version__ +from kivymd.app import MDApp +from materialyoucolor import __version__ as mc__version__ + +from examples.common_app import CommonApp, KV + +MAIN_KV = """ +: + size_hint_y:None + height:dp(50) + text:"" + sub_text:"" + icon:"" + spacing:dp(5) + MDIcon: + icon:root.icon + size_hint:None, 1 + width:self.height + BoxLayout: + orientation:"vertical" + MDLabel: + text:root.text + MDLabel: + adaptive_height:True + text:root.sub_text + font_style:"Body" + role:"medium" + shorten:True + shorten_from:"right" + theme_text_color:"Custom" + text_color:app.theme_cls.onSurfaceVariantColor[:-1] + [0.9] + +MDScreen: + md_bg_color: app.theme_cls.backgroundColor + BoxLayout: + orientation:"vertical" + MDScrollView: + do_scroll_x:False + MDBoxLayout: + spacing:dp(20) + orientation:"vertical" + adaptive_height:True + id:main_scroll + padding:[dp(10), 0] + MDBoxLayout: + adaptive_height:True + MDLabel: + theme_font_size:"Custom" + text:"OS Info" + font_size:"55sp" + adaptive_height:True + padding:[dp(10),dp(20),0,0] + BoxLayout: + orientation:"vertical" + size_hint_x:None + width:dp(70) + padding:[0, dp(20), dp(10),0] + MDIconButton: + on_release: app.open_menu(self) + size_hint: None, None + size:[dp(50)] * 2 + icon: "menu" + pos_hint:{"center_x":0.8, "center_y":0.9} + Widget: +""" + + +class Item(BoxLayout): + pass + + +class Example(MDApp, CommonApp): + def build(self): + self.theme_cls.theme_style = "Dark" + return Builder.load_string(MAIN_KV) + + def on_start(self): + super().on_start() + info = { + "Name": [ + os.name, + ( + "microsoft" + if os.name == "nt" + else ("linux" if os.uname()[0] != "Darwin" else "apple") + ), + ], + "Architecture": [os.uname().machine, "memory"], + "Hostname": [os.uname().nodename, "account"], + "Python Version": ["v" + sys.version, "language-python"], + "Kivy Version": ["v" + kv__version__, "alpha-k-circle-outline"], + "KivyMD Version": ["v" + __version__, "material-design"], + "MaterialYouColor Version": ["v" + mc__version__, "invert-colors"], + "Pillow Version":["Unknown", "image"], + "Working Directory": [os.getcwd(), "folder"], + "Home Directory": [os.path.expanduser("~"), "folder-account"], + "Environment Variables": [os.environ, "code-json"], + } + + try: + from PIL import __version__ as pil__version_ + info["Pillow Version"] = ["v" + pil__version_ ,"image"] + except Exception: + pass + + for info_item in info: + widget = Item() + widget.text = info_item + widget.sub_text = str(info[info_item][0]) + widget.icon = info[info_item][1] + self.root.ids.main_scroll.add_widget(widget) + + Window.size = [dp(350), dp(600)] + + +Example().run() diff --git a/kivymd/_version.py b/kivymd/_version.py index d503c8e5c..5e2b80cd2 100644 --- a/kivymd/_version.py +++ b/kivymd/_version.py @@ -1,5 +1,5 @@ release = False __version__ = "2.0.1.dev0" -__hash__ = "43a2ce216bdf99224356e6db4106253afbe1cecb" -__short_hash__ = "43a2ce2" -__date__ = "2024-01-21" +__hash__ = "f7bde69707ac708a758a02d89f14997ee468d1ee" +__short_hash__ = "f7bde69" +__date__ = "2024-02-27" diff --git a/kivymd/uix/scrollview.py b/kivymd/uix/scrollview.py index cc76d5300..573f7ea58 100644 --- a/kivymd/uix/scrollview.py +++ b/kivymd/uix/scrollview.py @@ -4,8 +4,8 @@ .. versionadded:: 1.0.0 -:class:`~kivy.uix.scrollview.ScrollView` class equivalent. Simplifies working -with some widget properties. For example: +:class:`~kivy.uix.scrollview.ScrollView` class equivalent. It implements Material Design's overscorll effect and +simplifies working with some widget properties. For example: ScrollView ---------- @@ -32,28 +32,156 @@ from __future__ import annotations -__all__ = ("MDScrollView",) +__all__ = ("MDScrollView", "StretchOverScrollStencil") -from kivy.effects.dampedscroll import DampedScrollEffect +import math +from kivy.effects.scroll import ScrollEffect from kivy.uix.scrollview import ScrollView +from kivy.properties import ColorProperty +from kivy.graphics import Scale, PushMatrix, PopMatrix, Color +from kivy.animation import Animation from kivymd.uix.behaviors import DeclarativeBehavior, BackgroundColorBehavior -class MDScrollViewEffect(DampedScrollEffect): +class StretchOverScrollStencil(ScrollEffect): """ - This class is simply based on DampedScrollEffect. + Stretches the view on overscroll and absorbs + velocity at start and end to convert to stretch. + .. note:: This effect only works with :class:`kivymd.uix.scrollview.MDScrollView`. If you need any documentation please look at :class:`~kivy.effects.dampedscrolleffect`. """ - def on_overscroll(self, instance, overscroll: int | float) -> None: - ... + # Android constants + minimum_absorbed_velocity = 0 + maximum_velocity = 10000 + stretch_intensity = 0.016 + exponential_scalar = math.e / (1 / 3) + scroll_friction = 0.015 + # Used in `absorb_impact` but for now + # it's not compatible with kivy so we using + # are approx value. + # fling_friction = 1.01 + approx_normailzer = 2e5 + + # Duration to normalize scale + # when touch up is recieved and view is stretched + duration_normailzer = 10 + + scroll_view = None # scroll view instance + scroll_scale = None # Scale instruction instance + + scale_axis = "y" # axis of effect + last_touch_pos = None # used to calculate distance + + def clamp(self, value, min_val=0, max_val=0): + return min(max(value, min_val), max_val) + + def __init__(self, *arg, **kwargs): + super().__init__(*arg, **kwargs) + self.friction = self.scroll_friction + + def is_top_or_bottom(self): + return getattr(self.scroll_view, "scroll_" + self.scale_axis) in [1, 0] + + _should_absorb = True + + def on_value(self, stencil, scroll_distance): + super().on_value(stencil, scroll_distance) + if self.target_widget: + if not all([self.scroll_view, self.scroll_scale]): + self.scroll_view = self.target_widget.parent + self.scroll_scale = self.scroll_view._internal_scale + + if self.is_top_or_bottom(): + if ( + abs(self.velocity) > self.minimum_absorbed_velocity + and self._should_absorb # only first time when reaches top or bottom + ): + self.absorb_impact() + self._should_absorb = False + else: + self._should_absorb = True + + def get_hw(self): + return "height" if self.scale_axis == "y" else "width" + + def set_scale_origin(self): + # Check if target size is small than scrollview + # if yes don't stretch scroll view + if getattr(self.target_widget, self.get_hw()) < getattr( + self.scroll_view, self.get_hw() + ): + return False + + self.scroll_scale.origin = [ + 0 if self.scroll_view.scroll_x <= 0.5 else self.scroll_view.width, + 0 if self.scroll_view.scroll_y <= 0.5 else self.scroll_view.height, + ] + return True + + def absorb_impact(self): + self.set_scale_origin() + sanitized_velocity = self.clamp( + abs(self.velocity), 1, self.maximum_velocity + ) + # Approx implementation. + new_scale = 1 + min( + (sanitized_velocity / self.approx_normailzer), + 1 / 3, + ) + init_anim = Animation( + **{self.scale_axis: new_scale}, + d=(sanitized_velocity * 4) / 1e6, + ) + init_anim.bind(on_complete=self.reset_scale) + init_anim.start(self.scroll_scale) + + def get_component(self, pos): + return pos[-1 if self.scale_axis == "y" else 1] + + def convert_overscroll(self, touch): + if ( + self.scroll_view + and self.target_widget.collide_point(*touch.pos) + and self.is_top_or_bottom() + and getattr(self.scroll_view, "do_scroll_" + self.scale_axis) + and self.velocity == 0 + and self.set_scale_origin() # sets stretch direction + ): + # Distance travelled by touch divided by size of scrollview + distance = ( + abs( + self.get_component(touch.pos) + - self.get_component(self.last_touch_pos) + ) + / self.scroll_view.height + ) + # constant scale due to distance + linear_intensity = self.stretch_intensity * distance + # Far the touch -> less it stretches + exponential_intensity = self.stretch_intensity * ( + 1 - math.exp(-distance * self.exponential_scalar) + ) + new_scale = 1 + exponential_intensity + linear_intensity + setattr(self.scroll_scale, self.scale_axis, new_scale) + + def reset_scale(self, *arg): + if not self.scroll_scale: + return + _scale = getattr(self.scroll_scale, self.scale_axis) + if _scale > 1: + anim = Animation( + **{self.scale_axis: 1}, + d=0.2, + ) + anim.start(self.scroll_scale) class MDScrollView(DeclarativeBehavior, BackgroundColorBehavior, ScrollView): """ - ScrollView class. + An approximate implementation to Material Design's overscorll effect. For more information, see in the :class:`~kivymd.uix.behaviors.declarative_behavior.DeclarativeBehavior` and @@ -62,6 +190,33 @@ class MDScrollView(DeclarativeBehavior, BackgroundColorBehavior, ScrollView): classes documentation. """ + md_bg_color = ColorProperty([0, 0, 0, 0]) + + _internal_scale = None | Scale + def __init__(self, *args, **kwargs): + self.effect_cls = StretchOverScrollStencil super().__init__(*args, **kwargs) - self.effect_cls = MDScrollViewEffect + with self.canvas.before: + Color(rgba=self.md_bg_color) + PushMatrix() + self._internal_scale = Scale() + with self.canvas.after: + PopMatrix() + self.effect_y.scale_axis = "y" + self.effect_x.scale_axis = "x" + + def on_touch_down(self, touch): + self.effect_x.last_touch_pos = touch.pos + self.effect_y.last_touch_pos = touch.pos + super().on_touch_down(touch) + + def on_touch_move(self, touch): + self.effect_x.convert_overscroll(touch) + self.effect_y.convert_overscroll(touch) + super().on_touch_move(touch) + + def on_touch_up(self, touch): + self.effect_x.reset_scale() + self.effect_y.reset_scale() + super().on_touch_up(touch)