Skip to content

Commit

Permalink
(uix/scrollview): Implement material design specifications for `MDS…
Browse files Browse the repository at this point in the history
…crollView`
  • Loading branch information
T-Dynamos committed Feb 29, 2024
1 parent e178310 commit 5c2bb37
Show file tree
Hide file tree
Showing 3 changed files with 291 additions and 14 deletions.
124 changes: 124 additions & 0 deletions examples/material_scroll.py
Original file line number Diff line number Diff line change
@@ -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 = """
<Item>:
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()
6 changes: 3 additions & 3 deletions kivymd/_version.py
Original file line number Diff line number Diff line change
@@ -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"
175 changes: 164 additions & 11 deletions kivymd/uix/scrollview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -32,28 +32,156 @@

from __future__ import annotations

__all__ = ("MDScrollView",)
__all__ = ("MDScrollView", "StretchOverScrollStencil")

from kivy.effects.dampedscroll import DampedScrollEffect
import math

from kivy.animation import Animation
from kivy.effects.scroll import ScrollEffect
from kivy.graphics import Color, PopMatrix, PushMatrix, Scale
from kivy.uix.scrollview import ScrollView

from kivymd.uix.behaviors import DeclarativeBehavior, BackgroundColorBehavior
from kivymd.uix.behaviors import BackgroundColorBehavior, DeclarativeBehavior


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
Expand All @@ -62,6 +190,31 @@ 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:
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)

0 comments on commit 5c2bb37

Please sign in to comment.