/
tab.py
executable file
·534 lines (425 loc) · 16.3 KB
/
tab.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
"""
Tabs
====
Copyright (c) 2019 Ivanov Yuri and KivyMD contributors -
modified this module
Copyright (c) 2015 Kivy Garden
https://github.com/kivy-garden/garden.androidtabs
For suggestions and questions:
<kivydevelopment@gmail.com>
This file is distributed under the terms of the same license,
as the Kivy framework.
`Material Design spec, Tabs <https://material.io/design/components/tabs.html>`_
"""
from kivy.app import App
from kivy.lang import Builder
from kivy.clock import Clock
from kivy.uix.label import Label
from kivy.uix.behaviors import ToggleButtonBehavior
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.anchorlayout import AnchorLayout
from kivy.uix.carousel import Carousel
from kivy.uix.widget import Widget
from kivy.uix.scrollview import ScrollView
from kivy.graphics import Rectangle
from kivy.utils import boundary
from kivy.properties import (
ObjectProperty,
NumericProperty,
VariableListProperty,
StringProperty,
AliasProperty,
BooleanProperty,
BoundedNumericProperty,
)
from kivymd.theming import ThemeManager
from kivymd.theming import ThemableBehavior
from kivymd.icon_definitions import md_icons
from kivymd import fonts_path
class MDTabsException(Exception):
"""The MDTabsException class."""
pass
class MDTabsLabel(ToggleButtonBehavior, Label):
"""MDTabsLabel it represent the label of each tab."""
text_color_normal = VariableListProperty([1, 1, 1, 0.6])
"""Text color of the label when it is not selected."""
text_color_active = VariableListProperty([1])
"""Text color of the label when it is selected."""
tab = ObjectProperty()
tab_bar = ObjectProperty()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.min_space = 0
def on_release(self):
# if the label is selected load the relative tab from carousel
if self.state == "down":
self.tab_bar.parent.carousel.load_slide(self.tab)
def on_texture(self, widget, texture):
# just save the minimum width of the label based of the content
if texture:
self.width = texture.width
self.min_space = self.width
def _trigger_update_tab_indicator(self):
# update the position and size of the indicator
# when the label changes size or position
if self.state == "down":
self.tab_bar.update_indicator(self.x, self.width)
class MDTabsBase(Widget):
"""
MDTabsBase allow you to create a tab.
You must create a new class that inherits from MDTabsBase.
In this way you have total control over the views of your tabbed panel.
"""
text = StringProperty()
"""It will be the label text of the tab."""
icon = StringProperty()
"""It will be the icon of the tab."""
tab_label = ObjectProperty()
"""It is the label object reference of the tab."""
def __init__(self, **kwargs):
self.tab_label = MDTabsLabel(tab=self)
super().__init__(**kwargs)
def on_text(self, widget, text):
# Set the icon
if text in md_icons:
self.tab_label.font_name = fonts_path + "materialdesignicons-webfont.ttf"
self.tab_label.text = md_icons[self.text]
self.tab_label.font_size = "24sp"
# Set the label text
else:
self.tab_label.text = self.text
class MDTabsMain(BoxLayout):
"""
MDTabsMain is just a boxlayout that contain
the carousel. It allows you to have control over the carousel.
"""
pass
class MDTabsCarousel(Carousel):
"""MDTabsCarousel class."""
pass
class MDTabsScrollView(ScrollView):
"""MDTabsScrollView hacked version to fix scroll_x manual setting."""
def goto(self, scroll_x, scroll_y):
"""Update event value along with scroll_*."""
def _update(e, x):
if e:
e.value = (e.max + e.min) * x
if not (scroll_x is None):
self.scroll_x = scroll_x
_update(self.effect_x, scroll_x)
if not (scroll_y is None):
self.scroll_y = scroll_y
_update(self.effect_y, scroll_y)
class MDTabsBar(ThemableBehavior, BoxLayout):
"""
MDTabsBar is just a boxlayout that contain
the scrollview for the tabs.
It is also responsible to resize the tab label when it needed.
"""
target = ObjectProperty(None, allownone=True)
"""
Is the carousel reference of the next tab / slide.
When you go from "Tab A" to "Tab B", "Tab B" will be the
target tab / slide of the carousel.
"""
def get_rect_instruction(self):
for i in self.layout.canvas.after.children:
if isinstance(i, Rectangle):
return i
indicator = AliasProperty(get_rect_instruction, cache=True)
"""Is the Rectangle instruction reference of the tab indicator."""
def get_last_scroll_x(self):
return self.scrollview.scroll_x
last_scroll_x = AliasProperty(get_last_scroll_x, bind=("target",), cache=True)
"""Is the carousel reference of the next tab/slide.
When you go from "Tab A" to "Tab B", "Tab B" will be the
target tab/slide of the carousel."""
def __init__(self, **kwargs):
self._trigger_update_tab_bar = Clock.schedule_once(self._update_tab_bar, 0)
super().__init__(**kwargs)
def _update_tab_bar(self, *args):
# update width of the labels when it is needed
width, tabs = self.scrollview.width, self.layout.children
tabs_widths = [t.min_space for t in tabs if t.min_space]
tabs_space = float(sum(tabs_widths))
if not tabs_space:
return
ratio = width / tabs_space
use_ratio = True in (width / len(tabs) < w for w in tabs_widths)
for t in tabs:
t.width = (
t.min_space
if tabs_space > width
else t.min_space * ratio
if use_ratio is True
else width / len(tabs)
)
def update_indicator(self, x, w):
# update position and size of the indicator
self.indicator.pos = (x, 0)
self.indicator.size = (w, self.indicator.size[1])
def tab_bar_autoscroll(self, target, step):
# automatic scroll animation of the tab bar.
bound_left = self.center_x
bound_right = self.layout.width - bound_left
dt = target.center_x - bound_left
sx, sy = self.scrollview.convert_distance_to_scroll(dt, 0)
# last scroll x of the tab bar
lsx = self.last_scroll_x
# determine scroll direction
scroll_is_late = lsx < sx
# distance to run
dst = abs(lsx - sx) * step
if not dst:
return
if scroll_is_late and target.center_x > bound_left:
x = lsx + dst
elif not scroll_is_late and target.center_x < bound_right:
x = lsx - dst
x = boundary(x, 0.0, 1.0)
self.scrollview.goto(x, None)
def android_animation(self, carousel, offset):
# try to reproduce the android animation effect.
if offset != 0 and abs(offset) < carousel.width:
forward = offset < 0
offset = abs(offset)
step = offset / float(carousel.width)
distance = abs(offset - carousel.width)
threshold = self.parent.anim_threshold
breakpoint = carousel.width - (carousel.width * threshold)
traveled = distance / breakpoint if breakpoint else 0
break_step = 1.0 - traveled
indicator_animation = self.parent.tab_indicator_anim
skip_slide = (
carousel.slides[carousel._skip_slide]
if carousel._skip_slide is not None
else None
)
next_slide = carousel.next_slide if forward else carousel.previous_slide
self.target = skip_slide if skip_slide else next_slide
if not self.target:
return
a = carousel.current_slide.tab_label
b = self.target.tab_label
self.tab_bar_autoscroll(b, step)
if not indicator_animation:
return
if step <= threshold:
if forward:
gap_w = abs((a.x + a.width) - (b.x + b.width))
w_step = a.width + (gap_w * step)
x_step = a.x
else:
gap = abs((a.x - b.x))
x_step = a.x - gap * step
w_step = a.width + gap * step
else:
if forward:
x_step = a.x + abs((a.x - b.x)) * break_step
gap_w = abs((a.x + a.width) - (b.x + b.width))
ind_width = a.width + gap_w * threshold
gap_w = ind_width - b.width
w_step = ind_width - (gap_w * break_step)
else:
x_step = a.x - abs((a.x - b.x)) * threshold
x_step = x_step - abs(x_step - b.x) * break_step
ind_width = (a.x + a.width) - x_step if threshold else a.width
gap_w = ind_width - b.width
w_step = ind_width - (gap_w * break_step)
w_step = w_step if w_step + x_step <= a.x + a.width else ind_width
self.update_indicator(x_step, w_step)
class MDTabs(ThemableBehavior, AnchorLayout):
"""The MDTabs class.
You can use it to create your own custom tabbed panel.
"""
default_tab = NumericProperty(0)
"""Index of the default tab. Default to 0."""
tab_bar_height = NumericProperty("48dp")
"""Height of the tab bar."""
tab_indicator_anim = BooleanProperty(False)
"""Tab indicator animation. Default to True.
If you do not want animation set it to False.
"""
tab_indicator_height = NumericProperty("2dp")
"""Height of the tab indicator."""
anim_duration = NumericProperty(0.2)
"""Duration of the slide animation. Default to 0.2."""
anim_threshold = BoundedNumericProperty(
0.8, min=0.0, max=1.0, errorhandler=lambda x: 0.0 if x < 0.0 else 1.0
)
"""Animation threshold allow you to change
the tab indicator animation effect. Default to 0.8.
"""
def on_carousel_index(self, carousel, index):
# when the index of the carousel change, update
# tab indicator, select the current tab and reset threshold data.
current_tab_label = carousel.current_slide.tab_label
if current_tab_label.state == "normal":
current_tab_label._do_press()
self.tab_bar.update_indicator(current_tab_label.x, current_tab_label.width)
def add_widget(self, widget, index=0, canvas=None):
# You can add only subclass of MDTabsBase.
if len(self.children) >= 2:
# FIXME: the condition always works!
#if not issubclass(widget.__class__, MDTabsBase):
# print(widget, widget.__class__)
# raise MDTabsException("MDTabs accept only subclass of MDTabsBase")
try:
widget.tab_label.tab_bar = self.tab_bar
self.tab_bar.layout.add_widget(widget.tab_label)
self.carousel.add_widget(widget)
return
except AttributeError:
pass
return super().add_widget(widget)
def remove_widget(self, widget):
# You can remove only subclass of MDTabsBase.
if not issubclass(widget.__class__, MDTabsBase):
raise MDTabsException("MDTabs can remove only subclass of MDTabBase")
if widget.parent.parent == self.carousel:
self.tab_bar.layout.remove_widget(widget.tab_label)
self.carousel.remove_widget(widget)
Builder.load_string(
"""
#:import DampedScrollEffect kivy.effects.dampedscroll.DampedScrollEffect
<MDTabsLabel>
size_hint: None, 1
halign: 'center'
padding: '12dp', 0
group: 'tabs'
allow_no_selection: False
text_color_normal:
(0, 0, 0, .5) if app.theme_cls.theme_style is 'Dark' \
else (1, 1, 1, .6)
text_color_active:
(0, 0, 0, .75) if app.theme_cls.theme_style is 'Dark' \
else (1, 1, 1, 1)
color:
self.text_color_active if self.state is 'down' \
else self.text_color_normal
on_x: self._trigger_update_tab_indicator()
on_width: self._trigger_update_tab_indicator()
<MDTabsScrollView>
size_hint: 1, 1
do_scroll_y: False
bar_color: 0, 0, 0, 0
bar_inactive_color: 0, 0, 0, 0
bar_width: 0
effect_cls: DampedScrollEffect
<MDTabs>
carousel: carousel
tab_bar: tab_bar
anchor_y: 'top'
MDTabsMain:
padding: 0, tab_bar.height, 0, 0
MDTabsCarousel:
id: carousel
anim_move_duration: root.anim_duration
on_index: root.on_carousel_index(*args)
on__offset: tab_bar.android_animation(*args)
on_slides: self.index = root.default_tab
on_slides: root.on_carousel_index(self, 0)
MDTabsBar:
id: tab_bar
carousel: carousel
scrollview: scrollview
layout: layout
size_hint: 1, None
height: root.tab_bar_height
canvas:
Color:
rgba: self.theme_cls.primary_color
Rectangle:
pos: self.pos
size: self.size
MDTabsScrollView:
id: scrollview
on_width: tab_bar._trigger_update_tab_bar()
GridLayout:
id: layout
rows: 1
size_hint: None, 1
width: self.minimum_width
on_width: tab_bar._trigger_update_tab_bar()
canvas.after:
Color:
rgba: root.theme_cls.accent_color
Rectangle:
pos: self.pos
size: 0, root.tab_indicator_height
"""
)
demo = """
<Example@BoxLayout>
orientation: 'vertical'
MDToolbar:
title: app.title
md_bg_color: app.theme_cls.primary_color
background_palette: 'Primary'
elevation: 10
left_action_items: [['menu', lambda x: x]]
MDTabs:
id: android_tabs
BoxLayout:
size_hint_y: None
height: dp(58)
spacing: dp(5)
padding: dp(5)
MDCheckbox:
size_hint: None, None
size: dp(48), dp(48)
on_state:
app.switch_tabs_to_text(android_tabs) if self.state == 'down' \
else app.switch_tabs_to_icon(android_tabs)
MDLabel:
theme_text_color: 'Primary'
text: 'Use text tabs'
Widget:
<MyTab>
FloatLayout:
MDLabel:
text: 'Content'
halign: 'center'
theme_text_color: 'Primary'
font_style: 'H6'
"""
if __name__ == "__main__":
from kivy.factory import Factory
class MyTab(BoxLayout, MDTabsBase):
pass
class Example(App):
title = "Example Tabs"
theme_cls = ThemeManager()
theme_cls.primary_palette = "BlueGray"
theme_cls.theme_style = "Dark"
theme_cls.accent_palette = "Gray"
list_name_icons = list(md_icons.keys())[0:15]
def switch_tabs_to_icon(self, istance_android_tabs):
for i, instance_tab in enumerate(
istance_android_tabs.ids.scrollview.children[0].children
):
istance_android_tabs.ids.scrollview.children[0].remove_widget(
instance_tab
)
istance_android_tabs.add_widget(MyTab(text=self.list_name_icons[i]))
def switch_tabs_to_text(self, istance_android_tabs):
for instance_tab in istance_android_tabs.ids.scrollview.children[
0
].children:
for k, v in md_icons.items():
if v == instance_tab.text:
istance_android_tabs.ids.scrollview.children[0].remove_widget(
instance_tab
)
istance_android_tabs.add_widget(
MyTab(text=" ".join(k.split("-")).capitalize())
)
break
def build(self):
Builder.load_string(demo)
screen = Factory.Example()
for name_tab in self.list_name_icons:
tab = MyTab(text=name_tab)
screen.ids.android_tabs.add_widget(tab)
return screen
Example().run()