Table Of Contents

Source code for kivy.effects.dampedscroll

'''
Damped scroll effect
====================

.. versionadded:: 3.0.0

This scroll effect is inspired by Flutter's BouncingScrollPhysics, which mimics
iOS-style scrolling behavior. Key features:

1. Logarithmic resistance during overscroll drag (rubber band feel)
2. Critically damped spring for bounce-back (no oscillation)
3. Exponential friction for momentum scrolling
4. Mathematically guaranteed stability

IMPLEMENTATION NOTES:
--------------------
This is a clean-room implementation based on understanding the physics and
algorithms used in Flutter and iOS, not a direct port of the code.

Mathematical formulas, physical laws, and algorithms are not subject to
copyright. This implementation uses standard physics equations and
mathematical transformations that are in the public domain.

Differences from the original DampedScrollEffect:
-------------------------------------------------
This implementation is different from the original DampedScrollEffect
that was introduced in Kivy 1.7.0.  The original DampedScrollEffect had a
numerical instability issue. This implementation uses critical damping
to ensure the effect is stable.  Additionally the properties in the
effect are different, and allow for more control over the effect.
'''

__all__ = ('DampedScrollEffect',)

from math import sqrt
from kivy.effects.scroll import ScrollEffect
from kivy.properties import NumericProperty


[docs] class DampedScrollEffect(ScrollEffect): '''Flutter-inspired scroll effect with iOS-style rubber banding. This implementation uses: 1. Logarithmic resistance for overscroll (increasing resistance as you pull) 2. Critically damped spring for bounce-back (no oscillation) 3. Exponential friction for smooth momentum See module documentation for more information. ''' rubber_band_coeff = NumericProperty(0.55) '''Rubber band resistance coefficient. Higher values = less resistance. Flutter/iOS typically use ~0.55. Lower values (0.3-0.4) feel stiffer, higher values (0.6-0.8) feel more elastic. :attr:`rubber_band_coeff` is a :class:`~kivy.properties.NumericProperty` and defaults to 0.55. ''' spring_mass = NumericProperty(1.0) '''Mass for spring simulation (affects bounce-back speed). :attr:`spring_mass` is a :class:`~kivy.properties.NumericProperty` and defaults to 1.0. ''' spring_stiffness = NumericProperty(100.0) '''Stiffness for spring simulation (affects bounce-back speed). :attr:`spring_stiffness` is a :class:`~kivy.properties.NumericProperty` and defaults to 100.0. ''' min_overscroll = NumericProperty(.5) '''An overscroll less than this amount will be normalized to 0. :attr:`min_overscroll` is a :class:`~kivy.properties.NumericProperty` and defaults to .5. ''' def __init__(self, **kwargs): super().__init__(**kwargs) self._spring_time = 0 self._spring_start_position = 0 self._spring_target = 0 self._spring_velocity = 0 def _rubber_band_clamp(self, overscroll, dimension): # Calculate rubber band resistance using logarithmic function. # This is the key to iOS-style feel: resistance increases # logarithmically, as you pull further, creating natural # rubber band behavior. # Formula from WebKit: (1.0 - (1.0 / ((x * coeff / dim) + 1.0))) * dim if abs(overscroll) < 0.001: return 0 sign = 1 if overscroll > 0 else -1 x = abs(overscroll) # Logarithmic resistance formula result = (1.0 - (1.0 / ((x * self.rubber_band_coeff / dimension) + 1.0)) ) * dimension return sign * result def _get_viewport_dimension(self): # Get the viewport dimension for rubber band calculation. # Detects whether this effect is used for X or Y scrolling by checking # which effect instance (effect_x or effect_y) this is in the parent # ScrollView. # Returns the appropriate dimension. target_widget = self.target_widget sv = target_widget.parent if sv.effect_x is self: # This is the X effect - use width for horizontal scrolling return target_widget.width elif sv.effect_y is self: # This is the Y effect - use height for vertical scrolling return self.target_widget.height
[docs] def update_velocity(self, dt): # Update velocity with critically damped spring physics. # If we're not moving and not overscrolled, stop if abs(self.velocity) <= self.min_velocity and abs( self.overscroll) < self.min_overscroll: self.velocity = 0 self.overscroll = 0 return # Check if we're in overscroll and should apply spring forces if abs(self.overscroll) > self.min_overscroll and not self.is_manual: # Use critically damped spring simulation self._apply_spring_force(dt) else: # Normal momentum scrolling with exponential friction if abs(self.overscroll) < self.min_overscroll: self.overscroll = 0 # Standard friction friction_force = self.velocity * self.friction * dt / self.std_dt self.velocity = self.velocity - friction_force if not self.is_manual: self.apply_distance(self.velocity * dt) self.trigger_velocity_update()
def _apply_spring_force(self, dt): # (internal) Apply critically damped spring force for stable bounce-back. # Uses the critical damping formula to ensure no oscillation: # damping = 2 * sqrt(stiffness * mass) # This is what makes the effect stable - critical damping prevents # overshoot. # Calculate critical damping (this is the key to stability!) critical_damping = 2.0 * sqrt(self.spring_stiffness * self.spring_mass) # Spring force (Hooke's law: F = -kx) spring_force = -self.spring_stiffness * self.overscroll # Damping force (F = -cv) damping_force = -critical_damping * self.velocity # Total acceleration (F = ma, so a = F/m) acceleration = (spring_force + damping_force) / self.spring_mass # Update velocity self.velocity += acceleration * dt # Update position distance = self.velocity * dt # Determine target (min or max) if self.overscroll > 0: target = self.max else: target = self.min # Apply distance self.apply_distance(distance) # Check if we've reached the target and should stop if self.overscroll > 0 and self.value <= self.max: self.value = self.max self.velocity = 0 self.overscroll = 0 elif self.overscroll < 0 and self.value >= self.min: self.value = self.min self.velocity = 0 self.overscroll = 0 def apply_distance(self, distance): # Apply distance with rubber band resistance during manual scrolling. if self.is_manual and abs(self.overscroll) > 0: # Apply logarithmic resistance during drag dimension = self._get_viewport_dimension() # Calculate how much resistance to apply # As overscroll increases, resistance increases logarithmically current_factor = abs( self._rubber_band_clamp(self.overscroll, dimension) / (self.overscroll if self.overscroll != 0 else 1)) # Reduce distance based on resistance # The further you pull, the less distance you get per pixel of drag distance *= current_factor * 0.5 # 0.5 adds extra resistance super().apply_distance(distance) def on_value(self, *args): # Update overscroll when value changes. scroll_min = self.min scroll_max = self.max if scroll_min > scroll_max: scroll_min, scroll_max = scroll_max, scroll_min if self.value < scroll_min: self.overscroll = self.value - scroll_min elif self.value > scroll_max: self.overscroll = self.value - scroll_max else: self.overscroll = 0 self.scroll = self.value def on_overscroll(self, *args): # Trigger velocity update when overscroll changes. if not self.is_manual: self.trigger_velocity_update()