Sideband/sbapp/kivymd/uix/segmentedbutton/segmentedbutton.py

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."""