''' 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()