test-kivy-app/kivy_venv/lib/python3.11/site-packages/kivy/uix/carousel.py

696 lines
24 KiB
Python
Raw Normal View History

2024-09-15 12:12:16 +00:00
'''
Carousel
========
.. image:: images/carousel.gif
:align: right
.. versionadded:: 1.4.0
The :class:`Carousel` widget provides the classic mobile-friendly carousel view
where you can swipe between slides.
You can add any content to the carousel and have it move horizontally or
vertically. The carousel can display pages in a sequence or a loop.
Example::
from kivy.app import App
from kivy.uix.carousel import Carousel
from kivy.uix.image import AsyncImage
class CarouselApp(App):
def build(self):
carousel = Carousel(direction='right')
for i in range(10):
src = "http://placehold.it/480x270.png&text=slide-%d&.png" % i
image = AsyncImage(source=src, fit_mode="contain")
carousel.add_widget(image)
return carousel
CarouselApp().run()
Kv Example::
Carousel:
direction: 'right'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-1.png'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-2.png'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-3.png'
AsyncImage:
source: 'http://placehold.it/480x270.png&text=slide-4.png'
.. versionchanged:: 1.5.0
The carousel now supports active children, like the
:class:`~kivy.uix.scrollview.ScrollView`. It will detect a swipe gesture
according to the :attr:`Carousel.scroll_timeout` and
:attr:`Carousel.scroll_distance` properties.
In addition, the slide container is no longer exposed by the API.
The impacted properties are
:attr:`Carousel.slides`, :attr:`Carousel.current_slide`,
:attr:`Carousel.previous_slide` and :attr:`Carousel.next_slide`.
'''
__all__ = ('Carousel', )
from functools import partial
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.animation import Animation
from kivy.uix.stencilview import StencilView
from kivy.uix.relativelayout import RelativeLayout
from kivy.properties import BooleanProperty, OptionProperty, AliasProperty, \
NumericProperty, ListProperty, ObjectProperty, StringProperty
class Carousel(StencilView):
'''Carousel class. See module documentation for more information.
'''
slides = ListProperty([])
'''List of slides inside the Carousel. The slides are the
widgets added to the Carousel using the :attr:`add_widget` method.
:attr:`slides` is a :class:`~kivy.properties.ListProperty` and is
read-only.
'''
def _get_slides_container(self):
return [x.parent for x in self.slides]
slides_container = AliasProperty(_get_slides_container, bind=('slides',))
direction = OptionProperty('right',
options=('right', 'left', 'top', 'bottom'))
'''Specifies the direction in which the slides are ordered. This
corresponds to the direction from which the user swipes to go from one
slide to the next. It
can be `right`, `left`, `top`, or `bottom`. For example, with
the default value of `right`, the second slide is to the right
of the first and the user would swipe from the right towards the
left to get to the second slide.
:attr:`direction` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'right'.
'''
min_move = NumericProperty(0.2)
'''Defines the minimum distance to be covered before the touch is
considered a swipe gesture and the Carousel content changed.
This is a expressed as a fraction of the Carousel's width.
If the movement doesn't reach this minimum value, the movement is
cancelled and the content is restored to its original position.
:attr:`min_move` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.2.
'''
anim_move_duration = NumericProperty(0.5)
'''Defines the duration of the Carousel animation between pages.
:attr:`anim_move_duration` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.5.
'''
anim_cancel_duration = NumericProperty(0.3)
'''Defines the duration of the animation when a swipe movement is not
accepted. This is generally when the user does not make a large enough
swipe. See :attr:`min_move`.
:attr:`anim_cancel_duration` is a :class:`~kivy.properties.NumericProperty`
and defaults to 0.3.
'''
loop = BooleanProperty(False)
'''Allow the Carousel to loop infinitely. If True, when the user tries to
swipe beyond last page, it will return to the first. If False, it will
remain on the last page.
:attr:`loop` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
def _get_index(self):
if self.slides:
return self._index % len(self.slides)
return None
def _set_index(self, value):
if self.slides:
self._index = value % len(self.slides)
else:
self._index = None
index = AliasProperty(_get_index, _set_index,
bind=('_index', 'slides'),
cache=True)
'''Get/Set the current slide based on the index.
:attr:`index` is an :class:`~kivy.properties.AliasProperty` and defaults
to 0 (the first item).
'''
def _prev_slide(self):
slides = self.slides
len_slides = len(slides)
index = self.index
if len_slides < 2: # None, or 1 slide
return None
if self.loop and index == 0:
return slides[-1]
if index > 0:
return slides[index - 1]
previous_slide = AliasProperty(_prev_slide,
bind=('slides', 'index', 'loop'),
cache=True)
'''The previous slide in the Carousel. It is None if the current slide is
the first slide in the Carousel. This ordering reflects the order in which
the slides are added: their presentation varies according to the
:attr:`direction` property.
:attr:`previous_slide` is an :class:`~kivy.properties.AliasProperty`.
.. versionchanged:: 1.5.0
This property no longer exposes the slides container. It returns
the widget you have added.
'''
def _curr_slide(self):
if len(self.slides):
return self.slides[self.index or 0]
current_slide = AliasProperty(_curr_slide,
bind=('slides', 'index'),
cache=True)
'''The currently shown slide.
:attr:`current_slide` is an :class:`~kivy.properties.AliasProperty`.
.. versionchanged:: 1.5.0
The property no longer exposes the slides container. It returns
the widget you have added.
'''
def _next_slide(self):
if len(self.slides) < 2: # None, or 1 slide
return None
if self.loop and self.index == len(self.slides) - 1:
return self.slides[0]
if self.index < len(self.slides) - 1:
return self.slides[self.index + 1]
next_slide = AliasProperty(_next_slide,
bind=('slides', 'index', 'loop'),
cache=True)
'''The next slide in the Carousel. It is None if the current slide is
the last slide in the Carousel. This ordering reflects the order in which
the slides are added: their presentation varies according to the
:attr:`direction` property.
:attr:`next_slide` is an :class:`~kivy.properties.AliasProperty`.
.. versionchanged:: 1.5.0
The property no longer exposes the slides container.
It returns the widget you have added.
'''
scroll_timeout = NumericProperty(200)
'''Timeout allowed to trigger the :attr:`scroll_distance`, in milliseconds.
If the user has not moved :attr:`scroll_distance` within the timeout,
no scrolling will occur and the touch event will go to the children.
:attr:`scroll_timeout` is a :class:`~kivy.properties.NumericProperty` and
defaults to 200 (milliseconds)
.. versionadded:: 1.5.0
'''
scroll_distance = NumericProperty('20dp')
'''Distance to move before scrolling the :class:`Carousel` in pixels. As
soon as the distance has been traveled, the :class:`Carousel` 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 20dp.
.. versionadded:: 1.5.0
'''
anim_type = StringProperty('out_quad')
'''Type of animation to use while animating to the next/previous slide.
This should be the name of an
:class:`~kivy.animation.AnimationTransition` function.
:attr:`anim_type` is a :class:`~kivy.properties.StringProperty` and
defaults to 'out_quad'.
.. versionadded:: 1.8.0
'''
ignore_perpendicular_swipes = BooleanProperty(False)
'''Ignore swipes on axis perpendicular to direction.
:attr:`ignore_perpendicular_swipes` is a
:class:`~kivy.properties.BooleanProperty` and defaults to False.
.. versionadded:: 1.10.0
'''
# private properties, for internal use only ###
_index = NumericProperty(0, allownone=True)
_prev = ObjectProperty(None, allownone=True)
_current = ObjectProperty(None, allownone=True)
_next = ObjectProperty(None, allownone=True)
_offset = NumericProperty(0)
_touch = ObjectProperty(None, allownone=True)
_change_touch_mode_ev = None
def __init__(self, **kwargs):
self._trigger_position_visible_slides = Clock.create_trigger(
self._position_visible_slides, -1)
super(Carousel, self).__init__(**kwargs)
self._skip_slide = None
self.touch_mode_change = False
self._prioritize_next = False
self.fbind('loop', lambda *args: self._insert_visible_slides())
def load_slide(self, slide):
'''Animate to the slide that is passed as the argument.
.. versionchanged:: 1.8.0
'''
slides = self.slides
start, stop = slides.index(self.current_slide), slides.index(slide)
if start == stop:
return
self._skip_slide = stop
if stop > start:
self._prioritize_next = True
self._insert_visible_slides(_next_slide=slide)
self.load_next()
else:
self._prioritize_next = False
self._insert_visible_slides(_prev_slide=slide)
self.load_previous()
def load_previous(self):
'''Animate to the previous slide.
.. versionadded:: 1.7.0
'''
self.load_next(mode='prev')
def load_next(self, mode='next'):
'''Animate to the next slide.
.. versionadded:: 1.7.0
'''
if self.index is not None:
w, h = self.size
_direction = {
'top': -h / 2,
'bottom': h / 2,
'left': w / 2,
'right': -w / 2}
_offset = _direction[self.direction]
if mode == 'prev':
_offset = -_offset
self._start_animation(min_move=0, offset=_offset)
def get_slide_container(self, slide):
return slide.parent
@property
def _prev_equals_next(self):
return self.loop and len(self.slides) == 2
def _insert_visible_slides(self, _next_slide=None, _prev_slide=None):
get_slide_container = self.get_slide_container
previous_slide = _prev_slide if _prev_slide else self.previous_slide
if previous_slide:
self._prev = get_slide_container(previous_slide)
else:
self._prev = None
current_slide = self.current_slide
if current_slide:
self._current = get_slide_container(current_slide)
else:
self._current = None
next_slide = _next_slide if _next_slide else self.next_slide
if next_slide:
self._next = get_slide_container(next_slide)
else:
self._next = None
if self._prev_equals_next:
setattr(self, '_prev' if self._prioritize_next else '_next', None)
super_remove = super(Carousel, self).remove_widget
for container in self.slides_container:
super_remove(container)
if self._prev and self._prev.parent is not self:
super(Carousel, self).add_widget(self._prev)
if self._next and self._next.parent is not self:
super(Carousel, self).add_widget(self._next)
if self._current:
super(Carousel, self).add_widget(self._current)
def _position_visible_slides(self, *args):
slides, index = self.slides, self.index
no_of_slides = len(slides) - 1
if not slides:
return
x, y, width, height = self.x, self.y, self.width, self.height
_offset, direction = self._offset, self.direction[0]
_prev, _next, _current = self._prev, self._next, self._current
get_slide_container = self.get_slide_container
last_slide = get_slide_container(slides[-1])
first_slide = get_slide_container(slides[0])
skip_next = False
_loop = self.loop
if direction in 'rl':
xoff = x + _offset
x_prev = {'l': xoff + width, 'r': xoff - width}
x_next = {'l': xoff - width, 'r': xoff + width}
if _prev:
_prev.pos = (x_prev[direction], y)
elif _loop and _next and index == 0:
# if first slide is moving to right with direction set to right
# or toward left with direction set to left
if ((_offset > 0 and direction == 'r') or
(_offset < 0 and direction == 'l')):
# put last_slide before first slide
last_slide.pos = (x_prev[direction], y)
skip_next = True
if _current:
_current.pos = (xoff, y)
if skip_next:
return
if _next:
_next.pos = (x_next[direction], y)
elif _loop and _prev and index == no_of_slides:
if ((_offset < 0 and direction == 'r') or
(_offset > 0 and direction == 'l')):
first_slide.pos = (x_next[direction], y)
if direction in 'tb':
yoff = y + _offset
y_prev = {'t': yoff - height, 'b': yoff + height}
y_next = {'t': yoff + height, 'b': yoff - height}
if _prev:
_prev.pos = (x, y_prev[direction])
elif _loop and _next and index == 0:
if ((_offset > 0 and direction == 't') or
(_offset < 0 and direction == 'b')):
last_slide.pos = (x, y_prev[direction])
skip_next = True
if _current:
_current.pos = (x, yoff)
if skip_next:
return
if _next:
_next.pos = (x, y_next[direction])
elif _loop and _prev and index == no_of_slides:
if ((_offset < 0 and direction == 't') or
(_offset > 0 and direction == 'b')):
first_slide.pos = (x, y_next[direction])
def on_size(self, *args):
size = self.size
for slide in self.slides_container:
slide.size = size
self._trigger_position_visible_slides()
def on_pos(self, *args):
self._trigger_position_visible_slides()
def on_index(self, *args):
self._insert_visible_slides()
self._trigger_position_visible_slides()
self._offset = 0
def on_slides(self, *args):
if self.slides:
self.index = self.index % len(self.slides)
self._insert_visible_slides()
self._trigger_position_visible_slides()
def on__offset(self, *args):
self._trigger_position_visible_slides()
# if reached full offset, switch index to next or prev
direction = self.direction[0]
_offset = self._offset
width = self.width
height = self.height
index = self.index
if self._skip_slide is not None or index is None:
return
# Move to next slide?
if (direction == 'r' and _offset <= -width) or \
(direction == 'l' and _offset >= width) or \
(direction == 't' and _offset <= - height) or \
(direction == 'b' and _offset >= height):
if self.next_slide:
self.index += 1
# Move to previous slide?
elif (direction == 'r' and _offset >= width) or \
(direction == 'l' and _offset <= -width) or \
(direction == 't' and _offset >= height) or \
(direction == 'b' and _offset <= -height):
if self.previous_slide:
self.index -= 1
elif self._prev_equals_next:
new_value = (_offset < 0) is (direction in 'rt')
if self._prioritize_next is not new_value:
self._prioritize_next = new_value
if new_value is (self._next is None):
self._prev, self._next = self._next, self._prev
def _start_animation(self, *args, **kwargs):
# compute target offset for ease back, next or prev
new_offset = 0
direction = kwargs.get('direction', self.direction)[0]
is_horizontal = direction in 'rl'
extent = self.width if is_horizontal else self.height
min_move = kwargs.get('min_move', self.min_move)
_offset = kwargs.get('offset', self._offset)
if _offset < min_move * -extent:
new_offset = -extent
elif _offset > min_move * extent:
new_offset = extent
# if new_offset is 0, it wasn't enough to go next/prev
dur = self.anim_move_duration
if new_offset == 0:
dur = self.anim_cancel_duration
# detect edge cases if not looping
len_slides = len(self.slides)
index = self.index
if not self.loop or len_slides == 1:
is_first = (index == 0)
is_last = (index == len_slides - 1)
if direction in 'rt':
towards_prev = (new_offset > 0)
towards_next = (new_offset < 0)
else:
towards_prev = (new_offset < 0)
towards_next = (new_offset > 0)
if (is_first and towards_prev) or (is_last and towards_next):
new_offset = 0
anim = Animation(_offset=new_offset, d=dur, t=self.anim_type)
anim.cancel_all(self)
def _cmp(*l):
if self._skip_slide is not None:
self.index = self._skip_slide
self._skip_slide = None
anim.bind(on_complete=_cmp)
anim.start(self)
def _get_uid(self, prefix='sv'):
return '{0}.{1}'.format(prefix, self.uid)
def on_touch_down(self, touch):
if not self.collide_point(*touch.pos):
touch.ud[self._get_uid('cavoid')] = True
return
if self.disabled:
return True
if self._touch:
return super(Carousel, self).on_touch_down(touch)
Animation.cancel_all(self)
self._touch = touch
uid = self._get_uid()
touch.grab(self)
touch.ud[uid] = {
'mode': 'unknown',
'time': touch.time_start}
self._change_touch_mode_ev = Clock.schedule_once(
self._change_touch_mode, self.scroll_timeout / 1000.)
self.touch_mode_change = False
return True
def on_touch_move(self, touch):
if not self.touch_mode_change:
if self.ignore_perpendicular_swipes and \
self.direction in ('top', 'bottom'):
if abs(touch.oy - touch.y) < self.scroll_distance:
if abs(touch.ox - touch.x) > self.scroll_distance:
self._change_touch_mode()
self.touch_mode_change = True
elif self.ignore_perpendicular_swipes and \
self.direction in ('right', 'left'):
if abs(touch.ox - touch.x) < self.scroll_distance:
if abs(touch.oy - touch.y) > self.scroll_distance:
self._change_touch_mode()
self.touch_mode_change = True
if self._get_uid('cavoid') in touch.ud:
return
if self._touch is not touch:
super(Carousel, self).on_touch_move(touch)
return self._get_uid() in touch.ud
if touch.grab_current is not self:
return True
ud = touch.ud[self._get_uid()]
direction = self.direction[0]
if ud['mode'] == 'unknown':
if direction in 'rl':
distance = abs(touch.ox - touch.x)
else:
distance = abs(touch.oy - touch.y)
if distance > self.scroll_distance:
ev = self._change_touch_mode_ev
if ev is not None:
ev.cancel()
ud['mode'] = 'scroll'
else:
if direction in 'rl':
self._offset += touch.dx
if direction in 'tb':
self._offset += touch.dy
return True
def on_touch_up(self, touch):
if self._get_uid('cavoid') in touch.ud:
return
if self in [x() for x in touch.grab_list]:
touch.ungrab(self)
self._touch = None
ud = touch.ud[self._get_uid()]
if ud['mode'] == 'unknown':
ev = self._change_touch_mode_ev
if ev is not None:
ev.cancel()
super(Carousel, self).on_touch_down(touch)
Clock.schedule_once(partial(self._do_touch_up, touch), .1)
else:
self._start_animation()
else:
if self._touch is not touch and self.uid not in touch.ud:
super(Carousel, self).on_touch_up(touch)
return self._get_uid() in touch.ud
def _do_touch_up(self, touch, *largs):
super(Carousel, self).on_touch_up(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
super(Carousel, self).on_touch_up(touch)
touch.grab_current = None
def _change_touch_mode(self, *largs):
if not self._touch:
return
self._start_animation()
uid = self._get_uid()
touch = self._touch
ud = touch.ud[uid]
if ud['mode'] == 'unknown':
touch.ungrab(self)
self._touch = None
super(Carousel, self).on_touch_down(touch)
return
def add_widget(self, widget, index=0, *args, **kwargs):
container = RelativeLayout(
size=self.size, x=self.x - self.width, y=self.y)
container.add_widget(widget)
super(Carousel, self).add_widget(container, index, *args, **kwargs)
if index != 0:
self.slides.insert(index - len(self.slides), widget)
else:
self.slides.append(widget)
def remove_widget(self, widget, *args, **kwargs):
# XXX be careful, the widget.parent refer to the RelativeLayout
# added in add_widget(). But it will break if RelativeLayout
# implementation change.
# if we passed the real widget
slides = self.slides
if widget in slides:
if self.index >= slides.index(widget):
self.index = max(0, self.index - 1)
container = widget.parent
slides.remove(widget)
super(Carousel, self).remove_widget(container, *args, **kwargs)
container.remove_widget(widget)
return
super(Carousel, self).remove_widget(widget, *args, **kwargs)
def clear_widgets(self, children=None, *args, **kwargs):
# `children` must be a list of slides or None
if children is None:
children = self.slides[:]
remove_widget = self.remove_widget
for widget in children:
remove_widget(widget)
super(Carousel, self).clear_widgets()
if __name__ == '__main__':
from kivy.app import App
class Example1(App):
def build(self):
carousel = Carousel(direction='left',
loop=True)
for i in range(4):
src = "http://placehold.it/480x270.png&text=slide-%d&.png" % i
image = Factory.AsyncImage(source=src, fit_mode="contain")
carousel.add_widget(image)
return carousel
Example1().run()