comparison cagou/backport/carousel.py @ 323:5bd583d00594

backport: added a new "backport" module for using unreleased code from Kivy: Carousel has been backported from Kivy 2.0, because a couple of bugs hitting Cagou are fixed there (notably https://github.com/kivy/kivy/issues/6370). The issue was specially visible when sliding chat widgets. If a version >= 2.0 of kivy is used, a warning will be displayed to indicated that the backport can be removed.
author Goffi <goffi@goffi.org>
date Fri, 06 Dec 2019 13:23:03 +0100
parents
children
comparison
equal deleted inserted replaced
322:e2b51663d8b8 323:5bd583d00594
1 '''
2 Carousel
3 ========
4
5 .. image:: images/carousel.gif
6 :align: right
7
8 .. versionadded:: 1.4.0
9
10 The :class:`Carousel` widget provides the classic mobile-friendly carousel view
11 where you can swipe between slides.
12 You can add any content to the carousel and have it move horizontally or
13 vertically. The carousel can display pages in a sequence or a loop.
14
15 Example::
16
17 from kivy.app import App
18 from kivy.uix.carousel import Carousel
19 from kivy.uix.image import AsyncImage
20
21
22 class CarouselApp(App):
23 def build(self):
24 carousel = Carousel(direction='right')
25 for i in range(10):
26 src = "http://placehold.it/480x270.png&text=slide-%d&.png" % i
27 image = AsyncImage(source=src, allow_stretch=True)
28 carousel.add_widget(image)
29 return carousel
30
31
32 CarouselApp().run()
33
34
35 Kv Example::
36
37 Carousel:
38 direction: 'right'
39 AsyncImage:
40 source: 'http://placehold.it/480x270.png&text=slide-1.png'
41 AsyncImage:
42 source: 'http://placehold.it/480x270.png&text=slide-2.png'
43 AsyncImage:
44 source: 'http://placehold.it/480x270.png&text=slide-3.png'
45 AsyncImage:
46 source: 'http://placehold.it/480x270.png&text=slide-4.png'
47
48
49 .. versionchanged:: 1.5.0
50 The carousel now supports active children, like the
51 :class:`~kivy.uix.scrollview.ScrollView`. It will detect a swipe gesture
52 according to the :attr:`Carousel.scroll_timeout` and
53 :attr:`Carousel.scroll_distance` properties.
54
55 In addition, the slide container is no longer exposed by the API.
56 The impacted properties are
57 :attr:`Carousel.slides`, :attr:`Carousel.current_slide`,
58 :attr:`Carousel.previous_slide` and :attr:`Carousel.next_slide`.
59
60 '''
61
62 __all__ = ('Carousel', )
63
64 from functools import partial
65 from kivy.clock import Clock
66 from kivy.factory import Factory
67 from kivy.animation import Animation
68 from kivy.uix.stencilview import StencilView
69 from kivy.uix.relativelayout import RelativeLayout
70 from kivy.properties import BooleanProperty, OptionProperty, AliasProperty, \
71 NumericProperty, ListProperty, ObjectProperty, StringProperty
72
73
74 class Carousel(StencilView):
75 '''Carousel class. See module documentation for more information.
76 '''
77
78 slides = ListProperty([])
79 '''List of slides inside the Carousel. The slides are the
80 widgets added to the Carousel using the :attr:`add_widget` method.
81
82 :attr:`slides` is a :class:`~kivy.properties.ListProperty` and is
83 read-only.
84 '''
85
86 def _get_slides_container(self):
87 return [x.parent for x in self.slides]
88
89 slides_container = AliasProperty(_get_slides_container, bind=('slides',))
90
91 direction = OptionProperty('right',
92 options=('right', 'left', 'top', 'bottom'))
93 '''Specifies the direction in which the slides are ordered. This
94 corresponds to the direction from which the user swipes to go from one
95 slide to the next. It
96 can be `right`, `left`, `top`, or `bottom`. For example, with
97 the default value of `right`, the second slide is to the right
98 of the first and the user would swipe from the right towards the
99 left to get to the second slide.
100
101 :attr:`direction` is an :class:`~kivy.properties.OptionProperty` and
102 defaults to 'right'.
103 '''
104
105 min_move = NumericProperty(0.2)
106 '''Defines the minimum distance to be covered before the touch is
107 considered a swipe gesture and the Carousel content changed.
108 This is a expressed as a fraction of the Carousel's width.
109 If the movement doesn't reach this minimum value, the movement is
110 cancelled and the content is restored to its original position.
111
112 :attr:`min_move` is a :class:`~kivy.properties.NumericProperty` and
113 defaults to 0.2.
114 '''
115
116 anim_move_duration = NumericProperty(0.5)
117 '''Defines the duration of the Carousel animation between pages.
118
119 :attr:`anim_move_duration` is a :class:`~kivy.properties.NumericProperty`
120 and defaults to 0.5.
121 '''
122
123 anim_cancel_duration = NumericProperty(0.3)
124 '''Defines the duration of the animation when a swipe movement is not
125 accepted. This is generally when the user does not make a large enough
126 swipe. See :attr:`min_move`.
127
128 :attr:`anim_cancel_duration` is a :class:`~kivy.properties.NumericProperty`
129 and defaults to 0.3.
130 '''
131
132 loop = BooleanProperty(False)
133 '''Allow the Carousel to loop infinitely. If True, when the user tries to
134 swipe beyond last page, it will return to the first. If False, it will
135 remain on the last page.
136
137 :attr:`loop` is a :class:`~kivy.properties.BooleanProperty` and
138 defaults to False.
139 '''
140
141 def _get_index(self):
142 if self.slides:
143 return self._index % len(self.slides)
144 return None
145
146 def _set_index(self, value):
147 if self.slides:
148 self._index = value % len(self.slides)
149 else:
150 self._index = None
151
152 index = AliasProperty(_get_index, _set_index,
153 bind=('_index', 'slides'),
154 cache=True)
155 '''Get/Set the current slide based on the index.
156
157 :attr:`index` is an :class:`~kivy.properties.AliasProperty` and defaults
158 to 0 (the first item).
159 '''
160
161 def _prev_slide(self):
162 slides = self.slides
163 len_slides = len(slides)
164 index = self.index
165 if len_slides < 2: # None, or 1 slide
166 return None
167 if self.loop and index == 0:
168 return slides[-1]
169 if index > 0:
170 return slides[index - 1]
171
172 previous_slide = AliasProperty(_prev_slide,
173 bind=('slides', 'index', 'loop'),
174 cache=True)
175 '''The previous slide in the Carousel. It is None if the current slide is
176 the first slide in the Carousel. This ordering reflects the order in which
177 the slides are added: their presentation varies according to the
178 :attr:`direction` property.
179
180 :attr:`previous_slide` is an :class:`~kivy.properties.AliasProperty`.
181
182 .. versionchanged:: 1.5.0
183 This property no longer exposes the slides container. It returns
184 the widget you have added.
185 '''
186
187 def _curr_slide(self):
188 if len(self.slides):
189 return self.slides[self.index or 0]
190
191 current_slide = AliasProperty(_curr_slide,
192 bind=('slides', 'index'),
193 cache=True)
194 '''The currently shown slide.
195
196 :attr:`current_slide` is an :class:`~kivy.properties.AliasProperty`.
197
198 .. versionchanged:: 1.5.0
199 The property no longer exposes the slides container. It returns
200 the widget you have added.
201 '''
202
203 def _next_slide(self):
204 if len(self.slides) < 2: # None, or 1 slide
205 return None
206 if self.loop and self.index == len(self.slides) - 1:
207 return self.slides[0]
208 if self.index < len(self.slides) - 1:
209 return self.slides[self.index + 1]
210
211 next_slide = AliasProperty(_next_slide,
212 bind=('slides', 'index', 'loop'),
213 cache=True)
214 '''The next slide in the Carousel. It is None if the current slide is
215 the last slide in the Carousel. This ordering reflects the order in which
216 the slides are added: their presentation varies according to the
217 :attr:`direction` property.
218
219 :attr:`next_slide` is an :class:`~kivy.properties.AliasProperty`.
220
221 .. versionchanged:: 1.5.0
222 The property no longer exposes the slides container.
223 It returns the widget you have added.
224 '''
225
226 scroll_timeout = NumericProperty(200)
227 '''Timeout allowed to trigger the :attr:`scroll_distance`, in milliseconds.
228 If the user has not moved :attr:`scroll_distance` within the timeout,
229 no scrolling will occur and the touch event will go to the children.
230
231 :attr:`scroll_timeout` is a :class:`~kivy.properties.NumericProperty` and
232 defaults to 200 (milliseconds)
233
234 .. versionadded:: 1.5.0
235 '''
236
237 scroll_distance = NumericProperty('20dp')
238 '''Distance to move before scrolling the :class:`Carousel` in pixels. As
239 soon as the distance has been traveled, the :class:`Carousel` will start
240 to scroll, and no touch event will go to children.
241 It is advisable that you base this value on the dpi of your target device's
242 screen.
243
244 :attr:`scroll_distance` is a :class:`~kivy.properties.NumericProperty` and
245 defaults to 20dp.
246
247 .. versionadded:: 1.5.0
248 '''
249
250 anim_type = StringProperty('out_quad')
251 '''Type of animation to use while animating to the next/previous slide.
252 This should be the name of an
253 :class:`~kivy.animation.AnimationTransition` function.
254
255 :attr:`anim_type` is a :class:`~kivy.properties.StringProperty` and
256 defaults to 'out_quad'.
257
258 .. versionadded:: 1.8.0
259 '''
260
261 ignore_perpendicular_swipes = BooleanProperty(False)
262 '''Ignore swipes on axis perpendicular to direction.
263
264 :attr:`ignore_perpendicular_swipes` is a
265 :class:`~kivy.properties.BooleanProperty` and defaults to False.
266
267 .. versionadded:: 1.10.0
268 '''
269
270 # private properties, for internal use only ###
271 _index = NumericProperty(0, allownone=True)
272 _prev = ObjectProperty(None, allownone=True)
273 _current = ObjectProperty(None, allownone=True)
274 _next = ObjectProperty(None, allownone=True)
275 _offset = NumericProperty(0)
276 _touch = ObjectProperty(None, allownone=True)
277
278 _change_touch_mode_ev = None
279
280 def __init__(self, **kwargs):
281 self._trigger_position_visible_slides = Clock.create_trigger(
282 self._position_visible_slides, -1)
283 super(Carousel, self).__init__(**kwargs)
284 self._skip_slide = None
285 self.touch_mode_change = False
286 self._prioritize_next = False
287 self.fbind('loop', lambda *args: self._insert_visible_slides())
288
289 def load_slide(self, slide):
290 '''Animate to the slide that is passed as the argument.
291
292 .. versionchanged:: 1.8.0
293 '''
294 slides = self.slides
295 start, stop = slides.index(self.current_slide), slides.index(slide)
296 if start == stop:
297 return
298
299 self._skip_slide = stop
300 if stop > start:
301 self._prioritize_next = True
302 self._insert_visible_slides(_next_slide=slide)
303 self.load_next()
304 else:
305 self._prioritize_next = False
306 self._insert_visible_slides(_prev_slide=slide)
307 self.load_previous()
308
309 def load_previous(self):
310 '''Animate to the previous slide.
311
312 .. versionadded:: 1.7.0
313 '''
314 self.load_next(mode='prev')
315
316 def load_next(self, mode='next'):
317 '''Animate to the next slide.
318
319 .. versionadded:: 1.7.0
320 '''
321 if self.index is not None:
322 w, h = self.size
323 _direction = {
324 'top': -h / 2,
325 'bottom': h / 2,
326 'left': w / 2,
327 'right': -w / 2}
328 _offset = _direction[self.direction]
329 if mode == 'prev':
330 _offset = -_offset
331
332 self._start_animation(min_move=0, offset=_offset)
333
334 def get_slide_container(self, slide):
335 return slide.parent
336
337 @property
338 def _prev_equals_next(self):
339 return self.loop and len(self.slides) == 2
340
341 def _insert_visible_slides(self, _next_slide=None, _prev_slide=None):
342 get_slide_container = self.get_slide_container
343
344 previous_slide = _prev_slide if _prev_slide else self.previous_slide
345 if previous_slide:
346 self._prev = get_slide_container(previous_slide)
347 else:
348 self._prev = None
349
350 current_slide = self.current_slide
351 if current_slide:
352 self._current = get_slide_container(current_slide)
353 else:
354 self._current = None
355
356 next_slide = _next_slide if _next_slide else self.next_slide
357 if next_slide:
358 self._next = get_slide_container(next_slide)
359 else:
360 self._next = None
361
362 if self._prev_equals_next:
363 setattr(self, '_prev' if self._prioritize_next else '_next', None)
364
365 super_remove = super(Carousel, self).remove_widget
366 for container in self.slides_container:
367 super_remove(container)
368
369 if self._prev and self._prev.parent is not self:
370 super(Carousel, self).add_widget(self._prev)
371 if self._next and self._next.parent is not self:
372 super(Carousel, self).add_widget(self._next)
373 if self._current:
374 super(Carousel, self).add_widget(self._current)
375
376 def _position_visible_slides(self, *args):
377 slides, index = self.slides, self.index
378 no_of_slides = len(slides) - 1
379 if not slides:
380 return
381 x, y, width, height = self.x, self.y, self.width, self.height
382 _offset, direction = self._offset, self.direction[0]
383 _prev, _next, _current = self._prev, self._next, self._current
384 get_slide_container = self.get_slide_container
385 last_slide = get_slide_container(slides[-1])
386 first_slide = get_slide_container(slides[0])
387 skip_next = False
388 _loop = self.loop
389
390 if direction in 'rl':
391 xoff = x + _offset
392 x_prev = {'l': xoff + width, 'r': xoff - width}
393 x_next = {'l': xoff - width, 'r': xoff + width}
394 if _prev:
395 _prev.pos = (x_prev[direction], y)
396 elif _loop and _next and index == 0:
397 # if first slide is moving to right with direction set to right
398 # or toward left with direction set to left
399 if ((_offset > 0 and direction == 'r') or
400 (_offset < 0 and direction == 'l')):
401 # put last_slide before first slide
402 last_slide.pos = (x_prev[direction], y)
403 skip_next = True
404 if _current:
405 _current.pos = (xoff, y)
406 if skip_next:
407 return
408 if _next:
409 _next.pos = (x_next[direction], y)
410 elif _loop and _prev and index == no_of_slides:
411 if ((_offset < 0 and direction == 'r') or
412 (_offset > 0 and direction == 'l')):
413 first_slide.pos = (x_next[direction], y)
414 if direction in 'tb':
415 yoff = y + _offset
416 y_prev = {'t': yoff - height, 'b': yoff + height}
417 y_next = {'t': yoff + height, 'b': yoff - height}
418 if _prev:
419 _prev.pos = (x, y_prev[direction])
420 elif _loop and _next and index == 0:
421 if ((_offset > 0 and direction == 't') or
422 (_offset < 0 and direction == 'b')):
423 last_slide.pos = (x, y_prev[direction])
424 skip_next = True
425 if _current:
426 _current.pos = (x, yoff)
427 if skip_next:
428 return
429 if _next:
430 _next.pos = (x, y_next[direction])
431 elif _loop and _prev and index == no_of_slides:
432 if ((_offset < 0 and direction == 't') or
433 (_offset > 0 and direction == 'b')):
434 first_slide.pos = (x, y_next[direction])
435
436 def on_size(self, *args):
437 size = self.size
438 for slide in self.slides_container:
439 slide.size = size
440 self._trigger_position_visible_slides()
441
442 def on_pos(self, *args):
443 self._trigger_position_visible_slides()
444
445 def on_index(self, *args):
446 self._insert_visible_slides()
447 self._trigger_position_visible_slides()
448 self._offset = 0
449
450 def on_slides(self, *args):
451 if self.slides:
452 self.index = self.index % len(self.slides)
453 self._insert_visible_slides()
454 self._trigger_position_visible_slides()
455
456 def on__offset(self, *args):
457 self._trigger_position_visible_slides()
458 # if reached full offset, switch index to next or prev
459 direction = self.direction[0]
460 _offset = self._offset
461 width = self.width
462 height = self.height
463 index = self.index
464 if self._skip_slide is not None or index is None:
465 return
466
467 # Move to next slide?
468 if (direction == 'r' and _offset <= -width) or \
469 (direction == 'l' and _offset >= width) or \
470 (direction == 't' and _offset <= - height) or \
471 (direction == 'b' and _offset >= height):
472 if self.next_slide:
473 self.index += 1
474
475 # Move to previous slide?
476 elif (direction == 'r' and _offset >= width) or \
477 (direction == 'l' and _offset <= -width) or \
478 (direction == 't' and _offset >= height) or \
479 (direction == 'b' and _offset <= -height):
480 if self.previous_slide:
481 self.index -= 1
482
483 elif self._prev_equals_next:
484 new_value = (_offset < 0) is (direction in 'rt')
485 if self._prioritize_next is not new_value:
486 self._prioritize_next = new_value
487 if new_value is (self._next is None):
488 self._prev, self._next = self._next, self._prev
489
490 def _start_animation(self, *args, **kwargs):
491 # compute target offset for ease back, next or prev
492 new_offset = 0
493 direction = kwargs.get('direction', self.direction)[0]
494 is_horizontal = direction in 'rl'
495 extent = self.width if is_horizontal else self.height
496 min_move = kwargs.get('min_move', self.min_move)
497 _offset = kwargs.get('offset', self._offset)
498
499 if _offset < min_move * -extent:
500 new_offset = -extent
501 elif _offset > min_move * extent:
502 new_offset = extent
503
504 # if new_offset is 0, it wasnt enough to go next/prev
505 dur = self.anim_move_duration
506 if new_offset == 0:
507 dur = self.anim_cancel_duration
508
509 # detect edge cases if not looping
510 len_slides = len(self.slides)
511 index = self.index
512 if not self.loop or len_slides == 1:
513 is_first = (index == 0)
514 is_last = (index == len_slides - 1)
515 if direction in 'rt':
516 towards_prev = (new_offset > 0)
517 towards_next = (new_offset < 0)
518 else:
519 towards_prev = (new_offset < 0)
520 towards_next = (new_offset > 0)
521 if (is_first and towards_prev) or (is_last and towards_next):
522 new_offset = 0
523
524 anim = Animation(_offset=new_offset, d=dur, t=self.anim_type)
525 anim.cancel_all(self)
526
527 def _cmp(*l):
528 if self._skip_slide is not None:
529 self.index = self._skip_slide
530 self._skip_slide = None
531
532 anim.bind(on_complete=_cmp)
533 anim.start(self)
534
535 def _get_uid(self, prefix='sv'):
536 return '{0}.{1}'.format(prefix, self.uid)
537
538 def on_touch_down(self, touch):
539 if not self.collide_point(*touch.pos):
540 touch.ud[self._get_uid('cavoid')] = True
541 return
542 if self.disabled:
543 return True
544 if self._touch:
545 return super(Carousel, self).on_touch_down(touch)
546 Animation.cancel_all(self)
547 self._touch = touch
548 uid = self._get_uid()
549 touch.grab(self)
550 touch.ud[uid] = {
551 'mode': 'unknown',
552 'time': touch.time_start}
553 self._change_touch_mode_ev = Clock.schedule_once(
554 self._change_touch_mode, self.scroll_timeout / 1000.)
555 self.touch_mode_change = False
556 return True
557
558 def on_touch_move(self, touch):
559 if not self.touch_mode_change:
560 if self.ignore_perpendicular_swipes and \
561 self.direction in ('top', 'bottom'):
562 if abs(touch.oy - touch.y) < self.scroll_distance:
563 if abs(touch.ox - touch.x) > self.scroll_distance:
564 self._change_touch_mode()
565 self.touch_mode_change = True
566 elif self.ignore_perpendicular_swipes and \
567 self.direction in ('right', 'left'):
568 if abs(touch.ox - touch.x) < self.scroll_distance:
569 if abs(touch.oy - touch.y) > self.scroll_distance:
570 self._change_touch_mode()
571 self.touch_mode_change = True
572
573 if self._get_uid('cavoid') in touch.ud:
574 return
575 if self._touch is not touch:
576 super(Carousel, self).on_touch_move(touch)
577 return self._get_uid() in touch.ud
578 if touch.grab_current is not self:
579 return True
580 ud = touch.ud[self._get_uid()]
581 direction = self.direction[0]
582 if ud['mode'] == 'unknown':
583 if direction in 'rl':
584 distance = abs(touch.ox - touch.x)
585 else:
586 distance = abs(touch.oy - touch.y)
587 if distance > self.scroll_distance:
588 ev = self._change_touch_mode_ev
589 if ev is not None:
590 ev.cancel()
591 ud['mode'] = 'scroll'
592 else:
593 if direction in 'rl':
594 self._offset += touch.dx
595 if direction in 'tb':
596 self._offset += touch.dy
597 return True
598
599 def on_touch_up(self, touch):
600 if self._get_uid('cavoid') in touch.ud:
601 return
602 if self in [x() for x in touch.grab_list]:
603 touch.ungrab(self)
604 self._touch = None
605 ud = touch.ud[self._get_uid()]
606 if ud['mode'] == 'unknown':
607 ev = self._change_touch_mode_ev
608 if ev is not None:
609 ev.cancel()
610 super(Carousel, self).on_touch_down(touch)
611 Clock.schedule_once(partial(self._do_touch_up, touch), .1)
612 else:
613 self._start_animation()
614
615 else:
616 if self._touch is not touch and self.uid not in touch.ud:
617 super(Carousel, self).on_touch_up(touch)
618 return self._get_uid() in touch.ud
619
620 def _do_touch_up(self, touch, *largs):
621 super(Carousel, self).on_touch_up(touch)
622 # don't forget about grab event!
623 for x in touch.grab_list[:]:
624 touch.grab_list.remove(x)
625 x = x()
626 if not x:
627 continue
628 touch.grab_current = x
629 super(Carousel, self).on_touch_up(touch)
630 touch.grab_current = None
631
632 def _change_touch_mode(self, *largs):
633 if not self._touch:
634 return
635 self._start_animation()
636 uid = self._get_uid()
637 touch = self._touch
638 ud = touch.ud[uid]
639 if ud['mode'] == 'unknown':
640 touch.ungrab(self)
641 self._touch = None
642 super(Carousel, self).on_touch_down(touch)
643 return
644
645 def add_widget(self, widget, index=0, canvas=None):
646 container = RelativeLayout(
647 size=self.size, x=self.x - self.width, y=self.y)
648 container.add_widget(widget)
649 super(Carousel, self).add_widget(container, index, canvas)
650 if index != 0:
651 self.slides.insert(index - len(self.slides), widget)
652 else:
653 self.slides.append(widget)
654
655 def remove_widget(self, widget, *args, **kwargs):
656 # XXX be careful, the widget.parent refer to the RelativeLayout
657 # added in add_widget(). But it will break if RelativeLayout
658 # implementation change.
659 # if we passed the real widget
660 slides = self.slides
661 if widget in slides:
662 if self.index >= slides.index(widget):
663 self.index = max(0, self.index - 1)
664 container = widget.parent
665 slides.remove(widget)
666 super(Carousel, self).remove_widget(container)
667 return container.remove_widget(widget, *args, **kwargs)
668 return super(Carousel, self).remove_widget(widget, *args, **kwargs)
669
670 def clear_widgets(self):
671 for slide in self.slides[:]:
672 self.remove_widget(slide)
673 super(Carousel, self).clear_widgets()
674
675
676 if __name__ == '__main__':
677 from kivy.app import App
678
679 class Example1(App):
680
681 def build(self):
682 carousel = Carousel(direction='left',
683 loop=True)
684 for i in range(4):
685 src = "http://placehold.it/480x270.png&text=slide-%d&.png" % i
686 image = Factory.AsyncImage(source=src, allow_stretch=True)
687 carousel.add_widget(image)
688 return carousel
689
690 Example1().run()