654 lines
19 KiB
Python
654 lines
19 KiB
Python
"""
|
|
Components/SegmentedButton
|
|
==========================
|
|
|
|
.. versionadded:: 1.2.0
|
|
|
|
.. seealso::
|
|
|
|
`Material Design spec, Segmented buttons <https://m3.material.io/components/segmented-buttons/overview>`_
|
|
|
|
`Segmented control <https://kivymd.readthedocs.io/en/latest/components/segmentedcontrol/>`_
|
|
|
|
.. rubric:: Segmented buttons help people select options, switch views,
|
|
or sort elements.
|
|
|
|
.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/segmented-button-preview.png
|
|
:align: center
|
|
|
|
Usage
|
|
-----
|
|
|
|
.. code-block:: kv
|
|
|
|
MDScreen:
|
|
|
|
MDSegmentedButton:
|
|
|
|
MDSegmentedButtonItem:
|
|
icon: ...
|
|
text: ...
|
|
|
|
MDSegmentedButtonItem:
|
|
icon: ...
|
|
text: ...
|
|
|
|
MDSegmentedButtonItem:
|
|
icon: ...
|
|
text: ...
|
|
|
|
Example
|
|
-------
|
|
|
|
.. code-block:: python
|
|
|
|
from kivy.lang import Builder
|
|
|
|
from kivymd.app import MDApp
|
|
|
|
KV = '''
|
|
MDScreen:
|
|
|
|
MDSegmentedButton:
|
|
pos_hint: {"center_x": .5, "center_y": .5}
|
|
|
|
MDSegmentedButtonItem:
|
|
text: "Walking"
|
|
|
|
MDSegmentedButtonItem:
|
|
text: "Transit"
|
|
|
|
MDSegmentedButtonItem:
|
|
text: "Driving"
|
|
'''
|
|
|
|
|
|
class Example(MDApp):
|
|
def build(self):
|
|
self.theme_cls.theme_style = "Dark"
|
|
return Builder.load_string(KV)
|
|
|
|
|
|
Example().run()
|
|
|
|
By default, segmented buttons support single marking of elements:
|
|
|
|
.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/segmented-button-multiselect-false.gif
|
|
:align: center
|
|
|
|
For multiple marking of elements, use the
|
|
:attr:`kivymd.uix.segmentedbutton.segmentedbutton.MDSegmentedButton.multiselect`
|
|
parameter:
|
|
|
|
.. code-block:: kv
|
|
|
|
MDSegmentedButton:
|
|
multiselect: True
|
|
|
|
.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/segmented-button-multiselect-true.gif
|
|
:align: center
|
|
|
|
Control width
|
|
-------------
|
|
|
|
The width of the panel of segmented buttons will be equal to the width
|
|
of the texture of the widest button multiplied by the number of buttons:
|
|
|
|
.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/segmented-button-width-by-default.png
|
|
:align: center
|
|
|
|
But you can use the `size_hint_x` parameter to specify the relative width:
|
|
|
|
.. code-block:: kv
|
|
|
|
MDSegmentedButton:
|
|
size_hint_x: .9
|
|
|
|
.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/segmented-button-width-size-hint-x.png
|
|
:align: center
|
|
|
|
Customization
|
|
-------------
|
|
|
|
You can see below in the documentation from which classes the
|
|
:class:`~kivymd.uix.segmentedbutton.segmentedbutton.MDSegmentedButton` and
|
|
:class:`~kivymd.uix.segmentedbutton.segmentedbutton.MDSegmentedButtonItem`
|
|
classes are inherited and use all their attributes such as
|
|
`md_bg_color`, `md_bg_color` etc. for additional customization of segments.
|
|
|
|
Events
|
|
------
|
|
|
|
- on_marked
|
|
The method is called when a segment is marked.
|
|
|
|
- on_unmarked
|
|
The method is called when a segment is unmarked.
|
|
|
|
.. code-block:: kv
|
|
|
|
MDSegmentedButton:
|
|
on_marked: app.on_marked(*args)
|
|
|
|
.. code-block:: python
|
|
|
|
def on_marked(
|
|
self,
|
|
segment_button: MDSegmentedButton,
|
|
segment_item: MDSegmentedButtonItem,
|
|
marked: bool,
|
|
) -> None:
|
|
print(segment_button)
|
|
print(segment_item)
|
|
print(marked)
|
|
|
|
A practical example
|
|
-------------------
|
|
|
|
.. code-block:: python
|
|
|
|
import os
|
|
|
|
from faker import Faker
|
|
|
|
from kivy.clock import Clock
|
|
from kivy.lang import Builder
|
|
from kivy.properties import StringProperty
|
|
|
|
from kivymd.app import MDApp
|
|
from kivymd.uix.boxlayout import MDBoxLayout
|
|
from kivymd.uix.segmentedbutton import MDSegmentedButton, MDSegmentedButtonItem
|
|
from kivymd.utils import asynckivy
|
|
|
|
KV = '''
|
|
<UserCard>
|
|
adaptive_height: True
|
|
md_bg_color: "#343930"
|
|
radius: 16
|
|
|
|
TwoLineAvatarListItem:
|
|
id: item
|
|
divider: None
|
|
_no_ripple_effect: True
|
|
text: root.name
|
|
secondary_text: root.path_to_file
|
|
theme_text_color: "Custom"
|
|
text_color: "#8A8D79"
|
|
secondary_theme_text_color: self.theme_text_color
|
|
secondary_text_color: self.text_color
|
|
on_size:
|
|
self.ids._left_container.size = (item.height, item.height)
|
|
self.ids._left_container.x = dp(6)
|
|
self._txt_right_pad = item.height + dp(12)
|
|
|
|
ImageLeftWidget:
|
|
source: root.album
|
|
radius: root.radius
|
|
|
|
|
|
MDScreen:
|
|
md_bg_color: "#151514"
|
|
|
|
MDBoxLayout:
|
|
orientation: "vertical"
|
|
padding: "12dp"
|
|
spacing: "12dp"
|
|
|
|
MDLabel:
|
|
adaptive_height: True
|
|
text: "Your downloads"
|
|
font_style: "H5"
|
|
theme_text_color: "Custom"
|
|
text_color: "#8A8D79"
|
|
|
|
MDSegmentedButton:
|
|
size_hint_x: 1
|
|
selected_color: "#303A29"
|
|
line_color: "#343930"
|
|
on_marked: app.on_marked(*args)
|
|
|
|
MDSegmentedButtonItem:
|
|
text: "Songs"
|
|
active: True
|
|
|
|
MDSegmentedButtonItem:
|
|
text: "Albums"
|
|
|
|
MDSegmentedButtonItem:
|
|
text: "Podcasts"
|
|
|
|
RecycleView:
|
|
id: card_list
|
|
viewclass: "UserCard"
|
|
bar_width: 0
|
|
|
|
RecycleBoxLayout:
|
|
orientation: 'vertical'
|
|
spacing: "16dp"
|
|
padding: "16dp"
|
|
default_size: None, dp(72)
|
|
default_size_hint: 1, None
|
|
size_hint_y: None
|
|
height: self.minimum_height
|
|
'''
|
|
|
|
|
|
class UserCard(MDBoxLayout):
|
|
name = StringProperty()
|
|
path_to_file = StringProperty()
|
|
album = StringProperty()
|
|
|
|
|
|
class Example(MDApp):
|
|
def build(self):
|
|
self.theme_cls.theme_style = "Dark"
|
|
return Builder.load_string(KV)
|
|
|
|
def on_marked(
|
|
self,
|
|
segment_button: MDSegmentedButton,
|
|
segment_item: MDSegmentedButtonItem,
|
|
marked: bool,
|
|
) -> None:
|
|
self.generate_card()
|
|
|
|
def generate_card(self):
|
|
async def generate_card():
|
|
for i in range(10):
|
|
await asynckivy.sleep(0)
|
|
self.root.ids.card_list.data.append(
|
|
{
|
|
"name": fake.name(),
|
|
"path_to_file": f"{os.path.splitext(fake.file_path())[0]}.mp3",
|
|
"album": fake.image_url(),
|
|
}
|
|
)
|
|
|
|
fake = Faker()
|
|
self.root.ids.card_list.data = []
|
|
Clock.schedule_once(lambda x: asynckivy.start(generate_card()))
|
|
|
|
|
|
Example().run()
|
|
|
|
.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/segmented-button-practical-example.gif
|
|
:align: center
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
__all__ = ("MDSegmentedButton", "MDSegmentedButtonItem")
|
|
|
|
import os
|
|
|
|
from kivy.animation import Animation
|
|
from kivy.clock import Clock
|
|
from kivy.lang import Builder
|
|
from kivy.metrics import dp
|
|
from kivy.properties import (
|
|
BooleanProperty,
|
|
ColorProperty,
|
|
ListProperty,
|
|
NumericProperty,
|
|
StringProperty,
|
|
VariableListProperty,
|
|
)
|
|
from kivy.uix.behaviors import ButtonBehavior
|
|
|
|
from kivymd import uix_path
|
|
from kivymd.uix.behaviors import RectangularRippleBehavior, ScaleBehavior
|
|
from kivymd.uix.boxlayout import MDBoxLayout
|
|
from kivymd.uix.floatlayout import MDFloatLayout
|
|
from kivymd.uix.label import MDIcon
|
|
|
|
with open(
|
|
os.path.join(uix_path, "segmentedbutton", "segmentedbutton.kv"),
|
|
encoding="utf-8",
|
|
) as kv_file:
|
|
Builder.load_string(kv_file.read())
|
|
|
|
|
|
class MDSegmentedButtonItem(
|
|
RectangularRippleBehavior, ButtonBehavior, MDFloatLayout
|
|
):
|
|
"""
|
|
Segment button item.
|
|
|
|
For more information, see in the
|
|
:class:`~kivymd.uix.behaviors.RectangularRippleBehavior` and
|
|
:class:`~kivy.uix.behaviors.ButtonBehavior` and
|
|
:class:`~kivymd.uix.boxlayout.MDBoxLayout`
|
|
class documentation.
|
|
"""
|
|
|
|
icon = StringProperty()
|
|
"""
|
|
Icon segment.
|
|
|
|
:attr:`icon` is an :class:`~kivy.properties.StringProperty`
|
|
and defaults to `''`.
|
|
"""
|
|
|
|
text = StringProperty()
|
|
"""
|
|
Text segment.
|
|
|
|
:attr:`text` is an :class:`~kivy.properties.StringProperty`
|
|
and defaults to `''`.
|
|
"""
|
|
|
|
active = BooleanProperty(False)
|
|
"""
|
|
Background color of an disabled segment.
|
|
|
|
:attr:`active` is an :class:`~kivy.properties.BooleanProperty`
|
|
and defaults to `False`.
|
|
"""
|
|
|
|
disabled_color = ColorProperty(None)
|
|
"""
|
|
Is active segment.
|
|
|
|
:attr:`active` is an :class:`~kivy.properties.ColorProperty`
|
|
and defaults to `None`.
|
|
"""
|
|
|
|
_no_ripple_effect = BooleanProperty(True)
|
|
_current_icon = ""
|
|
_current_md_bg_color = None
|
|
|
|
def on_disabled(self, instance, value: bool) -> None:
|
|
def on_disabled(*args):
|
|
if value:
|
|
if not self._current_md_bg_color:
|
|
self._current_md_bg_color = self.md_bg_color
|
|
self.md_bg_color = (
|
|
self.theme_cls.disabled_hint_text_color
|
|
if not self.disabled_color
|
|
else self.disabled_color
|
|
)
|
|
else:
|
|
if self._current_md_bg_color:
|
|
self.md_bg_color = self._current_md_bg_color
|
|
self._current_md_bg_color = None
|
|
|
|
Clock.schedule_once(on_disabled)
|
|
|
|
def on_icon(self, instance, icon_name: str):
|
|
if icon_name != "check":
|
|
self._current_icon = icon_name
|
|
|
|
|
|
# TODO:
|
|
# Add the feature to use both text and icons in segments -
|
|
# https://m3.material.io/components/segmented-buttons/guidelines#26abac1c-c6bd-44c1-a969-8c910c880b98
|
|
# Icons: optional check icon to indicate selected state -
|
|
# https://m3.material.io/components/segmented-buttons/overview#7b80f313-7d3a-4865-b26c-1f7ec98ba694
|
|
# Hovered: add a color for the hovered segment -
|
|
# https://m3.material.io/components/segmented-buttons/specs#d730b3ba-c59e-4ef8-b652-20979fe20b67
|
|
# Density: Each step down in density removes 4dp from the height -
|
|
# https://m3.material.io/components/segmented-buttons/specs#2d5cab36-1deb-40bd-9e37-bc2bb1657009
|
|
|
|
|
|
class MDSegmentedButton(MDBoxLayout):
|
|
"""
|
|
Segment button panel.
|
|
|
|
For more information, see in the
|
|
:class:`~kivymd.uix.boxlayout.MDBoxLayout` class documentation.
|
|
|
|
:Events:
|
|
`on_marked`
|
|
The method is called when a segment is marked.
|
|
`on_unmarked`
|
|
The method is called when a segment is unmarked.
|
|
"""
|
|
|
|
radius = VariableListProperty([20], length=4)
|
|
"""
|
|
Panel radius.
|
|
|
|
:attr:`radius` is an :class:`~kivy.properties.VariableListProperty`
|
|
and defaults to `[20, 20, 20, 20]`.
|
|
"""
|
|
|
|
multiselect = BooleanProperty(False)
|
|
"""
|
|
Do I allow multiple segment selection.
|
|
|
|
:attr:`multiselect` is an :class:`~kivy.properties.BooleanProperty`
|
|
and defaults to `False`.
|
|
"""
|
|
|
|
hiding_icon_transition = StringProperty("linear")
|
|
"""
|
|
Name of the transition hiding the current icon.
|
|
|
|
:attr:`hiding_icon_transition` is a :class:`~kivy.properties.StringProperty`
|
|
and defaults to `'linear'`.
|
|
"""
|
|
|
|
hiding_icon_duration = NumericProperty(0.05)
|
|
"""
|
|
Duration of hiding the current icon.
|
|
|
|
:attr:`hiding_icon_duration` is a :class:`~kivy.properties.NumericProperty`
|
|
and defaults to `0.05`.
|
|
"""
|
|
|
|
opening_icon_transition = StringProperty("linear")
|
|
"""
|
|
The name of the transition that opens a new icon of the "marked" type.
|
|
|
|
:attr:`opening_icon_transition` is a :class:`~kivy.properties.StringProperty`
|
|
and defaults to `'linear'`.
|
|
"""
|
|
|
|
opening_icon_duration = NumericProperty(0.05)
|
|
"""
|
|
The duration of opening a new icon of the "marked" type.
|
|
|
|
:attr:`opening_icon_duration` is a :class:`~kivy.properties.NumericProperty`
|
|
and defaults to `0.05`.
|
|
"""
|
|
|
|
selected_items = ListProperty()
|
|
"""
|
|
The list of :class:`~MDSegmentedButtonItem` objects that are currently
|
|
marked.
|
|
|
|
:attr:`selected_items` is a :class:`~kivy.properties.ListProperty`
|
|
and defaults to `[]`.
|
|
"""
|
|
|
|
selected_color = ColorProperty(None)
|
|
"""
|
|
Color of the marked segment.
|
|
|
|
:attr:`selected_color` is a :class:`~kivy.properties.ColorProperty`
|
|
and defaults to `None`.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.register_event_type("on_marked")
|
|
self.register_event_type("on_unmarked")
|
|
Clock.schedule_once(self.mark_segment)
|
|
Clock.schedule_once(self.adjust_segment_radius)
|
|
Clock.schedule_once(self.adjust_segment_panel_width, 2)
|
|
|
|
def mark_segment(self, *args) -> None:
|
|
"""Programmatically marks a segment."""
|
|
|
|
for widget in self.children:
|
|
if widget.active:
|
|
widget.active = False
|
|
widget.dispatch("on_release")
|
|
|
|
if not self.multiselect:
|
|
break
|
|
|
|
def adjust_segment_radius(self, *args) -> None:
|
|
"""Rounds off the first and last elements."""
|
|
|
|
if self.children[0].radius == [0, 0, 0, 0]:
|
|
self.children[0].radius = (0, self.height / 2, self.height / 2, 0)
|
|
if self.children[-1].radius == [0, 0, 0, 0]:
|
|
self.children[-1].radius = (self.height / 2, 0, 0, self.height / 2)
|
|
|
|
def adjust_segment_panel_width(self, *args) -> None:
|
|
"""
|
|
Sets the width of all segments and the width of the panel
|
|
by the widest segment.
|
|
"""
|
|
|
|
if not self.size_hint_x:
|
|
width_list = [
|
|
widget.ids.label_text.texture_size[0]
|
|
+ (dp(72) if widget.icon else dp(48))
|
|
for widget in self.children
|
|
]
|
|
max_width = max(width_list)
|
|
self.width = max_width * len(width_list)
|
|
else:
|
|
max_width = self.width / len(self.children)
|
|
|
|
for widget in self.children:
|
|
widget.width = max_width
|
|
|
|
self.opacity = 1
|
|
|
|
for widget in self.children:
|
|
if widget.active:
|
|
widget.dispatch("on_release")
|
|
|
|
def shift_segment_text(self, segment_item: MDSegmentedButtonItem) -> None:
|
|
"""
|
|
Shifts the segment text to the right, thus freeing up space
|
|
for the icon (when the segment is marked).
|
|
"""
|
|
|
|
Animation(
|
|
x=(
|
|
segment_item.ids.label_text.x
|
|
+ (
|
|
dp(16)
|
|
if not segment_item.icon and not segment_item.active
|
|
else 0
|
|
)
|
|
)
|
|
if not segment_item.active
|
|
else (
|
|
segment_item.ids.label_text.x
|
|
- (
|
|
dp(16)
|
|
if not segment_item.icon and segment_item.active
|
|
else 0
|
|
)
|
|
),
|
|
d=0.2,
|
|
).start(segment_item.ids.label_text)
|
|
|
|
def show_icon_marked_segment(
|
|
self, segment_item: MDSegmentedButtonItem
|
|
) -> None:
|
|
"""
|
|
Sets the icon for the marked segment and changes the icon scale
|
|
to the normal scale.
|
|
"""
|
|
|
|
segment_item.ids.scale_icon.icon = "check"
|
|
if segment_item.ids.scale_icon.icon == "check" and segment_item.active:
|
|
segment_item.ids.scale_icon.icon = segment_item._current_icon
|
|
|
|
Animation(
|
|
scale_value_x=1,
|
|
scale_value_y=1,
|
|
d=self.opening_icon_duration,
|
|
t=self.opening_icon_transition,
|
|
).start(segment_item.ids.scale_icon)
|
|
|
|
self.shift_segment_text(segment_item)
|
|
self.set_selected_segment_list(segment_item)
|
|
self.set_bg_marked_segment(segment_item)
|
|
|
|
def hide_icon_marked_segment(
|
|
self, segment_item: MDSegmentedButtonItem
|
|
) -> None:
|
|
"""Changes the scale of the icon of the marked segment to zero."""
|
|
|
|
anim = Animation(
|
|
scale_value_x=0,
|
|
scale_value_y=0,
|
|
d=self.hiding_icon_duration,
|
|
t=self.hiding_icon_transition,
|
|
)
|
|
anim.bind(
|
|
on_complete=lambda x, y: self.show_icon_marked_segment(segment_item)
|
|
)
|
|
anim.start(segment_item.ids.scale_icon)
|
|
|
|
def restore_bg_segment(self, segment_item) -> None:
|
|
Animation(md_bg_color=self.md_bg_color, d=0.2).start(segment_item)
|
|
|
|
def set_bg_marked_segment(self, segment_item) -> None:
|
|
if segment_item.active:
|
|
Animation(
|
|
md_bg_color=self.selected_color
|
|
if self.selected_color
|
|
else self.theme_cls.primary_color,
|
|
d=0.2,
|
|
).start(segment_item)
|
|
|
|
def set_selected_segment_list(self, segment_item) -> None:
|
|
segment_item.active = not segment_item.active
|
|
|
|
if segment_item.active:
|
|
self.selected_items.append(segment_item)
|
|
self.dispatch("on_marked", segment_item, segment_item.active)
|
|
else:
|
|
if segment_item in self.selected_items:
|
|
self.selected_items.remove(segment_item)
|
|
self.dispatch("on_unmarked", segment_item, segment_item.active)
|
|
|
|
def mark_item(self, segment_item: MDSegmentedButtonItem) -> None:
|
|
if segment_item.active and not self.multiselect:
|
|
return
|
|
if not self.multiselect and self.selected_items:
|
|
self.uncheck_item()
|
|
else:
|
|
if segment_item.active:
|
|
self.restore_bg_segment(segment_item)
|
|
|
|
self.hide_icon_marked_segment(segment_item)
|
|
|
|
def uncheck_item(self) -> None:
|
|
for item in self.children:
|
|
if item.active:
|
|
self.hide_icon_marked_segment(item)
|
|
self.restore_bg_segment(item)
|
|
break
|
|
|
|
def add_widget(self, widget, *args, **kwargs):
|
|
if isinstance(widget, MDSegmentedButtonItem):
|
|
widget.bind(on_release=self.mark_item)
|
|
return super().add_widget(widget)
|
|
|
|
def on_size(self, instance_segment_button, size: list) -> None:
|
|
"""Called when the root screen is resized."""
|
|
|
|
if self.size_hint_x:
|
|
max_width = size[0] / len(self.children)
|
|
for widget in self.children:
|
|
widget.width = max_width
|
|
|
|
def on_marked(self, *args):
|
|
"""The method is called when a segment is marked."""
|
|
|
|
def on_unmarked(self, *args):
|
|
"""The method is called when a segment is unmarked."""
|
|
|
|
|
|
class SegmentButtonIcon(MDIcon, ScaleBehavior):
|
|
"""Implements an icon with scaling behavior."""
|