Table Of Contents

Source code for kivy.uix.scrollview

'''
ScrollView
==========

.. versionadded:: 1.0.4

The :class:`ScrollView` widget provides a scrollable/pannable viewport that is
clipped at the scrollview's bounding box.

.. note::
    Use :class:`~kivy.uix.recycleview.RecycleView` for generating large
    numbers of widgets in order to display many data items.


Scrolling Behavior
------------------

The ScrollView accepts only one child and applies a viewport/window to
it according to the :attr:`~ScrollView.scroll_x` and
:attr:`~ScrollView.scroll_y` properties. Touches are analyzed to
determine if the user wants to scroll or control the child in some
other manner: you cannot do both at the same time. To determine if
interaction is a scrolling gesture, these properties are used:

    - :attr:`~ScrollView.scroll_distance`: the minimum distance to travel,
      defaults to 20 pixels.
    - :attr:`~ScrollView.scroll_timeout`: the maximum time period, defaults
      to 55 milliseconds.

If a touch travels :attr:`~ScrollView.scroll_distance` pixels within the
:attr:`~ScrollView.scroll_timeout` period, it is recognized as a scrolling
gesture and translation (scroll/pan) will begin. If the timeout occurs, the
touch down event is dispatched to the child instead (no translation).

The default value for those settings can be changed in the configuration file::

    [widgets]
    scroll_timeout = 250
    scroll_distance = 20

.. versionadded:: 1.1.1

    ScrollView now animates scrolling in Y when a mousewheel is used.


Limiting to the X or Y Axis
---------------------------

By default, the ScrollView allows scrolling along both the X and Y axes. You
can explicitly disable scrolling on an axis by setting the
:attr:`~ScrollView.do_scroll_x` or :attr:`~ScrollView.do_scroll_y` properties
to False.


Managing the Content Size and Position
--------------------------------------

The ScrollView manages the position of its children similarly to a
:class:`~kivy.uix.relativelayout.RelativeLayout` but does not use the
:attr:`~kivy.uix.widget.Widget.size_hint`. You must
carefully specify the :attr:`~kivy.uix.widget.Widget.size` of your content to
get the desired scroll/pan effect.

By default, the :attr:`~kivy.uix.widget.Widget.size_hint` is (1, 1), so the
content size will fit your ScrollView
exactly (you will have nothing to scroll). You must deactivate at least one of
the size_hint instructions (x or y) of the child to enable scrolling.
Setting :attr:`~kivy.uix.widget.Widget.size_hint_min` to not be None will
also enable scrolling for that dimension when the :class:`ScrollView` is
smaller than the minimum size.

To scroll a :class:`~kivy.uix.gridlayout.GridLayout` on it's Y-axis/vertically,
set the child's width  to that of the ScrollView (size_hint_x=1), and set
the size_hint_y property to None::

    from kivy.uix.gridlayout import GridLayout
    from kivy.uix.button import Button
    from kivy.uix.scrollview import ScrollView
    from kivy.core.window import Window
    from kivy.app import runTouchApp

    layout = GridLayout(cols=1, spacing=10, size_hint_y=None)
    # Make sure the height is such that there is something to scroll.
    layout.bind(minimum_height=layout.setter('height'))
    for i in range(100):
        btn = Button(text=str(i), size_hint_y=None, height=40)
        layout.add_widget(btn)
    root = ScrollView(size_hint=(1, None), size=(Window.width, Window.height))
    root.add_widget(layout)

    runTouchApp(root)


Kv Example::

    ScrollView:
        do_scroll_x: False
        do_scroll_y: True

        Label:
            size_hint_y: None
            height: self.texture_size[1]
            text_size: self.width, None
            padding: 10, 10
            text:
                'really some amazing text\\n' * 100

Overscroll Effects
------------------

.. versionadded:: 1.7.0

When scrolling would exceed the bounds of the :class:`ScrollView`, it
uses a :class:`~kivy.effects.scroll.ScrollEffect` to handle the
overscroll. These effects can perform actions like bouncing back,
changing opacity, or simply preventing scrolling beyond the normal
boundaries. Note that complex effects may perform many computations,
which can be slow on weaker hardware.

You can change what effect is being used by setting
:attr:`~ScrollView.effect_cls` to any effect class. Current options
include:

    - :class:`~kivy.effects.scroll.ScrollEffect`: Does not allow
      scrolling beyond the :class:`ScrollView` boundaries.
    - :class:`~kivy.effects.dampedscroll.DampedScrollEffect`: The
      current default. Allows the user to scroll beyond the normal
      boundaries, but has the content spring back once the
      touch/click is released.
    - :class:`~kivy.effects.opacityscroll.OpacityScrollEffect`: Similar
      to the :class:`~kivy.effect.dampedscroll.DampedScrollEffect`, but
      also reduces opacity during overscroll.

You can also create your own scroll effect by subclassing one of these,
then pass it as the :attr:`~ScrollView.effect_cls` in the same way.

Alternatively, you can set :attr:`~ScrollView.effect_x` and/or
:attr:`~ScrollView.effect_y` to an *instance* of the effect you want to
use. This will override the default effect set in
:attr:`~ScrollView.effect_cls`.

All the effects are located in the :mod:`kivy.effects`.

Nested ScrollViews
------------------
An application can have multiple levels of nested ScrollViews.  The
ScrollView class manages the hierarchy of nested ScrollViews.

It is important to set the :attr:`do_scroll_x` and :attr:`do_scroll_y`
properties to the correct values for the nested ScrollViews.
The nested scrolling behavior, described below, is only applied if the
:attr:`do_scroll_x` and :attr:`do_scroll_y` properties are set to the
appropriate values.

To achieve orthogonal scrolling, for example, the outer
ScrollView should have :attr:`do_scroll_x` set to False and the inner
ScrollView should have :attr:`do_scroll_y` set to True, or vice versa.

For Parallel scrolling, both ScrollViews should have the same value for
:attr:`do_scroll_x` and :attr:`do_scroll_y`.  This can allow vertical in
vertical,  horizontal in horizontal, or XY in XY scrolling.

For mixed scrolling, the outer ScrollView should have :attr:`do_scroll_x`
and :attr:`do_scroll_y` set to True, and the inner ScrollView should have
either :attr:`do_scroll_x` or :attr:`do_scroll_y` set to False.


**Orthogonal Scrolling** (outer and inner scroll in different directions):
    - Touch: Each ScrollView scrolls only in its direction
    - Wheel: Innermost ScrollView scrolls if it supports that axis
    - Example: Vertical outer + Horizontal inner

**Parallel Scrolling** (outer and inner scroll in the same direction):
    - Touch: Web-style boundary delegation (see below)
    - Wheel: Innermost ScrollView scrolls, no delegation to outer
    - Scrollbar: Scroll does not affect other ScrollView
    - Example: Vertical outer + Vertical inner

**Mixed Scrolling** (outer scrolls XY, inner one axis, or vice versa):
    - Shared axis: Web-style boundary delegation
    - Exclusive axes: Immediate delegation or inner-only scrolling
    - Wheel: Innermost ScrollView scrolls if it supports the axis
    - Example: XY outer + Horizontal inner


Web-Style Boundary Delegation
----------------------------

For parallel and shared-axis scrolling, ScrollView uses web-style
delegation:

    - If a touch starts at the inner boundary and moves out, it delegates
      scrolling to the outer ScrollView right away.
    - If a touch starts at the inner boundary and moves in, only the inner
      ScrollView scrolls.
    - If a touch starts not at a boundary, only the inner ScrollView
      scrolls, even if the gesture later reaches a boundary. Delegation to
      the outer never occurs mid-gesture in this case.
    - A new touch starting at the boundary is required to delegate to the
      outer.

Disable this behavior by setting :attr:`parallel_delegation` to False.

Wheel Behavior in Nested ScrollViews
------------------------------------

When the mouse scroll wheel or a trackpad is used, ScrollView always
applies web-style delegation:

  - Only the innermost ScrollView under the pointer/cursor will handle
    the wheel event.
  - If that ScrollView cannot scroll further along the wheel axis, the
    event is not propagated to outer ScrollViews. This matches standard
    browser and OS behavior.
  - Outer ScrollViews never respond to wheel events unless the pointer is
    over their scrollbars or unless there are no deeper nested
    ScrollViews.

This prevents "scroll hijacking" and provides an intuitive nested scroll
experience.

**Examples:**
  - In a vertical ScrollView containing a horizontal ScrollView, using the
    wheel over the horizontal ScrollView means only the vertical (outer)
    ScrollView scrolls, if its direction matches the wheel.
  - For two nested vertical ScrollViews, the innermost one under the
    pointer will scroll with the wheel until it cannot scroll further;
    wheel events are not delegated to the parent.

Wheel behavior is always active and is NOT affected by the
:attr:`parallel_delegation` or :attr:`delegate_to_outer` properties.
Those only control touch and touchpad gesture behavior.

.. versionchanged:: 3.0.0

    The ScrollView widget now supports nesting to arbitrary levels
    and configurations.
'''

__all__ = ("ScrollView",)

from functools import partial
from math import isclose
from enum import Enum

from kivy.animation import Animation
from kivy.config import Config
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.graphics import PushMatrix, Translate, PopMatrix, Canvas
from kivy.uix.stencilview import StencilView
from kivy.metrics import dp
from kivy.effects.dampedscroll import DampedScrollEffect
from kivy.properties import (
    NumericProperty,
    BooleanProperty,
    AliasProperty,
    ObjectProperty,
    ListProperty,
    ReferenceListProperty,
    OptionProperty,
    ColorProperty,
)
from kivy.uix.behaviors import FocusBehavior


# =============================================================================
# STATE MACHINE ENUMS
# =============================================================================


class ScrollMode(str, Enum):
    # Touch intent detection state machine.

    # Tracks whether a touch gesture is a tap/click or a scroll gesture
    # based on movement distance and timeout thresholds.

    # State Transition Diagram:
    #
    #                          movement > scroll_distance
    #                    ┌──────────────────────────────────┐
    #                    │                                  │
    #                    │                                  v
    #              ┌─────────┐                        ┌────────┐
    #              │ UNKNOWN │                        │ SCROLL │
    #              └─────────┘                        └────────┘
    #                    │                                 ^
    #                    │ timeout expires                 │
    #                    v                                 │
    #         try _simulate_touch_down()                   │
    #                    │                                 │
    #         ┌──────────┴──────────┐                      │
    #         │                     │                      │
    #    child grabbed         no child grabbed            │
    #         │                     │                      │
    #         v                     └──────────────────────┘
    #   hand off to child         transition to SCROLL mode
    #   (button, etc.)            (label, empty space, etc.)
    #
    # State Descriptions:
    # - UNKNOWN: Initial state, accumulating movement to detect intent
    # - SCROLL: Scroll gesture confirmed - either movement exceeded threshold
    #           OR timeout expired without child widget consuming the touch
    UNKNOWN = "unknown"  # Detecting intent - accumulating movement
    SCROLL = "scroll"  # Confirmed scroll gesture - movement exceeded threshold


class DelegationMode(str, Enum):
    # Web-style boundary delegation state machine.

    # Controls when an inner ScrollView delegates scrolling to its outer
    # ScrollView in parallel nested configurations (both scrolling same axis).

    # State Descriptions:
    # - UNLOCKED: Normal scrolling, no delegation. Inner scrolls freely.
    # - START_AT_BOUNDARY: Touch began at inner's boundary.
    #                      Watching movement direction.
    # - LOCKED: Touch trying to scroll beyond boundary.
    #           Outer takes over, inner blocked.

    # State Transition Diagram:

    # Touch starts NOT at boundary:
    #     ┌──────────┐
    #     │ UNLOCKED │ (stays UNLOCKED entire gesture, inner scrolls freely)
    #     └──────────┘

    # Touch starts AT boundary:
    #     ┌──────────────────┐
    #     │ START_AT_BOUNDARY│─────┐
    #     └──────────────────┘     │
    #             │                │
    #             │                │
    #     move into content  scroll beyond boundary
    #             │                │
    #             v                v
    #     ┌──────────┐         ┌────────┐
    #     │ UNLOCKED │         │ LOCKED │ (stays LOCKED entire gesture)
    #     └──────────┘         └────────┘

    # Key: Once LOCKED, stays LOCKED until touch release (no transitions out)

    # Example (Vertical Parallel):
    # 1. User touches inner ScrollView at bottom (scroll_y = 1.0)
    #    -> START_AT_BOUNDARY
    # 2a. User drags down (into content) -> UNLOCKED (inner scrolls normally)
    # 2b. User drags up (beyond bottom edge) -> LOCKED (outer takes over)
    # 3. If LOCKED: stays LOCKED, outer handles all further movement

    UNLOCKED = "unlocked"  # Normal scrolling, no delegation active
    START_AT_BOUNDARY = "start_at_boundary"  # Touch began at scroll boundary
    LOCKED = "locked"  # At boundary trying to scroll beyond - delegate to outer


# =============================================================================
# HIERARCHY MANAGEMENT CLASS
# =============================================================================


class ScrollViewHierarchy:
    # INTERNAL CLASS - Manages the hierarchy of nested ScrollViews for a
    # touch gesture.
    #
    # Stores ScrollViews from outermost (index 0) to innermost (index n).
    # Each ScrollView can use its index to find its parent and navigate
    # the chain.
    #
    # Design Philosophy:
    # - ScrollViews are stored in a list
    #   (scrollviews[0] = outer, scrollviews[-1] = inner)
    # - Relationship data (classification, axis_config) stored separately
    # - Classifications describe relationships BETWEEN levels,
    #   not properties OF levels
    # - Index arithmetic is encapsulated in methods for safety
    #
    # Example for 3-level hierarchy (outer → middle → inner):
    #     scrollviews = [outer, middle, inner]
    #     _parent_child_data = [
    #         {classification: 'parallel', axis_config: None},  # outer→middle
    #         {classification: 'orthogonal', axis_config: None} # middle→inner
    #     ]

    def __init__(self, outer_sv):
        # Initialize hierarchy with the outermost ScrollView.
        # Args:
        #   outer_sv: The outermost ScrollView in the hierarchy
        self.scrollviews = [outer_sv]  # List from outer to inner
        self._parent_child_data = (
            []
        )  # Relationship data between adjacent levels
        self.touched_index = 0  # Index of ScrollView where touch started

    def add_child(self, child_sv, classification, axis_config=None):
        # Add a child ScrollView to the hierarchy.
        # Args:
        #   child_sv: The child ScrollView to add
        #   classification: 'orthogonal', 'parallel', or 'mixed'
        #   axis_config: Dict with axis configuration (only for mixed)
        self.scrollviews.append(child_sv)
        self._parent_child_data.append(
            {"classification": classification, "axis_config": axis_config}
        )
        self.touched_index = len(self.scrollviews) - 1

    def get_parent(self, current_index):
        # Get parent ScrollView for given index.
        # Args:
        #   current_index: Index of the child ScrollView
        # Returns:
        #   Parent ScrollView, or None if at root (index 0)
        if current_index > 0:
            return self.scrollviews[current_index - 1]
        return None

    def get_relationship(self, child_index):
        # Get relationship data between child at child_index and its parent.
        # Encapsulates the index arithmetic for accessing relationship data.
        # Args:
        #   child_index: Index of the child ScrollView
        # Returns:
        #   Dict with 'classification' and 'axis_config', or None if no parent
        if 0 < child_index < len(self.scrollviews):
            return self._parent_child_data[child_index - 1]
        return None

    def get_classification(self, child_index):
        # Get classification between child and its parent.
        # Args:
        #   child_index: Index of the child ScrollView
        # Returns:
        #   Classification string ('orthogonal', 'parallel', 'mixed'),
        #   or None if child_index is 0 (no parent)
        rel = self.get_relationship(child_index)
        return rel["classification"] if rel else None

    def get_axis_config(self, child_index):
        # Get axis configuration for mixed classification.
        # Args:
        #   child_index: Index of the child ScrollView
        # Returns:
        #   Axis config dict for mixed cases, or None
        rel = self.get_relationship(child_index)
        return rel["axis_config"] if rel else None

    @property
    def depth(self):
        # Total depth of hierarchy (number of levels).
        return len(self.scrollviews)

    @property
    def outer(self):
        # Outermost ScrollView (index 0).
        return self.scrollviews[0]

    @property
    def inner(self):
        # Innermost ScrollView (last in list).
        return self.scrollviews[-1]


# =============================================================================
# CONFIGURATION CONSTANTS
# =============================================================================

# Boundary threshold for web-style delegation
# (normalized scroll position 0.0 to 1.0)
# A ScrollView is considered "at boundary" if scroll position is
# within this threshold of the minimum (0.0) or maximum (1.0) position.
# Used for parallel nested scrolling to determine when to delegate
# to outer ScrollView.
# Note: Set to 15% to account for elastic overscroll bounce-back
_BOUNDARY_THRESHOLD = 0.05  # 5% from edge

# When we are generating documentation, Config doesn't exist
_scroll_timeout = _scroll_distance = 0
if Config:
    _scroll_timeout = Config.getint("widgets", "scroll_timeout")
    _scroll_distance = "{}sp".format(
        Config.getint("widgets", "scroll_distance")
    )


[docs] class ScrollView(StencilView): '''ScrollView class. See module documentation for more information. :Events: `on_scroll_start` Dispatch when scrolling is detected. `on_scroll_move` Dispatched when scrolling continues. Fires continuously during scrolling. `on_scroll_stop` Dispatched when scrolling stops. Fires when velocity reaches zero and scroll position stabilizes for 3 consecutive frames. .. versionchanged:: 3.0.0 `on_scroll_start`, `on_scroll_move` and `on_scroll_stop` events are now dispatched for nested and non-nested ScrollViews. The behavior has been updated. Previously these events mirrored the behavior of the on_touch_* methods. Now they fire when scrolling starts, continues and stops. .. versionchanged:: 1.9.0 `on_scroll_start`, `on_scroll_move` and `on_scroll_stop` events are now dispatched when scrolling to handle nested ScrollViews. .. versionchanged:: 1.7.0 `auto_scroll`, `scroll_friction`, `scroll_moves`, `scroll_stoptime' has been deprecated, use :attr:`effect_cls` instead. ''' scroll_distance = NumericProperty(_scroll_distance) '''Distance to move before scrolling the :class:`ScrollView`, in pixels. As soon as the distance has been traveled, the :class:`ScrollView` will start to scroll, and no touch event will go to children. It is advisable that you base this value on the dpi of your target device's screen. :attr:`scroll_distance` is a :class:`~kivy.properties.NumericProperty` and defaults to 20 (pixels), according to the default value in user configuration. ''' scroll_wheel_distance = NumericProperty("20sp") '''Distance to move when scrolling with a mouse wheel. It is advisable that you base this value on the dpi of your target device's screen. .. versionadded:: 1.8.0 :attr:`scroll_wheel_distance` is a :class:`~kivy.properties.NumericProperty` , defaults to 20 pixels. ''' scroll_timeout = NumericProperty(_scroll_timeout) '''Timeout allowed to trigger the :attr:`scroll_distance`, in milliseconds. If the user has not moved :attr:`scroll_distance` within the timeout, the scrolling will be disabled, and the touch event will go to the children. :attr:`scroll_timeout` is a :class:`~kivy.properties.NumericProperty` and defaults to 55 (milliseconds) according to the default value in user configuration. .. versionchanged:: 1.5.0 Default value changed from 250 to 55. ''' scroll_x = NumericProperty(0.0) '''X scrolling value, between 0 and 1. If 0, the content's left side will touch the left side of the ScrollView. If 1, the content's right side will touch the right side. This property is controlled by :class:`ScrollView` only if :attr:`do_scroll_x` is True. :attr:`scroll_x` is a :class:`~kivy.properties.NumericProperty` and defaults to 0. ''' scroll_y = NumericProperty(1.0) '''Y scrolling value, between 0 and 1. If 0, the content's bottom side will touch the bottom side of the ScrollView. If 1, the content's top side will touch the top side. This property is controlled by :class:`ScrollView` only if :attr:`do_scroll_y` is True. :attr:`scroll_y` is a :class:`~kivy.properties.NumericProperty` and defaults to 1. ''' do_scroll_x = BooleanProperty(True) '''Allow scroll on X axis. :attr:`do_scroll_x` is a :class:`~kivy.properties.BooleanProperty` and defaults to True. ''' do_scroll_y = BooleanProperty(True) '''Allow scroll on Y axis. :attr:`do_scroll_y` is a :class:`~kivy.properties.BooleanProperty` and defaults to True. ''' def _get_do_scroll(self): return (self.do_scroll_x, self.do_scroll_y) def _set_do_scroll(self, value): if isinstance(value, (list, tuple)): self.do_scroll_x, self.do_scroll_y = value else: self.do_scroll_x = self.do_scroll_y = bool(value) do_scroll = AliasProperty( _get_do_scroll, _set_do_scroll, bind=("do_scroll_x", "do_scroll_y"), cache=True, ) '''Allow scroll on X or Y axis. :attr:`do_scroll` is a :class:`~kivy.properties.AliasProperty` of (:attr:`do_scroll_x` + :attr:`do_scroll_y`) ''' always_overscroll = BooleanProperty(True) '''Make sure user can overscroll even if there is not enough content to require scrolling. This is useful if you want to trigger some action on overscroll, but there is not always enough content to trigger it. :attr:`always_overscroll` is a :class:`~kivy.properties.BooleanProperty` and defaults to `True`. .. versionadded:: 2.0.0 The option was added and enabled by default, set to False to get the previous behavior of only allowing to overscroll when there is enough content to allow scrolling. ''' def _get_vbar(self): # must return (y, height) in % # calculate the viewport size / scrollview size % if self._viewport is None: return 0, 1.0 vh = self._viewport.height h = self.height if vh < h or vh == 0: return 0, 1.0 ph = max(0.01, h / float(vh)) sy = min(1.0, max(0.0, self.scroll_y)) py = (1.0 - ph) * sy return (py, ph) vbar = AliasProperty( _get_vbar, bind=("scroll_y", "_viewport", "viewport_size", "height"), cache=True, ) '''Return a tuple of (position, size) of the vertical scrolling bar. .. versionadded:: 1.2.0 The position and size are normalized between 0-1, and represent a proportion of the current scrollview height. This property is used internally for drawing the little vertical bar when you're scrolling. :attr:`vbar` is a :class:`~kivy.properties.AliasProperty`, readonly. ''' def _get_hbar(self): # must return (x, width) in % # calculate the viewport size / scrollview size % if self._viewport is None: return 0, 1.0 vw = self._viewport.width w = self.width if vw < w or vw == 0: return 0, 1.0 pw = max(0.01, w / float(vw)) sx = min(1.0, max(0.0, self.scroll_x)) px = (1.0 - pw) * sx return (px, pw) hbar = AliasProperty( _get_hbar, bind=("scroll_x", "_viewport", "viewport_size", "width"), cache=True, ) '''Return a tuple of (position, size) of the horizontal scrolling bar. .. versionadded:: 1.2.0 The position and size are normalized between 0-1, and represent a proportion of the current scrollview height. This property is used internally for drawing the little horizontal bar when you're scrolling. :attr:`hbar` is a :class:`~kivy.properties.AliasProperty`, readonly. ''' bar_color = ColorProperty([0.7, 0.7, 0.7, 0.9]) '''Color of horizontal / vertical scroll bar, in RGBA format. .. versionadded:: 1.2.0 :attr:`bar_color` is a :class:`~kivy.properties.ColorProperty` and defaults to [.7, .7, .7, .9]. .. versionchanged:: 2.0.0 Changed from :class:`~kivy.properties.ListProperty` to :class:`~kivy.properties.ColorProperty`. ''' bar_inactive_color = ColorProperty([0.7, 0.7, 0.7, 0.2]) '''Color of horizontal / vertical scroll bar (in RGBA format), when no scroll is happening. .. versionadded:: 1.9.0 :attr:`bar_inactive_color` is a :class:`~kivy.properties.ColorProperty` and defaults to [.7, .7, .7, .2]. .. versionchanged:: 2.0.0 Changed from :class:`~kivy.properties.ListProperty` to :class:`~kivy.properties.ColorProperty`. ''' bar_width = NumericProperty("2dp") '''Width of the horizontal / vertical scroll bar. The width is interpreted as a height for the horizontal bar. .. versionadded:: 1.2.0 :attr:`bar_width` is a :class:`~kivy.properties.NumericProperty` and defaults to 2. ''' bar_pos_x = OptionProperty("bottom", options=("top", "bottom")) '''Which side of the ScrollView the horizontal scroll bar should go on. Possible values are 'top' and 'bottom'. .. versionadded:: 1.8.0 :attr:`bar_pos_x` is an :class:`~kivy.properties.OptionProperty`, defaults to 'bottom'. ''' bar_pos_y = OptionProperty("right", options=("left", "right")) '''Which side of the ScrollView the vertical scroll bar should go on. Possible values are 'left' and 'right'. .. versionadded:: 1.8.0 :attr:`bar_pos_y` is an :class:`~kivy.properties.OptionProperty` and defaults to 'right'. ''' bar_pos = ReferenceListProperty(bar_pos_x, bar_pos_y) '''Which side of the scroll view to place each of the bars on. :attr:`bar_pos` is a :class:`~kivy.properties.ReferenceListProperty` of (:attr:`bar_pos_x`, :attr:`bar_pos_y`) ''' bar_margin = NumericProperty(0) '''Margin between the bottom / right side of the scrollview when drawing the horizontal / vertical scroll bar. .. versionadded:: 1.2.0 :attr:`bar_margin` is a :class:`~kivy.properties.NumericProperty`, default to 0 ''' effect_cls = ObjectProperty(DampedScrollEffect, allownone=True) '''Class effect to instantiate for X and Y axis. .. versionadded:: 1.7.0 :attr:`effect_cls` is an :class:`~kivy.properties.ObjectProperty` and defaults to :class:`DampedScrollEffect`. .. versionchanged:: 1.8.0 If you set a string, the :class:`~kivy.factory.Factory` will be used to resolve the class. ''' effect_x = ObjectProperty(None, allownone=True) '''Effect to apply for the X axis. If None is set, an instance of :attr:`effect_cls` will be created. .. versionadded:: 1.7.0 :attr:`effect_x` is an :class:`~kivy.properties.ObjectProperty` and defaults to None. ''' effect_y = ObjectProperty(None, allownone=True) '''Effect to apply for the Y axis. If None is set, an instance of :attr:`effect_cls` will be created. .. versionadded:: 1.7.0 :attr:`effect_y` is an :class:`~kivy.properties.ObjectProperty` and defaults to None, read-only. ''' viewport_size = ListProperty([0, 0]) '''(internal) Size of the internal viewport. This is the size of your only child in the scrollview. ''' scroll_type = OptionProperty( ["content"], options=( ["content"], ["bars"], ["bars", "content"], ["content", "bars"], ), ) '''Sets the type of scrolling to use for the content of the scrollview. Available options are: ['content'], ['bars'], ['bars', 'content']. +---------------------+------------------------------------------------+ | ['content'] | Content is scrolled by dragging or swiping the | | | content directly. | +---------------------+------------------------------------------------+ | ['bars'] | Content is scrolled by dragging or swiping the | | | scroll bars. | +---------------------+------------------------------------------------+ | ['bars', 'content'] | Content is scrolled by either of the above | | | methods. | +---------------------+------------------------------------------------+ .. versionadded:: 1.8.0 :attr:`scroll_type` is an :class:`~kivy.properties.OptionProperty` and defaults to ['content']. ''' smooth_scroll_end = NumericProperty(None, allownone=True) '''Whether smooth scroll end should be used when scrolling with the mouse-wheel and the factor of transforming the scroll distance to velocity. This option also enables velocity addition meaning if you scroll more, you will scroll faster and further. The recommended value is `10`. The velocity is calculated as :attr:`scroll_wheel_distance` * :attr:`smooth_scroll_end`. .. versionadded:: 1.11.0 :attr:`smooth_scroll_end` is a :class:`~kivy.properties.NumericProperty` and defaults to None. ''' slow_device_support = BooleanProperty(False) '''Enable slow device support for scroll gesture detection. On very slow devices, at least 3 frames are needed to accumulate velocity data for scroll effects to work properly. When enabled, after the scroll timeout expires, the gesture handoff will be delayed until at least 3 frames have rendered, ensuring sufficient velocity data accumulation. This addresses issues #1464 and #1499 for low-performance devices. Disable this on modern hardware to improve touch responsiveness. :attr:`slow_device_support` is a :class:`~kivy.properties.BooleanProperty` and defaults to False. .. versionadded:: 3.0.0 ''' parallel_delegation = BooleanProperty(True) '''Controls boundary delegation behavior for parallel nested ScrollViews. When True (default, web-style): - Touch starting at inner boundary, moving away → delegates to outer - Touch starting not at boundary → scrolls inner only, never delegates When False: - No delegation, only touched ScrollView scrolls :attr:`parallel_delegation` is a :class:`~kivy.properties.BooleanProperty` and defaults to True. .. versionadded:: 3.0.0 ''' delegate_to_outer = BooleanProperty(True) '''Controls whether touch scroll gestures delegate to outer ScrollViews. When True (default): - Orthogonal: Cross-axis gestures immediately delegate to outer (e.g., horizontal swipe in vertical-only ScrollView) - Parallel: At boundaries, delegates to outer (respects parallel_delegation) - Arbitrary depth: Continues searching up hierarchy for capable ScrollView When False: - No delegation to outer ScrollViews - Only the directly touched ScrollView handles the gesture NOTE: Mouse wheel events are NOT affected by this property. Wheel scrolling always uses web-style behavior: scroll the innermost ScrollView under cursor that can handle the direction. Example use cases: - Set False to lock touch scrolling to a specific nested level - Set False to prevent inner touch scroll from affecting outer scroll :attr:`delegate_to_outer` is a :class:`~kivy.properties.BooleanProperty` and defaults to True. .. versionadded:: 3.0.0 ''' # Class constants for mouse wheel scroll button sets _MOUSE_WHEEL_HORIZONTAL = {"scrollleft", "scrollright"} _MOUSE_WHEEL_VERTICAL = {"scrolldown", "scrollup"} _MOUSE_WHEEL_DECREASE = {"scrolldown", "scrollleft"} # negative direction _MOUSE_WHEEL_INCREASE = {"scrollup", "scrollright"} # positive direction _viewport = ObjectProperty(None, allownone=True) _bar_color = ListProperty([0, 0, 0, 0]) def _set_viewport_size(self, instance, value): self.viewport_size = value def on__viewport(self, instance, value): if value: value.bind(size=self._set_viewport_size) self.viewport_size = value.size def __init__(self, **kwargs): self._touch = None self._nested_sv_active_touch = ( None # Stores the touch that's currently active in nested scenario ) self._trigger_update_from_scroll = Clock.create_trigger( self.update_from_scroll, -1 ) # For velocity-based stop detection for on_scroll_stop self._velocity_check_ev = None self._position_check_ev = None self._last_scroll_pos = None self._stable_frames = 0 # For scroll effect tracking self._effect_x_start_width = None self._effect_y_start_height = None self._update_effect_bounds_ev = None self._bind_inactive_bar_color_ev = None # create a specific canvas for the viewport self.canvas_viewport = Canvas() self.canvas = Canvas() with self.canvas_viewport.before: PushMatrix() self.g_translate = Translate(0, 0) with self.canvas_viewport.after: PopMatrix() super(ScrollView, self).__init__(**kwargs) self.register_event_type("on_scroll_start") self.register_event_type("on_scroll_move") self.register_event_type("on_scroll_stop") # Bind scroll position changes to dispatch on_scroll_move self.fbind("scroll_x", self._on_scroll_pos_changed) self.fbind("scroll_y", self._on_scroll_pos_changed) # now add the viewport canvas to our canvas self.canvas.add(self.canvas_viewport) effect_cls = self.effect_cls if isinstance(effect_cls, str): effect_cls = Factory.get(effect_cls) if self.effect_x is None and effect_cls is not None: self.effect_x = effect_cls(target_widget=self._viewport) if self.effect_y is None and effect_cls is not None: self.effect_y = effect_cls(target_widget=self._viewport) trigger_update_from_scroll = self._trigger_update_from_scroll update_effect_widget = self._update_effect_widget update_effect_x_bounds = self._update_effect_x_bounds update_effect_y_bounds = self._update_effect_y_bounds fbind = self.fbind fbind("width", update_effect_x_bounds) fbind("height", update_effect_y_bounds) fbind("viewport_size", self._update_effect_bounds) fbind("_viewport", update_effect_widget) fbind("scroll_x", trigger_update_from_scroll) fbind("scroll_y", trigger_update_from_scroll) fbind("pos", trigger_update_from_scroll) fbind("size", trigger_update_from_scroll) trigger_update_from_scroll() update_effect_widget() update_effect_x_bounds() update_effect_y_bounds() def on_effect_x(self, instance, value): if value: value.bind(scroll=self._update_effect_x) value.target_widget = self._viewport def on_effect_y(self, instance, value): if value: value.bind(scroll=self._update_effect_y) value.target_widget = self._viewport def on_effect_cls(self, instance, cls): if isinstance(cls, str): cls = Factory.get(cls) self.effect_x = cls(target_widget=self._viewport) self.effect_x.bind(scroll=self._update_effect_x) self.effect_y = cls(target_widget=self._viewport) self.effect_y.bind(scroll=self._update_effect_y) def _update_effect_widget(self, *args): if self.effect_x: self.effect_x.target_widget = self._viewport if self.effect_y: self.effect_y.target_widget = self._viewport def _update_effect_x_bounds(self, *args): if not self._viewport or not self.effect_x: return scrollable_width = self.width - self.viewport_size[0] self.effect_x.min = 0 self.effect_x.max = min(0, scrollable_width) self.effect_x.value = scrollable_width * self.scroll_x def _update_effect_y_bounds(self, *args): if not self._viewport or not self.effect_y: return scrollable_height = self.height - self.viewport_size[1] self.effect_y.min = 0 if scrollable_height < 0 else scrollable_height self.effect_y.max = scrollable_height self.effect_y.value = self.effect_y.max * self.scroll_y def _update_effect_bounds(self, *args): # "sync up the physics with reality" method # keeps the smooth scrolling effects aligned with the actual SV state self._update_effect_x_bounds() self._update_effect_y_bounds() def _update_effect_x(self, *args): vp = self._viewport if not vp or not self.effect_x: return if self.effect_x.is_manual: sw = vp.width - self._effect_x_start_width else: sw = vp.width - self.width if sw < 1 and not (self.always_overscroll and self.do_scroll_x): return if sw != 0: sx = self.effect_x.scroll / sw self.scroll_x = -sx self._trigger_update_from_scroll() def _update_effect_y(self, *args): vp = self._viewport if not vp or not self.effect_y: return if self.effect_y.is_manual: sh = vp.height - self._effect_y_start_height else: sh = vp.height - self.height if sh < 1 and not (self.always_overscroll and self.do_scroll_y): return if sh != 0: sy = self.effect_y.scroll / sh self.scroll_y = -sy self._trigger_update_from_scroll()
[docs] def to_local(self, x, y, **k): tx, ty = self.g_translate.xy return x - tx, y - ty
[docs] def to_parent(self, x, y, **k): tx, ty = self.g_translate.xy return x + tx, y + ty
def _apply_transform(self, m, pos=None): tx, ty = self.g_translate.xy m.translate(tx, ty, 0) return super(ScrollView, self)._apply_transform(m, (0, 0)) def _simulate_touch_down(self, touch): # CONTROLLED TOUCH RE-DISPATCH TO CHILD WIDGETS # ============================================== # This method safely re-dispatches touch events to child widgets, # typically used when a touch gesture is determined to be a # click rather than a scroll. # # Returns: # bool: True if a child widget handled/consumed the touch, # False otherwise. # This indicates whether the touch was accepted by # a child widget # (e.g., a button, text input, etc.). touch.push() touch.apply_transform_2d(self.to_local) # Dispatch touch to children ret = super(ScrollView, self).on_touch_down(touch) touch.pop() return ret
[docs] def on_motion(self, etype, me): if me.type_id in self.motion_filter and "pos" in me.profile: me.push() me.apply_transform_2d(self.to_local) ret = super().on_motion(etype, me) me.pop() return ret return super().on_motion(etype, me)
def _delegate_to_children(self, touch, method_name, check_collision=True): # Generic helper function to safely delegate touch events to child # widgets. # Handles coordinate transformation and returns the result. # Args: # touch: The touch event to delegate # method_name: The method name to call (e.g., 'on_touch_move') # check_collision: Whether to check collision before delegating # Returns: True if any child handled the touch, False otherwise if check_collision and not self.collide_point(*touch.pos): return False touch.push() touch.apply_transform_2d(self.to_local) res = getattr(super(ScrollView, self), method_name)(touch) touch.pop() return res def _delegate_touch_up_to_children_widget_coords(self, touch): # Delegate touch_up to children using widget coordinates # (no collision check). touch.push() touch.apply_transform_2d(self.to_widget) res = super(ScrollView, self).on_touch_up(touch) touch.pop() return res def _find_child_scrollview_at_touch(self, touch): # Find the child ScrollView that collides with the touch position. # # Strategy: When we encounter a Layout, # we first identify which specific child collides with the touch, # then only explore that child's subtree. This naturally handles # arbitrary depth and prevents finding ScrollViews from unrelated # sibling branches. # # Returns: # ScrollView: The first child ScrollView that collides with touch, # or None viewport = self._viewport # Safety checks: viewport must exist and have children if not viewport: return None if not hasattr(viewport, "children"): return None if not viewport.children: return None # Transform touch to viewport space touch.push() touch.apply_transform_2d(viewport.to_widget) result = self._find_scrollview_in_widget(viewport, touch) touch.pop() return result def _find_scrollview_in_widget(self, widget, touch): # Recursively find ScrollView under touch, narrowing by Layout children. # # When we encounter a widget with children (a Layout), we first # identify which specific child collides with the touch point, then # only explore that child's subtree. This prevents finding ScrollViews # from unrelated sibling branches. # # Args: # widget: The widget to search within # touch: The touch event (already transformed to appropriate space) # # Returns: # ScrollView or None: The first ScrollView found under touch # If this widget has children, # narrow down which child contains the touch if hasattr(widget, "children") and widget.children: for child in widget.children: # Skip children that don't collide with touch if not child.collide_point(*touch.pos): continue # Found a colliding child - is it a ScrollView? if isinstance(child, ScrollView): return child # Not a ScrollView, but it collides - # recursively search its subtree result = self._find_scrollview_in_widget(child, touch) if result: return result # No children or no colliding children with ScrollViews return None def _build_hierarchy_recursive(self, touch): # Build complete ScrollView hierarchy from this (outer) to innermost. # # Recursively finds all nested ScrollViews under the touch point and # builds a ScrollViewHierarchy object with classifications for each # parent-child relationship. # # Args: # touch: The touch event # # Returns: # ScrollViewHierarchy or None: Complete hierarchy from this (outer) # to innermost, or None if no children # Find immediate child child_sv = self._find_child_scrollview_at_touch(touch) if not child_sv: return None # Start building hierarchy with self as outer hierarchy = ScrollViewHierarchy(self) # Recursively build the chain current_sv = child_sv parent_sv = self while current_sv: # Classify this parent-child relationship config_type, axis_config = ( parent_sv._classify_nested_configuration(current_sv)) # Add to hierarchy hierarchy.add_child(current_sv, config_type, axis_config) # Look for next level down parent_sv = current_sv current_sv = current_sv._find_child_scrollview_at_touch(touch) return hierarchy def _get_nested_data(self, touch): # Extract nested coordination data for this ScrollView from touch. # # Provides convenient access to hierarchy, current index, and parent # for arbitrary depth nesting. This method makes it easy for any # ScrollView in the hierarchy to find its place and navigate. # # NOTE: This method is for FUTURE arbitrary nesting support (Phase 3+). # Currently NOT USED by 2-level delegation logic. Will be integrated # when chain delegation is implemented. # # Args: # touch: The touch event # # Returns: # tuple: (hierarchy, my_index, parent_sv) where: # - hierarchy: ScrollViewHierarchy object # - my_index: Index of this ScrollView in the hierarchy # - parent_sv: Parent ScrollView, or None if this is outermost # # Returns (None, None, None) if not in a nested configuration # or if this ScrollView is not found in the hierarchy. if "nested" not in touch.ud: return None, None, None if "hierarchy" not in touch.ud["nested"]: return None, None, None hierarchy = touch.ud["nested"]["hierarchy"] # Find our index in the hierarchy my_index = None for i, sv in enumerate(hierarchy.scrollviews): if sv is self: my_index = i break if my_index is None: return None, None, None parent_sv = hierarchy.get_parent(my_index) return hierarchy, my_index, parent_sv def _get_primary_scroll_axis(self, touch): # Determine which axis has dominant touch movement. # # Compares absolute X and Y deltas to determine the primary direction # of the current touch movement. Used for axis-specific scroll logic. # # Args: # touch: Touch event with dx and dy attributes # # Returns: # str: 'x' if horizontal movement dominates, # 'y' if vertical movement dominates, # None if no clear dominance abs_dx = abs(touch.dx) abs_dy = abs(touch.dy) if abs_dx > abs_dy: return "x" elif abs_dy > abs_dx: return "y" return None def _classify_nested_configuration(self, child_sv): # Classify the nested ScrollView configuration type. # # Determines if the outer/inner relationship is: # - Orthogonal: Different axes (e.g., outer=vertical, inner=horizontal) # - Parallel: Same axes (e.g., both vertical) # - Mixed: Combination (e.g., outer=xy, inner=vertical) # # Args: # child_sv: The inner ScrollView # # Returns: # tuple: (config_type, axis_config) # config_type: 'orthogonal', 'parallel', or 'mixed' # axis_config: dict with 'shared', 'outer_exclusive', # 'inner_exclusive' # (only for mixed configurations, None otherwise) outer_axes = (self.do_scroll_x, self.do_scroll_y) inner_axes = (child_sv.do_scroll_x, child_sv.do_scroll_y) # Determine configuration type is_orthogonal = ( outer_axes[0] != inner_axes[0] and outer_axes[1] != inner_axes[1] and (outer_axes[0] or outer_axes[1]) and (inner_axes[0] or inner_axes[1]) ) if is_orthogonal: return ("orthogonal", None) is_parallel = outer_axes == inner_axes if is_parallel: return ("parallel", None) # Mixed configuration - determine axis capabilities shared = [] outer_exclusive = [] inner_exclusive = [] # Check X axis if outer_axes[0] and inner_axes[0]: shared.append("x") elif outer_axes[0] and not inner_axes[0]: outer_exclusive.append("x") elif not outer_axes[0] and inner_axes[0]: inner_exclusive.append("x") # Check Y axis if outer_axes[1] and inner_axes[1]: shared.append("y") elif outer_axes[1] and not inner_axes[1]: outer_exclusive.append("y") elif not outer_axes[1] and inner_axes[1]: inner_exclusive.append("y") axis_config = { "shared": shared, "outer_exclusive": outer_exclusive, "inner_exclusive": inner_exclusive, } return ("mixed", axis_config) def _initialize_nested_inner(self, touch, child_sv): # Initialize scrolling on the inner ScrollView with proper coordinate # transformation. # # # Args: # touch: The touch/mouse event # child_sv: The inner ScrollView to initialize # # Returns: # bool: True if scrolling was successfully initialized, # False if both rejected the touch is_wheel = "button" in touch.profile and touch.button.startswith( "scroll" ) # Transform touch to inner's PARENT coordinate space # (NOT viewport, NOT window - the parent widget that contains the inner) touch.push() touch.apply_transform_2d(child_sv.parent.to_widget) result = child_sv._scroll_initialize(touch) touch.pop() if result: # Inner accepted scrolling (or delegated to child widget) # Check if inner actually set up scroll state # (not just delegated to button) inner_uid = child_sv._get_uid() if inner_uid in touch.ud: # Inner actually set up scroll state - this is real scrolling # For MOUSE WHEEL: Don't grab or set _touch # (each wheel event is independent) # For REGULAR TOUCH: Outer grabs, inner's _touch is set for # coordination if not is_wheel: # Regular touch: outer grabs, inner tracks the touch touch.grab(self) child_sv._touch = touch self._nested_sv_active_touch = ( touch # Store the active touch for nested scenario ) # Wheel events are handled immediately, # no grab/touch tracking needed # Inner set up scroll state - claim the touch return True else: # Inner delegated to a child widget (button, etc) - # DON'T claim the touch... # Let it propagate so the button can grab and handle it return False # Inner rejected the touch # For MOUSE WHEEL in orthogonal setups, try outer ScrollView if is_wheel: # Try outer with ORIGINAL touch (already popped above) if self._scroll_initialize(touch): return True return False # ========================================================================= # TOUCH HANDLER HELPER METHODS # ========================================================================= # The following helper methods support the main touch handling lifecycle. # They are organized by purpose: detection, delegation, intent, # finalization. def _setup_boundary_delegation(self, touch, in_bar): # Configure web-style boundary delegation for parallel nested scrolling. # # In parallel nested setups (both scrolling same direction), detects if # touch starts at this ScrollView's scroll boundary. If so, marks for # immediate delegation to parent on first movement away from boundary. # # For arbitrary depth: Uses hierarchy to find immediate parent, # not always outer. # # Only applies when: # - Touch is in a nested configuration # - Not in a scrollbar (delegation disabled for bars) # - parent.parallel_delegation is True # # Args: # touch: The touch event with touch.ud['nested'] already set # in_bar: Whether touch is in a scrollbar if "nested" not in touch.ud or in_bar: return # For arbitrary depth: find our immediate parent from hierarchy hierarchy, my_index, parent_sv = self._get_nested_data(touch) if not parent_sv: # We're the outer or not in hierarchy - no delegation needed return # Only proceed if parent has parallel_delegation enabled if not parent_sv.parallel_delegation: return # PARALLEL DELEGATION: Only check boundaries in directions where BOTH # child and parent scroll. # In orthogonal setups (child scrolls Y, parent scrolls X), # the child should freely overscroll in Y direction without delegation. # Check X boundary only if both child and parent scroll horizontally at_boundary_x = ( self.do_scroll_x and parent_sv.do_scroll_x and self._is_at_scroll_boundary("x") ) # Check Y boundary only if both child and parent scroll vertically at_boundary_y = ( self.do_scroll_y and parent_sv.do_scroll_y and self._is_at_scroll_boundary("y") ) # Set delegation_mode based on boundary state in PARALLEL only # For arbitrary depth: Store per-ScrollView delegation mode using UID if at_boundary_x or at_boundary_y: # Store per-ScrollView delegation mode sv_uid = self._get_uid() if "delegation_modes" not in touch.ud["nested"]: touch.ud["nested"]["delegation_modes"] = {} touch.ud["nested"]["delegation_modes"][ sv_uid ] = DelegationMode.START_AT_BOUNDARY # Also set global mode for backward compatibility with 2-level code touch.ud["nested"][ "delegation_mode" ] = DelegationMode.START_AT_BOUNDARY def _delegate_to_parent_scroll(self, touch, child_sv, parent_sv): # Delegate scrolling from child to parent ScrollView in hierarchy chain. # # Called when a child ScrollView rejects touch movement # (e.g., orthogonal gesture, boundary reached, or delegation decision). # Initializes parent's scroll state and takes over scroll handling. # # This supports arbitrary depth by delegating to immediate parent, which # can then delegate to ITS parent if needed. # # Args: # touch: The touch event # child_sv: The child ScrollView that is relinquishing control # parent_sv: The parent ScrollView that will take over # # Returns: # bool: Result from parent's _scroll_update # Initialize parent's scroll state if not already set up parent_uid = parent_sv._get_uid() if parent_uid not in touch.ud: touch.ud[parent_uid] = { "mode": ScrollMode.SCROLL, # Already scrolling (not UNKNOWN) "dx": 0, "dy": 0, "scroll_action": False, "frames": 0, "can_defocus": False, } parent_sv._initialize_scroll_effects(touch, in_bar=False) parent_sv._touch = touch parent_sv.dispatch("on_scroll_start") if child_sv: # CRITICAL: Finalize child's scroll when cascading # This stops effects AND schedules on_scroll_stop event # But doesn't interfere with normal on_touch_up to # buttons/widgets child_sv._finalize_scroll_for_cascade(touch) # Now process the touch movement with parent touch.ud["sv.handled"] = {"x": False, "y": False} return parent_sv._scroll_update(touch) def _detect_scroll_intent(self, touch, ud): # Detect if touch movement indicates scroll intent vs click. # # Tracks cumulative movement while in 'unknown' mode. Once movement # exceeds scroll_distance threshold, transitions to 'scroll' mode. # # Args: # touch: The touch event # ud: The touch's user data dict for this ScrollView # # Returns: # bool: True if processing should continue, False if rejected if not (self.do_scroll_x or self.do_scroll_y): # No scrolling enabled - trigger mode change to delegate to children # Touch is in parent coords, but _change_touch_mode expects # window coords touch.push() touch.apply_transform_2d(self.to_local) touch.apply_transform_2d(self.to_window) self._change_touch_mode() touch.pop() return False # Accumulate absolute movement on both axes ud["dx"] += abs(touch.dx) ud["dy"] += abs(touch.dy) # Transition to scroll mode if movement exceeds threshold if ud["dx"] > self.scroll_distance or ud["dy"] > self.scroll_distance: ud["mode"] = ScrollMode.SCROLL # Only dispatch on_scroll_start if we weren't already scrolling # (e.g. from scrollbar) if not ud["scroll_action"]: self.dispatch("on_scroll_start") return True def _check_nested_delegation(self, touch, not_in_bar): # Check if this ScrollView should delegate to its parent in hierarchy. # # Uses the hierarchy to find this ScrollView's relationship with # its parent and calls the appropriate delegation method. # Supports arbitrary depth. # # Only applies to ScrollViews scrolling content (not scrollbars). # # Args: # touch: The touch event # not_in_bar: Whether touch is NOT in a scrollbar # # Returns: # bool: True if should delegate to parent # False if should continue handling # Only check delegation for nested ScrollViews scrolling content if "nested" not in touch.ud: return False if not not_in_bar: return False # Get hierarchy and find our position hierarchy, my_index, parent_sv = self._get_nested_data(touch) # Check if delegation to outer is enabled if not self.delegate_to_outer: return False # Delegation disabled - don't cascade to parent if not hierarchy or parent_sv is None: return False # Not nested or we're the outer - no delegation # Only check delegation if we're the CURRENT handler in the hierarchy # For arbitrary depth: check current_index # Check if we're the current handler (hierarchy-based routing) if "current_index" not in touch.ud["nested"]: # No routing info - shouldn't happen but be safe return False if my_index != touch.ud["nested"]["current_index"]: return False # Not our turn to handle, don't delegate # CROSS-AXIS DELEGATION: Check FIRST if gesture is on an axis we can't # handle. This applies regardless of our relationship type with our # immediate child # Example: V outer (can't scroll X) in V→H should delegate X gestures # to outer XY above primary_axis = self._get_primary_scroll_axis(touch) if primary_axis == "x" and not self.do_scroll_x: # Horizontal gesture but we only scroll vertically - delegate upward return True if primary_axis == "y" and not self.do_scroll_y: # Vertical gesture but we only scroll horizontally - delegate upward return True # Get our relationship with immediate parent classification = hierarchy.get_classification(my_index) axis_config = hierarchy.get_axis_config(my_index) # Dispatch to appropriate delegation method based on classification if classification == "orthogonal": # Check immediate parent first (standard orthogonal delegation) if self._should_delegate_orthogonal(touch, parent_sv): return True # PHASE 8: Non-adjacent parallel delegation # If orthogonal with immediate parent but we're at boundary AND # trying to scroll beyond,look for a parallel ancestor further up # the hierarchy # Example: V->H->V, inner V at boundary should delegate to outer V primary_axis = self._get_primary_scroll_axis(touch) if primary_axis and self._is_scrolling_beyond_boundary( primary_axis, touch ): # We're at boundary AND scrolling beyond - # search for parallel ancestor parallel_ancestor, ancestor_idx = self._find_parallel_ancestor( touch, primary_axis ) if parallel_ancestor and parallel_ancestor.parallel_delegation: # Found ancestor that can handle this axis with # parallel_delegation enabled # Delegate to it # (cascade will update current_index to ancestor_idx) touch.ud["nested"]["parallel_ancestor_index"] = ancestor_idx return True return False elif classification == "mixed": return self._should_delegate_mixed(touch, parent_sv, axis_config) elif classification == "parallel": return self._should_delegate_parallel(touch, parent_sv) return False def _is_at_scroll_boundary(self, axis): # Check if this ScrollView is at a scroll boundary on the given axis. # # Args: # axis: 'x' or 'y' - the axis to check # # Returns: # bool: True if at boundary (near 0.0 or 1.0), False otherwise if axis == "x": if not self.do_scroll_x: return False scroll_pos = self.scroll_x elif axis == "y": if not self.do_scroll_y: return False scroll_pos = self.scroll_y else: return False # At boundary if within threshold of min or max return scroll_pos <= _BOUNDARY_THRESHOLD or scroll_pos >= ( 1.0 - _BOUNDARY_THRESHOLD ) def _is_scrolling_beyond_boundary(self, axis, touch): # Check if scroll gesture is trying to move beyond the current boundary. # # Args: # axis: 'x' or 'y' - the axis to check # touch: The touch event # # Returns: # bool: True if at boundary AND trying to scroll beyond it if axis == "x": if not self.do_scroll_x: return False scroll_pos = self.scroll_x # Check scroll direction from touch movement dx = touch.dx if hasattr(touch, "dx") else (touch.x - touch.px) # At left boundary (scroll_x ~= 0.0) trying to scroll further left if scroll_pos <= _BOUNDARY_THRESHOLD and dx > 0: return True # At right boundary (scroll_x ~= 1.0) trying to scroll further right if scroll_pos >= (1.0 - _BOUNDARY_THRESHOLD) and dx < 0: return True elif axis == "y": if not self.do_scroll_y: return False scroll_pos = self.scroll_y # Check scroll direction from touch movement dy = touch.dy if hasattr(touch, "dy") else (touch.y - touch.py) # At top boundary (scroll_y ~= 0.0) trying to scroll further up if scroll_pos <= _BOUNDARY_THRESHOLD and dy > 0: return True # At bottom boundary (scroll_y ~= 1.0) trying to scroll further down if scroll_pos >= (1.0 - _BOUNDARY_THRESHOLD) and dy < 0: return True return False def _find_parallel_ancestor(self, touch, axis): # Find nearest ancestor in hierarchy that can scroll on the given axis. # # Skips orthogonal intermediates to enable non-adjacent parallel # delegation. Used: V->H->V boundary delegation. # # CRITICAL: Respects delegate_to_outer property. If an intermediate # ScrollView has delegate_to_outer=False, we stop searching there # (acting as a delegation barrier). # # Args: # touch: The touch event # axis: 'x' or 'y' - the axis to search for # # Returns: # tuple: (ancestor_sv, ancestor_index) if found, # (None, None) if not found # # Example: # V->H->V hierarchy: Inner V at boundary, searching for 'y' # - Skips Middle H (can't scroll Y) # - Returns Outer V (can scroll Y) # # BUT if Outer V has delegate_to_outer=False: # - Returns (None, None) - can't delegate past the barrier hierarchy, my_index, _ = self._get_nested_data(touch) if not hierarchy: return None, None # Walk up hierarchy looking for ancestor that can scroll on this axis for i in range(my_index - 1, -1, -1): ancestor = hierarchy.scrollviews[i] # CRITICAL: Check if this ancestor allows delegation through it # If delegate_to_outer=False, it acts as a barrier # - stop searching here if not ancestor.delegate_to_outer: return None, None # Check if this ancestor can handle the axis we need if axis == "x" and ancestor.do_scroll_x: return ancestor, i if axis == "y" and ancestor.do_scroll_y: return ancestor, i return None, None def _handle_focus_behavior(self, touch, uid_key): # Handle focus behavior after scroll finalization. # # If the touch caused scrolling (can_defocus=False), add it to the # ignored list so focused widgets don't get defocused by this touch. # # Args: # touch: The touch event # uid_key: The unique key for this ScrollView's touch data if uid_key in touch.ud and not touch.ud[uid_key].get( "can_defocus", True ): FocusBehavior.ignored_touch.append(touch) def _touch_in_handle(self, pos, size, touch): # check if the touch is in the handle of the scrollview # touching the handle allows the user to drag the scrollview # clicking in the bar, and not the handle, jumps to that position # in the scrollview x, y = pos width, height = size return x <= touch.x <= x + width and y <= touch.y <= y + height def _check_scroll_bounds(self, touch): # Check if touch is within scrollable bounds and set in_bar flags. vp = self._viewport if not vp: return False, False # Calculate scrollable dimensions width_scrollable = ( self.always_overscroll and self.do_scroll_x ) or vp.width > self.width height_scrollable = ( self.always_overscroll and self.do_scroll_y ) or vp.height > self.height # Calculate distance from touch to scroll bar edges d = { "bottom": touch.y - self.y - self.bar_margin, "top": self.top - touch.y - self.bar_margin, "left": touch.x - self.x - self.bar_margin, "right": self.right - touch.x - self.bar_margin, } # Check if touch is in horizontal or vertical scroll bars scroll_bar = "bars" in self.scroll_type in_bar_x = ( scroll_bar and width_scrollable and (0 <= d[self.bar_pos_x] <= self.bar_width) ) in_bar_y = ( scroll_bar and height_scrollable and (0 <= d[self.bar_pos_y] <= self.bar_width) ) return in_bar_x, in_bar_y def _handle_mouse_wheel_scroll(self, btn, in_bar_x, in_bar_y): # Handle mouse wheel scrolling - WEB-STYLE (no delegation). # Wheel scrolls ONLY this ScrollView if it can handle the direction. # If it can't, returns False to let parent widgets try. m = self.scroll_wheel_distance # WEB-STYLE BEHAVIOR: # Mouse wheel scrolls the innermost ScrollView under cursor that can # handle the direction. No automatic delegation through hierarchy - user # must move mouse to scroll a different element. This matches standard # web browser UX. if btn in self._MOUSE_WHEEL_HORIZONTAL: # Horizontal wheel: only handle if we can scroll horizontally if not ( self.do_scroll_x and ( (self.always_overscroll and self.do_scroll_x) or (self._viewport and self._viewport.width > self.width) ) ): return False # Can't scroll horizontally, pass to parent elif btn in self._MOUSE_WHEEL_VERTICAL: # Vertical wheel: only handle if we can scroll vertically if not ( self.do_scroll_y and ( (self.always_overscroll and self.do_scroll_y) or (self._viewport and self._viewport.height > self.height) ) ): return False # Can't scroll vertically, pass to parent # Select appropriate scroll effect e = self._select_scroll_effect_for_wheel(btn, in_bar_x, in_bar_y) if not e: return False # Dispatch on_scroll_start for mouse wheel scrolling self.dispatch("on_scroll_start") self._apply_wheel_scroll(e, btn, m) e.trigger_velocity_update() return True def _select_scroll_effect_for_wheel(self, btn, in_bar_x, in_bar_y): # Select the appropriate scroll effect for mouse wheel scrolling. vp = self._viewport if not vp: return None width_scrollable = ( self.always_overscroll and self.do_scroll_x ) or vp.width > self.width height_scrollable = ( self.always_overscroll and self.do_scroll_y ) or vp.height > self.height if ( self.effect_x and self.do_scroll_y and height_scrollable and btn in self._MOUSE_WHEEL_VERTICAL ): return self.effect_x if in_bar_x else self.effect_y elif ( self.effect_y and self.do_scroll_x and width_scrollable and btn in self._MOUSE_WHEEL_HORIZONTAL ): return self.effect_y if in_bar_y else self.effect_x return None def _apply_wheel_scroll(self, effect, btn, distance): # Apply wheel scroll movement to the selected effect. self._update_effect_bounds() if btn in self._MOUSE_WHEEL_DECREASE: if self.smooth_scroll_end: effect.velocity -= distance * self.smooth_scroll_end else: if self.always_overscroll: effect.value = effect.value - distance else: effect.value = max(effect.value - distance, effect.max) effect.velocity = 0 elif btn in self._MOUSE_WHEEL_INCREASE: if self.smooth_scroll_end: effect.velocity += distance * self.smooth_scroll_end else: if self.always_overscroll: effect.value = effect.value + distance else: effect.value = min(effect.value + distance, effect.min) effect.velocity = 0 def _handle_scrollbar_jump(self, touch, in_bar_x, in_bar_y): # Handle scrollbar position jump when clicking in bar but not handle. ud = touch.ud if in_bar_y and not self._touch_in_handle( self._handle_y_pos, self._handle_y_size, touch ): self.scroll_y = (touch.y - self.y) / self.height elif in_bar_x and not self._touch_in_handle( self._handle_x_pos, self._handle_x_size, touch ): self.scroll_x = (touch.x - self.x) / self.width e = self.effect_y if in_bar_y else self.effect_x if e: self._update_effect_bounds() e.velocity = 0 e.overscroll = 0 e.trigger_velocity_update() def _initialize_scroll_effects(self, touch, in_bar): # Initialize scroll effects for both axes if enabled. if self.do_scroll_x and self.effect_x and not in_bar: self._update_effect_bounds() self._effect_x_start_width = self.width self.effect_x.start(touch.x) if self.do_scroll_y and self.effect_y and not in_bar: self._update_effect_bounds() self._effect_y_start_height = self.height self.effect_y.start(touch.y) def _should_delegate_orthogonal(self, touch, parent_sv): # Check if touch movement is orthogonal to scroll direction. # # Only called for orthogonal configurations # (classification already verified). # Delegates when movement is in a direction child can't handle # but parent can. # # Args: # touch: The touch event # parent_sv: The parent ScrollView in the hierarchy # # Returns: # bool: True if should delegate to parent abs_dx = abs(touch.dx) abs_dy = abs(touch.dy) # Only delegate if movement is SIGNIFICANTLY orthogonal # (2x threshold to avoid noise) # AND parent scrollview CAN scroll in that direction # Horizontal movement that child can't handle, but parent can if ( abs_dx > abs_dy * 2 and not self.do_scroll_x and parent_sv.do_scroll_x ): return True # Vertical movement that child can't handle, but parent can if ( abs_dy > abs_dx * 2 and not self.do_scroll_y and parent_sv.do_scroll_y ): return True return False def _should_delegate_mixed(self, touch, parent_sv, axis_config): # Check if touch should be delegated in mixed nested configurations. # # Only called for mixed configurations (classification already verified) # Handles parent-exclusive and child-exclusive axes. # Shared axes use the same boundary logic as parallel cases. # # Args: # touch: The touch event # parent_sv: The parent ScrollView in the hierarchy # axis_config: Dictionary with axis configuration # # Returns: # bool: True if should delegate to parent # Calculate total movement since touch_down total_dx = touch.x - touch.ox total_dy = touch.y - touch.oy abs_dx = abs(total_dx) abs_dy = abs(total_dy) # Need minimum movement to determine direction if abs_dx < self.scroll_distance and abs_dy < self.scroll_distance: return False # Determine dominant drag direction is_horizontal_dominant = abs_dx > abs_dy is_vertical_dominant = abs_dy > abs_dx # Check parent-exclusive axes (immediate delegation) outer_exclusive = axis_config.get("outer_exclusive", []) if is_horizontal_dominant and "x" in outer_exclusive: return True if is_vertical_dominant and "y" in outer_exclusive: return True # Check child-exclusive axes (no delegation, child continues) inner_exclusive = axis_config.get("inner_exclusive", []) if is_horizontal_dominant and "x" in inner_exclusive: return False if is_vertical_dominant and "y" in inner_exclusive: return False # Shared axes: use parallel boundary delegation logic # Call _should_delegate_parallel for shared axes # (same behavior as parallel cases) return self._should_delegate_parallel(touch, parent_sv) def _should_delegate_parallel(self, touch, parent_sv): # Check if scroll should lock at boundary for parallel scrolling. # # Only called for parallel configurations # (classification already verified). # Also used for SHARED AXES in MIXED cases. # # Implements web-style boundary locking based on delegation_mode: # Returns True if child scrollview should stop scrolling (locked state). # Does NOT cause delegation to parent - that only happens on NEW touch. # # Args: # touch: The touch event # parent_sv: The parent ScrollView in the hierarchy # # Returns: # bool: True if should lock (stop scrolling but don't delegate) # NOTE: Cross-axis delegation (e.g., X gesture on Y-only ScrollView) is # handled earlier in _check_nested_delegation before dispatching to this # method. This method only handles boundary delegation for axes this # ScrollView CAN scroll. # Check parallel_delegation logic if not parent_sv.parallel_delegation: return False # Get per-ScrollView delegation mode (arbitrary depth support) sv_uid = self._get_uid() delegation_modes = touch.ud["nested"].get("delegation_modes", {}) # For arbitrary depth: use per-ScrollView mode # (default UNLOCKED if not set) if delegation_modes: # We have per-ScrollView modes (arbitrary depth) # - use ours or default to UNLOCKED delegation_mode = delegation_modes.get( sv_uid, DelegationMode.UNLOCKED ) else: # No per-ScrollView modes (2-level legacy) - use global mode delegation_mode = touch.ud["nested"].get( "delegation_mode", DelegationMode.UNLOCKED ) # If not in delegation mode, never lock if delegation_mode == DelegationMode.UNLOCKED: return False # If already locked, keep it locked (child doesn't scroll, # stays locked for this gesture) if delegation_mode == DelegationMode.LOCKED: return True # delegation_mode == START_AT_BOUNDARY # Web-style: Started at boundary, now check movement direction primary_axis = self._get_primary_scroll_axis(touch) if primary_axis == "x" and self.do_scroll_x: # Horizontal scrolling at_boundary = self._is_at_scroll_boundary("x") scrolling_beyond = self._is_scrolling_beyond_boundary("x", touch) # Check if we've moved away from the boundary into content if not at_boundary: # Moved away from boundary into content - # UNLOCK for this gesture sv_uid = self._get_uid() if "delegation_modes" in touch.ud["nested"]: touch.ud["nested"]["delegation_modes"][ sv_uid ] = DelegationMode.UNLOCKED touch.ud["nested"]["delegation_mode"] = DelegationMode.UNLOCKED return False # Still at boundary - check if trying to scroll beyond if scrolling_beyond: # Trying to overscroll - DELEGATE to parent (chain delegation) return True # Delegate to parent elif primary_axis == "y" and self.do_scroll_y: # Vertical scrolling at_boundary = self._is_at_scroll_boundary("y") scrolling_beyond = self._is_scrolling_beyond_boundary("y", touch) # Check if we've moved away from the boundary into content if not at_boundary: # Moved away from boundary into content, UNLOCK for this gesture sv_uid = self._get_uid() if "delegation_modes" in touch.ud["nested"]: touch.ud["nested"]["delegation_modes"][ sv_uid ] = DelegationMode.UNLOCKED touch.ud["nested"]["delegation_mode"] = DelegationMode.UNLOCKED return False # Still at boundary - check if trying to scroll beyond if scrolling_beyond: # Trying to overscroll - DELEGATE to parent (chain delegation) return True # Delegate to parent return False def _process_scroll_axis_x(self, touch, not_in_bar): # Process X-axis scroll movement. if ( touch.ud["sv.handled"]["x"] or not self.do_scroll_x or not self.effect_x ): return False width = self.width if touch.ud["in_bar_x"]: if self.hbar[1] != 1: dx = touch.dx / float(width - width * self.hbar[1]) self.scroll_x = min(max(self.scroll_x + dx, 0.0), 1.0) self._trigger_update_from_scroll() elif not_in_bar: self.effect_x.update(touch.x) touch.ud["sv.handled"]["x"] = True touch.ud["sv.can_defocus"] = False return True def _process_scroll_axis_y(self, touch, not_in_bar): # Process Y-axis scroll movement. if ( touch.ud["sv.handled"]["y"] or not self.do_scroll_y or not self.effect_y ): return False height = self.height if touch.ud["in_bar_y"] and self.vbar[1] != 1.0: dy = touch.dy / float(height - height * self.vbar[1]) self.scroll_y = min(max(self.scroll_y + dy, 0.0), 1.0) self._trigger_update_from_scroll() elif not_in_bar: self.effect_y.update(touch.y) self._trigger_update_from_scroll() touch.ud["sv.handled"]["y"] = True touch.ud["sv.can_defocus"] = False return True def _stop_scroll_effects(self, touch, not_in_bar): if self.do_scroll_x and self.effect_x and not_in_bar: self.effect_x.stop(touch.x) if self.do_scroll_y and self.effect_y and not_in_bar: self.effect_y.stop(touch.y) def _finalize_scroll_for_cascade(self, touch): # Finalize scroll when cascading to parent, # without interfering with touch_up. # # This stops effects and schedules on_scroll_stop, but doesn't handle # click passthrough or other finalization that might interfere with # the normal on_touch_up flow to child widgets (like buttons). # # Called during on_touch_move when delegating from child to parent. # Clear our active touch reference (so we can accept new touches) self._touch = None # Get scroll state uid = self._get_uid() if uid not in touch.ud: return # Never initialized, nothing to finalize ud = touch.ud[uid] # Safeguard: Check if already finalized if ud.get("_finalized_for_cascade"): return ud["_finalized_for_cascade"] = True # Stop scroll effects (prevent stuck scroll) not_in_bar = not touch.ud.get("in_bar_x", False) and not touch.ud.get( "in_bar_y", False ) self._stop_scroll_effects(touch, not_in_bar) # Schedule velocity check for on_scroll_stop event if ud["mode"] == ScrollMode.SCROLL or ud.get("scroll_action"): if self._velocity_check_ev: self._velocity_check_ev.cancel() self._velocity_check_ev = Clock.schedule_interval( self._check_velocity_for_stop, 1 / 60.0 ) # CRITICAL: Remove our uid from touch.ud so on_touch_up will delegate # to children. This ensures buttons and other child widgets receive # their on_touch_up events even after we've cascaded scroll # handling to a parent del touch.ud[uid] # Clean up svavoid flag (but KEEP claimed_by_child flag - # it's needed for on_touch_up!) svavoid_key = self._get_uid("svavoid") if svavoid_key in touch.ud: del touch.ud[svavoid_key] # Update effect bounds ev = self._update_effect_bounds_ev if ev is None: ev = self._update_effect_bounds_ev = Clock.create_trigger( self._update_effect_bounds ) ev() # CRITICAL: Trigger bar fade animation since _scroll_finalize # won't be called (our uid was deleted above, # so _scroll_finalize will early-return) # This schedules the 0.5s timer to fade bar from active to # inactive color self._trigger_update_from_scroll() # NOTE: We do NOT handle click passthrough here because the actual # on_touch_up will come later and needs to reach child widgets # # TOUCH USER DATA (touch.ud) KEY DOCUMENTATION # ============================================ # This section documents all touch.ud keys used across ScrollView. # # KEY NAMING CONVENTIONS: # ---------------------- # # PER-INSTANCE 'sv.' NAMESPACE: # ----------------------------- # ScrollView uses 'sv.' prefixed keys via _get_uid() to create # instance-specific keys like 'sv.123', where 123 is the widget's # unique ID. Each ScrollView only checks its own keys - they do NOT # share or coordinate via these keys. # # Per-ScrollView Instance Keys: # - sv.<uid>: Primary state dict for this ScrollView instance # Contains: # { # 'mode': ScrollMode, # State: UNKNOWN (detecting intent) # # -> SCROLL (confirmed) # 'dx': float, # accumulated absolute X movement # # for detection # 'dy': float, # accumulated absolute Y movement # # for detection # 'scroll_action': bool, # True if touch started in scrollbar # # (skips on_scroll_start dispatch) # 'frames': int, # Clock.frames at touch start (timing) # 'can_defocus': bool, # Whether this touch can defocus # # focused widgets # } # # - svavoid.<uid>: Flag indicating this ScrollView should avoid # handling this touch # Set when: Mouse wheel events are handled, or touch doesn't # collide # Purpose: Prevents double-processing of already-handled touches # # Note: Each widget (ScrollView, DragBehavior) has its own # UID-namespaced svavoid key. Widgets do NOT coordinate via svavoid # - each checks only its own key. # # Cross-ScrollView Shared Keys: # - sv.handled: dict {'x': bool, 'y': bool} # Purpose: Tracks which axes have been processed by a ScrollView # Lifecycle: Set at start of on_touch_move, updated during scroll # processing # # - sv.claimed_by_child: bool # Set when: _change_touch_mode delegates touch to children and # child grabs (timeout expired) # Purpose: Signals a child widget (button, etc.) has claimed this # touch. Prevents ANY ScrollView from processing scroll gestures. # ScrollView becomes transparent, calling super() to propagate # touch properly. # Used in: _scroll_initialize, on_touch_move, on_touch_up # Lifecycle: Set in _change_touch_mode when child grabs, cleared # in on_touch_up # # - sv.can_defocus: bool # Purpose: Controls whether FocusBehavior should defocus on this # touch # Set to False when: Touch results in actual scrolling # Used in: on_touch_up to prevent defocus after scroll gestures # # NESTED SCROLLVIEW NAMESPACE # (Arbitrary Depth Support): # ------------------------------------------------------- # - nested: dict - Nested ScrollView coordination state for # arbitrary depth hierarchies # Contains: # { # 'hierarchy': ScrollViewHierarchy, # Chain of nested # # ScrollViews (2+ levels) # 'current_index': int, # Index of ScrollView currently # # handling touch # 'parallel_ancestor_index': int (optional), # For # # non-adjacent delegation # } # Purpose: Coordinates touches across multiple levels of nested # ScrollViews # Lifecycle: Built in outer's on_touch_down, updated during # cascade in on_touch_move # # - sv_hierarchy_handled: bool # Global flag indicating hierarchy coordination is complete # Set when: Outer ScrollView completes on_touch_up coordination # (line 2623) # Purpose: Prevents multiple hierarchies from interfering with # each other. This occurs in non-adjacent delegation when # skip-level cascade creates a shallower hierarchy (e.g., # 3-level becomes 2-level) # Used in: on_touch_up to make all ScrollViews transparent after # coordination done # Lifecycle: Set once by first hierarchy to complete, checked by # all ScrollViews # # GLOBAL SCROLLBAR FLAGS # (No Namespacing Required): # -------------------------------------------------- # - in_bar_x: bool - Touch started on horizontal scroll bar # - in_bar_y: bool - Touch started on vertical scroll bar # Purpose: Differentiates bar dragging from content scrolling # Affects: Touch routing, effect handling, movement calculations, # delegation # Rationale: Scroll bars are positioned at widget edges and cannot # overlap, so multiple ScrollViews can safely share these global # flags # ========================================================================= # MAIN TOUCH HANDLING METHODS (in lifecycle order) # =========================================================================
[docs] def on_touch_down(self, touch): # SCROLLVIEW TOUCH HANDLING WITH AUTO-NESTED DETECTION # ===================================================== # This method automatically detects nested ScrollView configurations and # sets up coordination. if not self.collide_point(*touch.pos): return False # Check if we already have an active nested ScrollView touch # This prevents multiple ScrollViews from scrolling simultaneously if self._nested_sv_active_touch is not None: touch.ud[self._get_uid("svavoid")] = True return False # NESTED DETECTION via touch event flow: # Parent widgets receive touches BEFORE children in Kivy. # - If 'nested' NOT in touch.ud: We're the FIRST ScrollView to see # this touch (either outer or standalone - check for children below) # - If 'nested' IN touch.ud: We're an INNER ScrollView # (parent already set up coordination - process as inner) # Check if touch is on OUR scrollbar - # if so, handle it directly (don't look for children) # This ensures outer scrollbar works even when there are inner SVs in_bar_x, in_bar_y = self._check_scroll_bounds(touch) if in_bar_x or in_bar_y: # Touch is on our scrollbar - handle it directly if self._scroll_initialize(touch): touch.grab(self) return True return False # We might be the outer ScrollView - check for child at touch position # Build full hierarchy (supports arbitrary depth nesting) hierarchy = self._build_hierarchy_recursive(touch) is_wheel = "button" in touch.profile and touch.button.startswith( "scroll" ) if hierarchy: # We're the OUTER ScrollView with nested children # hierarchy.depth >= 2 (self + at least one child) # For backward compatibility with 2-level code, extract outer/inner outer_sv = hierarchy.outer inner_sv = hierarchy.inner # Innermost (deepest) ScrollView # Get config for the LAST relationship # (inner with its immediate parent) config_type = hierarchy.get_classification(hierarchy.depth - 1) axis_config = hierarchy.get_axis_config(hierarchy.depth - 1) # Set up nested coordination with hierarchy touch.ud["nested"] = { "hierarchy": hierarchy, "current_index": hierarchy.touched_index, # Start at innermost "config_type": config_type, # Classification of inner with its parent "delegation_mode": DelegationMode.UNLOCKED, # Will be set in _scroll_initialize } # Store axis_config for mixed configurations if axis_config: touch.ud["nested"]["axis_config"] = axis_config # WEB-STYLE WHEEL BEHAVIOR: Wheel scrolls ONLY innermost ScrollView # under cursor # No delegation through hierarchy - matches standard web browser UX if is_wheel: # Try only the innermost ScrollView if inner_sv._scroll_initialize(touch): return True # Innermost couldn't handle it - don't try outer levels # This prevents scroll hijacking when inner reaches boundary return False # Non-wheel touch: Initialize scrolling on the innermost child # (handles coordinate transformation) result = self._initialize_nested_inner(touch, inner_sv) # After initialization, setup delegation modes for ALL ScrollViews # in hierarchy # (The innermost already had its mode set by # _setup_boundary_delegation in _scroll_initialize) # Now check the other levels in the hierarchy if result and "nested" in touch.ud: for i in range( 1, hierarchy.depth - 1 ): # Skip outer (0) and innermost (already done) child_sv = hierarchy.scrollviews[i] parent_sv = hierarchy.scrollviews[i - 1] # Only check if parent has parallel_delegation enabled if parent_sv.parallel_delegation: # Check if child is at boundary in parallel directions at_boundary_x = ( child_sv.do_scroll_x and parent_sv.do_scroll_x and child_sv._is_at_scroll_boundary("x") ) at_boundary_y = ( child_sv.do_scroll_y and parent_sv.do_scroll_y and child_sv._is_at_scroll_boundary("y") ) if at_boundary_x or at_boundary_y: child_uid = child_sv._get_uid() if "delegation_modes" not in touch.ud["nested"]: touch.ud["nested"]["delegation_modes"] = {} touch.ud["nested"]["delegation_modes"][ child_uid ] = DelegationMode.START_AT_BOUNDARY return result # We're STANDALONE - no parent, no child ScrollView found if self._scroll_initialize(touch): # Only grab if we actually set up scroll state uid = self._get_uid() if uid in touch.ud: # We set up scroll state - # claim this touch to ensure only one active touch self._nested_sv_active_touch = touch touch.grab(self) return True return False
def _scroll_initialize(self, touch): # This is the first phase of the scroll gesture, call from on_touch_down # it is used to determine if the scrollview should handle the touch # and to initialize the scroll effects if "nested" in touch.ud and "hierarchy" in touch.ud["nested"]: is_inner = touch.ud["nested"]["hierarchy"].inner == self # Check if this touch was claimed by a child widget (e.g., button) # If so, don't initialize scrolling if touch.ud.get("sv.claimed_by_child", False): return False # Skip collision check if we're the inner in a nested setup and parent # already validated (parent called us directly after finding us # with _find_child_scrollview_at_touch) skip_collision = ( "nested" in touch.ud and "hierarchy" in touch.ud["nested"] and touch.ud["nested"]["hierarchy"].inner == self ) if not skip_collision and not self.collide_point(*touch.pos): touch.ud[self._get_uid("svavoid")] = True return False if self.disabled: return True if self._touch: # Already handling a touch - reject this one to enforce single-touch # EXCEPT for mouse wheel events which are independent is_wheel = "button" in touch.profile and touch.button.startswith( "scroll" ) if not is_wheel: # Check if stored touch is stale (completed but not cleaned up) # This happens when a touch completes via hierarchy handling but # this ScrollView wasn't the one that called _scroll_finalize # This is defensive. All _touch are cleared in scroll_finalize touch_is_stale = ( 'sv_hierarchy_handled' in self._touch.ud # Touch completed and not any( ref() is self for ref in (self._touch.grab_list or []) ) # We're not in grab list ) if touch_is_stale: # Clear the stale touch reference self._touch = None # Fall through to process the new touch else: # Touch is still active - reject new touch return False # For wheel events, continue even if we have an active touch if not (self.do_scroll_x or self.do_scroll_y): # Scrolling is disabled for both axes # Re-dispatch to child content so non-scroll interactions # (e.g., buttons) still work return self._simulate_touch_down(touch) # Handle mouse scrolling, only if the viewport size is bigger than the # scrollview size, and if the user allowed to do it vp = self._viewport if not vp: return True scroll_type = self.scroll_type ud = touch.ud # Check if touch is in scroll bars and set in_bar flags in_bar_x, in_bar_y = self._check_scroll_bounds(touch) in_bar = in_bar_x or in_bar_y ud["in_bar_x"] = in_bar_x ud["in_bar_y"] = in_bar_y if "button" in touch.profile and touch.button.startswith("scroll"): if self._handle_mouse_wheel_scroll( touch.button, in_bar_x, in_bar_y ): touch.ud[self._get_uid("svavoid")] = True # Start velocity check for scroll stop after mouse wheel if self._velocity_check_ev: self._velocity_check_ev.cancel() self._velocity_check_ev = Clock.schedule_interval( self._check_velocity_for_stop, 1 / 60.0 ) return True return False if scroll_type == ["bars"] and not in_bar: return self._simulate_touch_down(touch) if in_bar: self._handle_scrollbar_jump(touch, in_bar_x, in_bar_y) # Dispatch on_scroll_start for scrollbar interactions self.dispatch("on_scroll_start") # No mouse scrolling, the user is going to drag the scrollview with # this touch. self._touch = touch # Set the touch state for this touch uid = self._get_uid() ud[uid] = { "mode": ScrollMode.UNKNOWN, "dx": 0, "dy": 0, "scroll_action": in_bar, "frames": Clock.frames, "can_defocus": True, # Default: allow defocus unless scrolling } # Initialize scroll effects for content scrolling self._initialize_scroll_effects(touch, in_bar) # Configure boundary delegation for nested parallel scrolling self._setup_boundary_delegation(touch, in_bar) if not in_bar: Clock.schedule_once( self._change_touch_mode, self.scroll_timeout / 1000.0 ) return True
[docs] def on_touch_move(self, touch): # SCROLLVIEW TOUCH HANDLING WITH ARBITRARY DEPTH HIERARCHY # ========================================================= # Handles both standalone and nested ScrollView configurations. # For nested setups (2+ levels), uses hierarchy and current_index to # route touches to the correct ScrollView in the chain. # NESTED COORDINATION: Check if we have hierarchy data nested_data = touch.ud.get("nested") if nested_data and "hierarchy" in nested_data: hierarchy = nested_data["hierarchy"] current_index = nested_data["current_index"] # Only the OUTER ScrollView coordinates the hierarchy if hierarchy.outer is not self: return True # Not our job to coordinate # We're the outer - we coordinate regardless of grab state # The hierarchy structure itself determines our role, not the grab # Get the ScrollView that should handle this touch current_sv = hierarchy.scrollviews[current_index] # Check if child widget claimed the touch - propagate to children if touch.ud.get("sv.claimed_by_child", False): return super(ScrollView, self).on_touch_move(touch) # Route to current handler if current_sv is self: # We (outer) are handling - process normally touch.ud["sv.handled"] = {"x": False, "y": False} return self._scroll_update(touch) else: # Another ScrollView in hierarchy is handling # Transform and delegate to it # Loop to handle cascading delegation up the chain while True: current_sv = hierarchy.scrollviews[current_index] touch.ud["sv.handled"] = {"x": False, "y": False} touch.push() touch.apply_transform_2d(current_sv.parent.to_widget) result = current_sv._scroll_update(touch) touch.pop() # If current ScrollView accepted, we're done if result: return True # Current ScrollView rejected - cascade up the chain # The _should_delegate_* methods already handle boundary # logic and return True/False. If they return # True (delegate), we should cascade regardless of mode if current_index > 0: # Move up to parent in chain child_sv = current_sv # Check if we should skip to a non-adjacent # parallel ancestor if "parallel_ancestor_index" in nested_data: # Non-adjacent parallel delegation # (e.g., V->H->V, skip H, go to outer V) new_index = nested_data["parallel_ancestor_index"] del nested_data[ "parallel_ancestor_index" ] # One-time use # Finalize any intermediate ScrollViews that we're # skipping over # This ensures their touch state is cleaned up even # though they never handled this touch for skip_idx in range( current_index - 1, new_index, -1 ): skipped_sv = hierarchy.scrollviews[skip_idx] skipped_uid = skipped_sv._get_uid() if skipped_uid in touch.ud: # This intermediate ScrollView has touch # data - clean it up touch.push() touch.apply_transform_2d( skipped_sv.parent.to_widget ) skipped_sv._finalize_scroll_for_cascade( touch ) touch.pop() current_index = new_index else: # Standard delegation to immediate parent current_index -= 1 nested_data["current_index"] = current_index parent_sv = hierarchy.scrollviews[current_index] # Initialize parent's scroll state if not already set up parent_uid = parent_sv._get_uid() if parent_uid not in touch.ud: # Set to UNKNOWN so parent can detect scroll # intent on next move touch.ud[parent_uid] = { "mode": ScrollMode.UNKNOWN, "dx": 0, "dy": 0, "scroll_action": False, "frames": 0, "can_defocus": False, } # Transform touch to parent's space before # initializing effects touch.push() touch.apply_transform_2d(parent_sv.parent.to_widget) parent_sv._initialize_scroll_effects( touch, in_bar=False ) touch.pop() parent_sv._touch = touch parent_sv.dispatch("on_scroll_start") # CRITICAL: Finalize child's scroll when cascading # This stops effects AND schedules # on_scroll_stop event # But doesn't interfere with normal # on_touch_up to buttons/widgets touch.push() touch.apply_transform_2d(child_sv.parent.to_widget) child_sv._finalize_scroll_for_cascade(touch) touch.pop() # Extra safety: If child had _touch set but # no scroll state, clear it to prevent # blocking future touches if child_sv._touch is touch: child_sv._touch = None # Parent just initialized - return True and let next # touch_move handle it # This prevents immediate cascading # in the same event return True # Parent already initialized continue else: # At outer level, and it rejected - # shouldn't happen but return False return False # STANDALONE: Standard single-touch processing # Only process our designated touch if self._touch is not touch: return self._delegate_to_children(touch, "on_touch_move") if touch.grab_current is not self: return True # Verify we have established scroll state for this touch if not any( isinstance(key, str) and key.startswith("sv.") for key in touch.ud ): return self._delegate_to_children(touch, "on_touch_move") # Process the scroll movement touch.ud["sv.handled"] = {"x": False, "y": False} return self._scroll_update(touch)
def _scroll_update(self, touch): # Second phase of scroll gesture - # updates scroll effects during movement. # # Handles mode detection (unknown -> scroll), nested delegation checks, # and axis-specific scroll processing. # Early rejection checks if self._get_uid("svavoid") in touch.ud: return False if touch.ud.get("sv.claimed_by_child", False): # Child widget claimed touch - propagate through widget tree return super(ScrollView, self).on_touch_move(touch) # Allow this touch to defocus focused widgets (default behavior) touch.ud["sv.can_defocus"] = True # Verify we have scroll state for this touch uid = self._get_uid() if uid not in touch.ud: self._touch = False return self._scroll_initialize(touch) # Detect scroll intent (unknown -> scroll mode transition) ud = touch.ud[uid] if ud["mode"] == ScrollMode.UNKNOWN: if not self._detect_scroll_intent(touch, ud): return False # Process active scrolling if ud["mode"] == ScrollMode.SCROLL: not_in_bar = not touch.ud["in_bar_x"] and not touch.ud["in_bar_y"] # Check if inner should delegate to outer if self._check_nested_delegation(touch, not_in_bar): return ( False # Delegate to outer (on_touch_move will switch mode) ) # Process scroll movement for each axis self._process_scroll_axis_x(touch, not_in_bar) self._process_scroll_axis_y(touch, not_in_bar) return True
[docs] def on_touch_up(self, touch): # SCROLLVIEW TOUCH HANDLING WITH ARBITRARY DEPTH HIERARCHY # ======================================================== # Handles touch release for both standalone and nested # configurations. For nested setups (2+ levels), uses # current_index to finalize the correct ScrollView in the # chain. # # CRITICAL: Unschedule the timeout callback to prevent a race # condition. If the timeout fires during on_touch_up # processing, child widgets can grab the touch too late and # never receive on_touch_up (stuck button bug) if self._touch is touch: Clock.unschedule(self._change_touch_mode) # FAST PATH: If ANY nested hierarchy already completed on_touch_up, # skip processing. This prevents duplicate processing when # non-adjacent delegation creates multiple hierarchies for the # same touch (e.g., a 3-level cascade becomes a 2-level cascade). # The first hierarchy to complete sets this flag; all others # become transparent. if "sv_hierarchy_handled" in touch.ud: return False # NESTED COORDINATION: Check if we have hierarchy data nested_data = touch.ud.get("nested") if nested_data and "hierarchy" in nested_data: hierarchy = nested_data["hierarchy"] current_index = nested_data["current_index"] # Only the OUTER ScrollView coordinates if hierarchy.outer is not self: # Intermediate ScrollView - be transparent # Outer ScrollView will do the cleanup return False # Get the ScrollView that was handling this touch current_sv = hierarchy.scrollviews[current_index] # Check if current handler has scroll state current_uid = current_sv._get_uid() has_state = current_uid in touch.ud # Finalize the current handler if current_sv is self: # We (outer) were handling - finalize normally self._scroll_finalize(touch) self._handle_focus_behavior(touch, self._get_uid()) else: # Another ScrollView in hierarchy was handling # Transform and finalize it current_uid = current_sv._get_uid() if current_uid in touch.ud: touch.push() touch.apply_transform_2d(current_sv.parent.to_widget) current_sv._scroll_finalize(touch) touch.pop() self._handle_focus_behavior(touch, current_uid) # Clear the nested ScrollView active touch (outer only) if self._nested_sv_active_touch is touch: self._nested_sv_active_touch = None # Release grab if touch.grab_current is self: touch.ungrab(self) # Mark that ScrollView hierarchy handling is complete touch.ud["sv_hierarchy_handled"] = True # Proactively clear _touch for ALL ScrollViews in hierarchy # This prevents stale touch references when intermediate ScrollViews # had _touch set but didn't call _scroll_finalize themselves for sv in hierarchy.scrollviews: if sv._touch is touch: sv._touch = None # CRITICAL: Check if a child widget (button) claimed this touch # We check AFTER cleanup so hierarchy state is properly finalized claimed_by_child = touch.ud.get("sv.claimed_by_child", False) # If a child claimed the touch, propagate through widget tree by # calling super() # This ensures intermediate parent widgets receive on_touch_up # events # Kivy's grab mechanism will also dispatch to grabbed widgets # automatically if claimed_by_child: return super(ScrollView, self).on_touch_up(touch) # No child claimed - ScrollView handled the gesture # Return False to allow other widgets to process if needed return False # STANDALONE: Handle touch release uid_key = self._get_uid() # Touch was handled by this ScrollView if uid_key in touch.ud: self._scroll_finalize(touch) # Clear the nested ScrollView active touch (standalone case) if self._nested_sv_active_touch is touch: self._nested_sv_active_touch = None # Release grab if we still have it (handlers may have released it) gl = touch.grab_list or [] if any(w() is self for w in gl): touch.ungrab(self) self._handle_focus_behavior(touch, uid_key) return True # Touch not handled by us - delegate to children uid = self._get_uid("svavoid") if self._touch is not touch and uid not in touch.ud: return self._delegate_to_children(touch, "on_touch_up") # Final fallback: finalize and ungrab if self._scroll_finalize(touch): touch.ungrab(self) self._handle_focus_behavior(touch, uid_key) return True
def _scroll_finalize(self, touch): # SCROLL COMPLETION AND FINAL CLEANUP, called from on_touch_up # This method handles the end of scroll gestures and performs cleanup. self._touch = None # Clear our active touch reference # Early exit if this ScrollView never handled this touch: # Case 1: svavoid is set - we explicitly avoided this touch # (e.g., outside bounds, wheel handled) # Case 2: UID not in touch.ud - we never initialized scroll state # (touch went to child widget) if ( self._get_uid("svavoid") in touch.ud or self._get_uid() not in touch.ud ): return False uid = self._get_uid() ud = touch.ud[uid] # Determine if this was a scroll bar interaction not_in_bar = not touch.ud["in_bar_x"] and not touch.ud["in_bar_y"] # Stop scroll effects if they were active (not for bar interactions) self._stop_scroll_effects(touch, not_in_bar) # Start checking for velocity-based stop if this was a scroll # this is used to dispatch the on_scroll_stop event if ud["mode"] == ScrollMode.SCROLL or ud["scroll_action"]: if self._velocity_check_ev: self._velocity_check_ev.cancel() self._velocity_check_ev = Clock.schedule_interval( self._check_velocity_for_stop, 1 / 60.0 ) # CLICK PASSTHROUGH LOGIC # If the gesture never transitioned from UNKNOWN to SCROLL mode, # it means the user made a tap/click rather than a scroll gesture. # We need to simulate the click for child widgets to handle. if ud["mode"] == ScrollMode.UNKNOWN: # Only simulate click if no scroll action occurred # (e.g., no scroll bar interaction or dragging) if not ud["scroll_action"]: self._simulate_touch_down( touch ) # Let children handle the "click" # Schedule delayed touch up to complete the click simulation Clock.schedule_once(partial(self._do_touch_up, touch), 0.2) # Update effect bounds after scroll completion ev = self._update_effect_bounds_ev if ev is None: ev = self._update_effect_bounds_ev = Clock.create_trigger( self._update_effect_bounds ) ev() # Always accept mouse wheel events if "button" in touch.profile and touch.button.startswith("scroll"): return True # Return whether we had any involvement with this touch return self._get_uid() in touch.ud
[docs] def scroll_to(self, widget, padding=10, animate=True): '''Scrolls the viewport to ensure that the given widget is visible, optionally with padding and animation. If animate is True (the default), then the default animation parameters will be used. Otherwise, it should be a dict containing arguments to pass to :class:`~kivy.animation.Animation` constructor. .. versionadded:: 1.9.1 ''' if not self.parent: return # if _viewport is layout and has pending operation, reschedule if hasattr(self._viewport, "do_layout"): if self._viewport._trigger_layout.is_triggered: Clock.schedule_once( lambda *dt: self.scroll_to(widget, padding, animate) ) return if isinstance(padding, (int, float)): padding = (padding, padding) pos = self.parent.to_widget(*widget.to_window(*widget.pos)) cor = self.parent.to_widget(*widget.to_window(widget.right, widget.top)) dx = dy = 0 if pos[1] < self.y: dy = self.y - pos[1] + dp(padding[1]) elif cor[1] > self.top: dy = self.top - cor[1] - dp(padding[1]) if pos[0] < self.x: dx = self.x - pos[0] + dp(padding[0]) elif cor[0] > self.right: dx = self.right - cor[0] - dp(padding[0]) dsx, dsy = self.convert_distance_to_scroll(dx, dy) sxp = min(1, max(0, self.scroll_x - dsx)) syp = min(1, max(0, self.scroll_y - dsy)) # Stop any existing motion before starting new animation if self.effect_x: self.effect_x.halt() if self.effect_y: self.effect_y.halt() if animate: if animate is True: animate = {"d": 0.2, "t": "out_quad"} Animation.stop_all(self, "scroll_x", "scroll_y") Animation(scroll_x=sxp, scroll_y=syp, **animate).start(self) else: self.scroll_x = sxp self.scroll_y = syp
[docs] def convert_distance_to_scroll(self, dx, dy): '''Convert a distance in pixels to a scroll distance, depending on the content size and the scrollview size. The result will be a tuple of scroll distance that can be added to :data:`scroll_x` and :data:`scroll_y` ''' if not self._viewport: return 0, 0 vp = self._viewport if vp.width > self.width: sw = vp.width - self.width sx = dx / float(sw) else: sx = 0 if vp.height > self.height: sh = vp.height - self.height sy = dy / float(sh) else: sy = 1 return sx, sy
[docs] def update_from_scroll(self, *largs): '''Force the reposition of the content, according to current value of :attr:`scroll_x` and :attr:`scroll_y`. This method is automatically called when one of the :attr:`scroll_x`, :attr:`scroll_y`, :attr:`pos` or :attr:`size` properties change, or if the size of the content changes. ''' if not self._viewport: self.g_translate.xy = self.pos return vp = self._viewport # update from size_hint if vp.size_hint_x is not None: w = vp.size_hint_x * self.width if vp.size_hint_min_x is not None: w = max(w, vp.size_hint_min_x) if vp.size_hint_max_x is not None: w = min(w, vp.size_hint_max_x) vp.width = w if vp.size_hint_y is not None: h = vp.size_hint_y * self.height if vp.size_hint_min_y is not None: h = max(h, vp.size_hint_min_y) if vp.size_hint_max_y is not None: h = min(h, vp.size_hint_max_y) vp.height = h if vp.width > self.width or self.always_overscroll: sw = vp.width - self.width x = self.x - self.scroll_x * sw else: x = self.x if vp.height > self.height or self.always_overscroll: sh = vp.height - self.height y = self.y - self.scroll_y * sh else: y = self.top - vp.height # from 1.8.0, we now use a matrix by default, instead of moving the # widget position behind. We set it here, but it will be a no-op most # of the time. vp.pos = 0, 0 self.g_translate.xy = x, y # New in 1.2.0, show bar when scrolling happens and (changed in 1.9.0) # fade to bar_inactive_color when no scroll is happening. ev = self._bind_inactive_bar_color_ev if ev is None: ev = self._bind_inactive_bar_color_ev = Clock.create_trigger( self._bind_inactive_bar_color, 0.5 ) self.funbind("bar_inactive_color", self._change_bar_color) Animation.stop_all(self, "_bar_color") self.fbind("bar_color", self._change_bar_color) self._bar_color = self.bar_color ev()
def _bind_inactive_bar_color(self, *args): self.funbind("bar_color", self._change_bar_color) self.fbind("bar_inactive_color", self._change_bar_color) Animation( _bar_color=self.bar_inactive_color, d=0.5, t="out_quart" ).start(self) def _change_bar_color(self, inst, value): self._bar_color = value
[docs] def add_widget(self, widget, *args, **kwargs): if self._viewport: raise Exception("ScrollView accept only one widget") canvas = self.canvas self.canvas = self.canvas_viewport super(ScrollView, self).add_widget(widget, *args, **kwargs) self.canvas = canvas self._viewport = widget widget.bind( size=self._trigger_update_from_scroll, size_hint_min=self._trigger_update_from_scroll, ) self._trigger_update_from_scroll()
[docs] def remove_widget(self, widget, *args, **kwargs): canvas = self.canvas self.canvas = self.canvas_viewport super(ScrollView, self).remove_widget(widget, *args, **kwargs) self.canvas = canvas if widget is self._viewport: self._viewport = None
def _on_scroll_pos_changed(self, instance, value): # Called when scroll_x or scroll_y changes. # Dispatches on_scroll_move event to notify listeners of # actual scroll position changes. self.dispatch("on_scroll_move") def _check_position_stable(self, dt): # Check if scroll position has stabilized. Used to dispatch the # on_scroll_stop event. current_pos = (self.scroll_x, self.scroll_y) if self._last_scroll_pos is None: self._last_scroll_pos = current_pos self._stable_frames = 0 return True # Check if position changed pos_changed = not ( isclose(current_pos[0], self._last_scroll_pos[0]) and isclose(current_pos[1], self._last_scroll_pos[1]) ) if not pos_changed: # Position hasn't changed - increment stable counter self._stable_frames += 1 # Require 3 consecutive stable frames before dispatching stop if self._stable_frames >= 3: self.dispatch("on_scroll_stop") # Clean up if self._position_check_ev: self._position_check_ev.cancel() self._position_check_ev = None self._last_scroll_pos = None self._stable_frames = 0 return False else: # Position still changing - reset counter self._last_scroll_pos = current_pos self._stable_frames = 0 return True def _check_velocity_for_stop(self, dt): # Used to dispatch the on_scroll_stop event. # Checks if velocity is zero. After detecting zero velocity, # we verify the position has stabilized before dispatching. # Get current velocities vel_x = self.effect_x.velocity if self.effect_x else 0 vel_y = self.effect_y.velocity if self.effect_y else 0 # Use small threshold for velocity check if isclose(vel_x, 0.0) and isclose(vel_y, 0.0): # Velocity is zero - now check position stability if self._velocity_check_ev: self._velocity_check_ev.cancel() self._velocity_check_ev = None # Start position stability check self._last_scroll_pos = None self._stable_frames = 0 if self._position_check_ev: self._position_check_ev.cancel() self._position_check_ev = Clock.schedule_interval( self._check_position_stable, 0 ) return False return True def _get_uid(self, prefix="sv"): # UNIQUE IDENTIFIER GENERATOR FOR TOUCH.UD KEYS # ============================================== # Generates unique keys for touch.ud (user data) dictionary based on # this ScrollView's unique ID. # # USAGE PATTERNS: # - _get_uid() -> 'sv.123' (primary state key) # - _get_uid('svavoid') -> 'svavoid.123' (avoid processing flag) # # This ensures each ScrollView instance has its own namespace in # touch.ud, preventing conflicts in nested scenarios. return "{0}.{1}".format(prefix, self.uid) def _get_debug_name(self): # Helper method for debug output - identifies ScrollView by scroll axes axes = [] if self.do_scroll_x: axes.append("X") if self.do_scroll_y: axes.append("Y") axis_str = "+".join(axes) if axes else "NONE" return f"SV[{axis_str}:{self.uid}]" def _change_touch_mode(self, *largs): # SCROLL TIMEOUT HANDLER - GESTURE DETECTION TIMEOUT # ================================================== # This method is called when the scroll_timeout expires without the # touch traveling the minimum scroll_distance. It transitions from # scroll detection mode to normal widget interaction mode by handing-off # the touch to child widgets. if not self._touch: return uid = self._get_uid() touch = self._touch if uid not in touch.ud: self._touch = False return ud = touch.ud[uid] # Only proceed if we're still in gesture detection mode if ud["mode"] != ScrollMode.UNKNOWN or ud["scroll_action"]: return # SLOW DEVICE PROTECTION: # On very slow devices, we need at least 3 frames to accumulate # velocity data. Otherwise, scroll effects might not work properly. # This addresses issues #1464 and #1499 for low-performance devices. if self.slow_device_support: diff_frames = Clock.frames - ud["frames"] if diff_frames < 3: Clock.schedule_once(self._change_touch_mode, 0) return # CLEANUP AND HANDOFF: # Cancel any scroll effects that may have started if self.do_scroll_x and self.effect_x: self.effect_x.cancel() if self.do_scroll_y and self.effect_y: self.effect_y.cancel() # Store hierarchy info has_hierarchy = ( "nested" in touch.ud and "hierarchy" in touch.ud["nested"] ) if has_hierarchy: hierarchy = touch.ud["nested"]["hierarchy"] # Cancel any scroll effects that may have started if self.do_scroll_x and self.effect_x: self.effect_x.cancel() if self.do_scroll_y and self.effect_y: self.effect_y.cancel() touch.ungrab(self) self._touch = None # CONTROLLED HANDOFF TO CHILD WIDGETS: # Transform touch coordinates and re-dispatch to children # This allows buttons, sliders, etc. to handle the touch normally # NOTE: Do NOT schedule _do_touch_up here, this is a live touch handoff, # not a synthetic click. The user is still holding their finger down. touch.push() touch.apply_transform_2d(self.to_widget) touch.apply_transform_2d(self.to_parent) child_grabbed = self._simulate_touch_down(touch) touch.pop() if child_grabbed: # A child widget grabbed the touch - hand it off completely uid = self._get_uid() if uid in touch.ud: del touch.ud[uid] # Set flag to prevent re-initialization, this touch belongs to child touch.ud["sv.claimed_by_child"] = True # Clear the nested ScrollView active touch since we're handing off if has_hierarchy: if hierarchy.scrollviews[0]._nested_sv_active_touch is touch: hierarchy.scrollviews[0]._nested_sv_active_touch = None else: # Standalone case: clear our own active touch if self._nested_sv_active_touch is touch: self._nested_sv_active_touch = None else: # No child grabbed it (e.g., user is on empty space) - # transition to SCROLL mode so the user can begin scrolling without # restarting the gesture uid = self._get_uid() if uid in touch.ud: touch.ud[uid]["mode"] = ScrollMode.SCROLL # Dispatch on_scroll_start since we're now ready to scroll self.dispatch("on_scroll_start") # Re-grab since we ungrabbed above touch.grab(self) self._touch = touch def _do_touch_up(self, touch, *largs): # Complete touch lifecycle by sending touch_up to all widgets that # grabbed this touch. # Ensures proper cleanup for buttons/widgets that were touched # during scroll gestures touch is in window coords self._delegate_touch_up_to_children_widget_coords(touch) # don't forget about grab event! for x in touch.grab_list[:]: touch.grab_list.remove(x) x = x() if not x: continue touch.grab_current = x # touch is in window coords self._delegate_touch_up_to_children_widget_coords(touch) touch.grab_current = None
[docs] def on_scroll_start(self): '''Event fired when a scroll gesture starts. This event is dispatched when scrolling begins, regardless of the input method (touch, mouse wheel, or programmatic). It fires once per scroll gesture when the ScrollView determines that scrolling should commence. For touch/content scrolling, this fires after the touch has moved beyond the scroll_distance threshold. For scrollbar interactions, it fires when the scrollbar is initially grabbed. For mouse wheel scrolling, it fires on the first scroll wheel event. .. versionchanged:: 3.0.0 Removed touch parameter. Use on_touch_down/move/up for touch-specific handling. ''' pass
[docs] def on_scroll_move(self): '''Event fired when the scroll position changes. This event is dispatched whenever the scroll_x or scroll_y properties change during an active scroll gesture. It provides a unified way to track scrolling regardless of input method. (touch, mouse wheel, scrollbar, or programmatic scroll_x/scroll_y changes). This event fires continuously during scrolling and is useful for implementing scroll-based animations, progress indicators, or parallax effects. .. versionchanged:: 3.0.0 Removed touch parameter. Use on_touch_down/move/up for touch-specific handling. Now fires for all scroll_x/scroll_y changes including programmatic updates. ''' pass
[docs] def on_scroll_stop(self): '''Event fired when scrolling motion stops. This event is dispatched when the scrolling motion has completely stopped. Fires when both velocity reaches zero and scroll position stabilizes for 3 consecutive frames. This event is useful for triggering actions after scrolling completes, such as loading more content, snapping to grid positions, or updating UI state. .. versionchanged:: 3.0.0 Removed touch parameter. Use on_touch_down/move/up for touch-specific handling. ''' pass
if __name__ == "__main__": from kivy.app import App from kivy.uix.gridlayout import GridLayout from kivy.uix.button import Button class ScrollViewApp(App): def build(self): layout1 = GridLayout(cols=4, spacing=10, size_hint=(None, None)) layout1.bind( minimum_height=layout1.setter("height"), minimum_width=layout1.setter("width"), ) for i in range(40): btn = Button( text=str(i), size_hint=(None, None), size=(200, 100) ) layout1.add_widget(btn) scrollview1 = ScrollView(bar_width="2dp", smooth_scroll_end=10) scrollview1.add_widget(layout1) layout2 = GridLayout(cols=4, spacing=10, size_hint=(None, None)) layout2.bind( minimum_height=layout2.setter("height"), minimum_width=layout2.setter("width"), ) for i in range(40): btn = Button( text=str(i), size_hint=(None, None), size=(200, 100) ) layout2.add_widget(btn) scrollview2 = ScrollView( scroll_type=["bars"], bar_width="9dp", scroll_wheel_distance=100 ) scrollview2.add_widget(layout2) root = GridLayout(cols=2) root.add_widget(scrollview1) root.add_widget(scrollview2) return root ScrollViewApp().run()