diff --git a/examples/material_scroll.py b/examples/material_scroll.py new file mode 100644 index 000000000..a42eb3f7f --- /dev/null +++ b/examples/material_scroll.py @@ -0,0 +1,118 @@ +import os +import sys +import psutil + +from kivy.core.window import Window +from kivy.metrics import dp +from kivy.uix.boxlayout import BoxLayout +from kivy.lang import Builder +from kivymd import __version__ +from kivymd.app import MDApp + +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: self.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 ("ubuntu" if os.uname()[0] != "Darwin" else "apple") + ), + ], + "Architecture": [os.uname().machine, "memory"], + "Hostname": [os.uname().nodename, "account"], + "Memory": [ + "{:.2f} GB".format(psutil.virtual_memory().total / (1024**3)), + "memory", + ], + "Python Version": ["v" + sys.version, "language-python"], + "KivyMD Version": ["v" + __version__, "material-design"], + "Working Directory": [os.getcwd(), "folder"], + "Home Directory": [os.path.expanduser("~"), "folder-account"], + "Environment Variables": [os.environ, "code-json"], + } + + 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..c174dacd9 100644 --- a/kivymd/uix/scrollview.py +++ b/kivymd/uix/scrollview.py @@ -34,21 +34,137 @@ __all__ = ("MDScrollView",) -from kivy.effects.dampedscroll import DampedScrollEffect +import math +from kivy.effects.scroll import ScrollEffect from kivy.uix.scrollview import ScrollView +from kivy.properties import NumericProperty, ListProperty +from kivy.graphics import Scale, PushMatrix, PopMatrix +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. + Material Design overscorll effect. 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 set_scale_origin(self): + 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, + ] + + 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 + ): + # sets stretch direction + self.set_scale_origin() + # 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, # android default + t="in_out_circ", + ) + anim.start(self.scroll_scale) class MDScrollView(DeclarativeBehavior, BackgroundColorBehavior, ScrollView): @@ -62,6 +178,30 @@ class MDScrollView(DeclarativeBehavior, BackgroundColorBehavior, ScrollView): classes documentation. """ + _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: + 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)