From 17c7ab57560c3c14608ff4a89feea5937a7615ba Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Thu, 16 Jan 2025 02:13:50 +0530 Subject: [PATCH] `uix`: new search widget --- .gitignore | 1 + examples/search.py | 112 ++++++++ kivymd/factory_registers.py | 9 + kivymd/uix/list/list.kv | 13 +- kivymd/uix/search/__init__.py | 1 + kivymd/uix/search/search.kv | 74 +++++ kivymd/uix/search/search.py | 516 ++++++++++++++++++++++++++++++++++ kivymd/utils/__init__.py | 8 + 8 files changed, 722 insertions(+), 12 deletions(-) create mode 100644 examples/search.py create mode 100644 kivymd/uix/search/__init__.py create mode 100644 kivymd/uix/search/search.kv create mode 100644 kivymd/uix/search/search.py diff --git a/.gitignore b/.gitignore index 4bb3a3ad7..59d261052 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ temp /kivymd/tools/release/*.zip /kivymd/tools/release/temp .idea/ +docs/sources diff --git a/examples/search.py b/examples/search.py new file mode 100644 index 000000000..00d17a231 --- /dev/null +++ b/examples/search.py @@ -0,0 +1,112 @@ +from kivy.lang import Builder +from kivymd.app import MDApp +from kivymd.uix.list import MDListItem +from examples.common_app import CommonApp +from kivy.properties import StringProperty +from kivymd.icon_definitions import md_icons + +class IconItem(MDListItem): + icon = StringProperty() + text = StringProperty() + +MAIN_KV = """ +#: import images_path kivymd.images_path + + + theme_bg_color:"Custom" + md_bg_color:[0,0,0,0] + MDListItemLeadingIcon: + icon: root.icon + + MDListItemSupportingText: + text: root.text + +MDScreen: + md_bg_color:app.theme_cls.backgroundColor + BoxLayout: + padding:[dp(10), dp(30), dp(10), dp(10)] + orientation:"vertical" + + MDSearchBar: + id: search_bar + supporting_text: "Search in text" + view_root: root + on_text: app.set_list_md_icons(text=args[-1], search=True) + + # Search Bar items + MDSearchBarLeadingContainer: + MDSearchLeadingIcon: + icon: "menu" + on_release: app.open_menu(self) + + MDSearchBarTrailingContainer: + MDSearchTrailingIcon: + icon:"microphone" + MDSearchTrailingAvatar: + source:f"{images_path}/logo/kivymd-icon-128.png" + + # Search View + MDSearchViewLeadingContainer: + MDSearchLeadingIcon: + icon:"arrow-left" + on_release: search_bar.close_view() + + MDSearchViewTrailingContainer: + MDSearchTrailingIcon: + icon:"window-close" + on_release: search_bar.text = "" + + MDSearchViewContainer: + RecycleView: + id: rv + key_viewclass: 'viewclass' + key_size: 'height' + + RecycleBoxLayout: + default_size: None, dp(48) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + Widget: + + BoxLayout: + size_hint_y:None + height:dp(30) + padding:[dp(50), 0] + spacing:dp(10) + MDLabel: + text:"Bar dock" + halign:"right" + MDSwitch: + on_active:search_bar.docked = args[-1] +""" + +class Example(MDApp, CommonApp): + + def build(self): + return Builder.load_string(MAIN_KV) + + def on_start(self): + self.set_list_md_icons() + + def set_list_md_icons(self, text="", search=False): + def add_icon_item(name_icon): + self.root.ids.rv.data.append( + { + "viewclass": "IconItem", + "icon": name_icon, + "text": name_icon, + "callback": lambda x: x, + } + ) + + self.root.ids.rv.data = [] + for name_icon in md_icons.keys(): + if search: + if text in name_icon: + add_icon_item(name_icon) + else: + add_icon_item(name_icon) + +Example().run() diff --git a/kivymd/factory_registers.py b/kivymd/factory_registers.py index 50fcd002c..3c473b47c 100644 --- a/kivymd/factory_registers.py +++ b/kivymd/factory_registers.py @@ -132,3 +132,12 @@ register("MDCircularLayout", module="kivymd.uix.circularlayout") register("MDHeroFrom", module="kivymd.uix.hero") register("MDHeroTo", module="kivymd.uix.hero") +register("MDSearchBar", module="kivymd.uix.search") +register("MDSearchTrailingAvatar", module="kivymd.uix.search") +register("MDSearchTrailingIcon", module="kivymd.uix.search") +register("MDSearchLeadingIcon", module="kivymd.uix.search") +register("MDSearchViewContainer", module="kivymd.uix.search") +register("MDSearchBarLeadingContainer", module="kivymd.uix.search") +register("MDSearchBarTrailingContainer", module="kivymd.uix.search") +register("MDSearchViewLeadingContainer", module="kivymd.uix.search") +register("MDSearchViewTrailingContainer", module="kivymd.uix.search") diff --git a/kivymd/uix/list/list.kv b/kivymd/uix/list/list.kv index 2682a454a..5d2c62959 100644 --- a/kivymd/uix/list/list.kv +++ b/kivymd/uix/list/list.kv @@ -8,18 +8,7 @@ # Divider. canvas.after: Color: - rgba: - ( \ - ( \ - self.theme_cls.surfaceVariantColor \ - if not self.disabled else \ - self.theme_cls.onSurfaceColor \ - ) \ - if self.theme_divider_color == "Primary" else \ - self.divider_color - ) \ - - if self.divider else self.theme_cls.transparentColor + rgba:self.theme_cls.transparentColor Line: width: 1 points: self.x ,self.y, self.x + self.width, self.y diff --git a/kivymd/uix/search/__init__.py b/kivymd/uix/search/__init__.py new file mode 100644 index 000000000..114d7eee5 --- /dev/null +++ b/kivymd/uix/search/__init__.py @@ -0,0 +1 @@ +from .search import * diff --git a/kivymd/uix/search/search.kv b/kivymd/uix/search/search.kv new file mode 100644 index 000000000..29c312ac8 --- /dev/null +++ b/kivymd/uix/search/search.kv @@ -0,0 +1,74 @@ +: + size_hint_x: None + width: dp(30) + +: + size_hint: [None, 1] + width: dp(24) + icon_color: app.theme_cls.onSurfaceColor + +: + size_hint: [None, 1] + width: dp(24) + icon_color: app.theme_cls.onSurfaceColor + +: + size_hint_x: None + width: self.minimum_width + spacing: dp(16) + +: + size_hint_x: None + width: self.minimum_width + spacing: dp(16) + +: + size_hint_x: None + width: self.minimum_width + spacing: dp(16) + +: + size_hint_x: None + width: self.minimum_width + spacing: dp(16) + +: + size_hint_y:None + height:dp(55) + canvas: + Color: + rgba: app.theme_cls.outlineColor + Line: + points: + [[self.x,self.y+self.height], + [self.x+self.width, self.y+self.height]] + +: + size_hint:[1,1] + MDBoxLayout: + id: root_container + orientation: 'vertical' + md_bg_color: app.theme_cls.surfaceContainerHighColor + size_hint: [None, None] + orientation: 'vertical' + # header + BoxLayout: + id: header + padding: [dp(16), 0] + spacing: dp(16) + size_hint_y: None + height: dp(56) + TextInput: + id: text_input + background_color:[0,0,0,0] + foreground_color: app.theme_cls.onSurfaceColor + cursor_color:app.theme_cls.outlineColor + hint_text_color: app.theme_cls.onSurfaceVariantColor + padding: [0, (self.parent.height - self.font_size - dp(3)) / 2] + multiline: False + font_size: root._font_style["font-size"] + on_focus: if args[-1]: root.switch_state("open") + on_text: root.root.text = args[-1] +: + size_hint_y: None + height: dp(56) diff --git a/kivymd/uix/search/search.py b/kivymd/uix/search/search.py new file mode 100644 index 000000000..4b03aa58c --- /dev/null +++ b/kivymd/uix/search/search.py @@ -0,0 +1,516 @@ +""" +Components/Search +================= + +.. seealso:: + + `Material Design spec, Search `_ + +.. rubric:: Search allows users to enter a keyword or phrase to get relevant information. + +.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/search-preview.png + :align: center + +Usage +----- + +.. code-block:: python + +.. code-block:: kv + + MDSearchBar: + id: search_bar + supporting_text: "Search in text" + view_root: root + + # Search Bar + MDSearchBarLeadingContainer: + MDSearchLeadingIcon: + icon: "menu" + on_release: app.open_menu(self) + + MDSearchBarTrailingContainer: + MDSearchTrailingIcon: + icon:"microphone" + MDSearchTrailingAvatar: + source:f"{images_path}/logo/kivymd-icon-128.png" + + # Search View + MDSearchViewLeadingContainer: + MDSearchLeadingIcon: + icon:"arrow-left" + on_release: search_bar.close_view() + + MDSearchViewTrailingContainer: + MDSearchTrailingIcon: + icon:"window-close" + + MDSearchViewContainer: + ... + +Anatomy +------- + +.. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/search-anatomy.png + :align: center +""" + +from __future__ import annotations + +__all__ = ( + "MDSearchBar", + "MDSearchTrailingAvatar", + "MDSearchTrailingIcon", + "MDSearchLeadingIcon", + "MDSearchViewContainer", + "MDSearchBarLeadingContainer", + "MDSearchBarTrailingContainer", + "MDSearchViewLeadingContainer", + "MDSearchViewTrailingContainer", +) + +import os + +from kivy.animation import Animation +from kivy.clock import Clock +from kivy.core.window import Window +from kivy.lang import Builder +from kivy.metrics import dp +from kivy.properties import ( + ColorProperty, + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, + BooleanProperty, +) +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.image import Image +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.widget import Widget + +from kivymd import uix_path +from kivymd.font_definitions import theme_font_styles +from kivymd.uix.boxlayout import MDBoxLayout +from kivymd.uix.label import MDIcon +from kivymd.utils import next_frame + +with open(os.path.join(uix_path, "search", "search.kv"), encoding="utf-8") as kv_file: + Builder.load_string(kv_file.read()) + + +class MDSearchTrailingAvatar(ButtonBehavior, Image): + """ + Trailing avatar class. + + For more information, see in the + :class:`~kivy.uix.behaviors.button.ButtonBehavior` and + :class:`~kivy.uix.image.Image` + classes documentation. + """ + + +class MDSearchLeadingIcon(ButtonBehavior, MDIcon): + """ + Leading icon class. + + For more information, see in the + :class:`~kivy.uix.behaviors.button.ButtonBehavior` and + :class:`~kivymd.uix.label.MDIcon` + classes documentation. + """ + + +class MDSearchTrailingIcon(ButtonBehavior, MDIcon): + """ + Trailing icon class. + + For more information, see in the + :class:`~kivy.uix.behaviors.button.ButtonBehavior` and + :class:`~kivymd.uix.label.MDIcon` + classes documentation. + """ + + +class MDSearchBarTrailingContainer(BoxLayout): + """ + Trailing container class for search bar. + + For more information, see in the + :class:`~kivy.uix.boxlayout.BoxLayout` + class documentation. + """ + + +class MDSearchBarLeadingContainer(BoxLayout): + """ + Leading container class for search bar. + + For more information, see in the + :class:`~kivy.uix.boxlayout.BoxLayout` + class documentation. + """ + + +class MDSearchViewTrailingContainer(BoxLayout): + """ + Trailing container class for search view. + + For more information, see in the + :class:`~kivy.uix.boxlayout.BoxLayout` + class documentation. + """ + + +class MDSearchViewLeadingContainer(BoxLayout): + """ + Leading container class for search view. + + For more information, see in the + :class:`~kivy.uix.boxlayout.BoxLayout` + class documentation. + """ + + +class MDSearchViewContainer(BoxLayout): + """ + A container for widgets that are displayed when the search bar is in focus. + + For more information, see in the + :class:`~kivy.uix.boxlayout.BoxLayout` + class documentation. + """ + + _d = 0.3 + _children = None + + def add_widget(self, widget, *args, **kwargs): + if self._children is not None: + raise Exception("MDSearchViewContainer only accetps single widget") + + self._children = widget + + def show_child(self, anim_time): + self._children.opacity = 0 + next_frame(Animation(opacity=1, d=self._d).start, self._children, t=anim_time) + next_frame(super().add_widget, self._children, t=anim_time) + + def hide_child(self): + super().remove_widget(self._children) + + def remove_widget(self, widget): + if self._children == widget: + super().remove_widget(self._children) + + +class MDSearchWidget(RelativeLayout): + """ + Internal widget for the search bar. + + For more information, see in the + :class:`~kivy.uix.relativelayout.RelativeLayout` + class documentation. + """ + + _font_style = theme_font_styles["Title"]["medium"] + _d = 0.3 + _t = "easing_standard" + state = "close" + + def __init__(self, root, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root = root + + def update_bar(self, *args): + if self.state == "close": + self.ids.root_container.size = self.root.size + self.ids.root_container.radius = dp(28) + self.ids.root_container.pos = self.root.pos + else: + if self.root.docked: + self.ids.root_container.radius = [dp(28)] * 4 + docked_size = self.root.width, dp(56) + self.root.docked_height + self.ids.root_container.pos = [ + self.root.pos[0], + self.root.pos[1] - docked_size[1] + dp(56), + ] + else: + self.ids.root_container.radius = [0] * 4 + self.ids.root_container.pos = [0, 0] + self.ids.root_container.size = self.size + + def _docked_open(self, opacity_down, opacity_up): + docked_size = self.root.width, dp(56) + self.root.docked_height + Animation( + size=docked_size, + pos=[self.root.pos[0], self.root.pos[1] - docked_size[1] + dp(56)], + radius=[dp(28)] * 4, + t=self._t, + d=self._d, + ).start(self.ids.root_container) + + self.root._view_container.size_hint_y = 1 + self.root._view_container.opacity = 0 + self.root._view_container.padding = [0, 0, 0, dp(16)] + + next_frame( + self.ids.root_container.add_widget, + self.root._view_container, + index=0, + ) + next_frame(opacity_up.start, self.root._view_container, t=self._d) + self.icons_open(opacity_up, opacity_down, self._d / 2) + self.root._view_container.show_child(self._d) + + def _docked_close(self, opacity_down, opacity_up): + self._close(opacity_down, opacity_up) + + def _open(self, opacity_down, opacity_up): + h_d = self._d / 2 + + # container + self.root._view_container.size_hint_y = 1 + self.root._view_container.opacity = 0 + self.root._view_container.padding = [0] * 4 + + self.ids.root_container.add_widget(self.root._view_container, index=0) + next_frame(opacity_up.start, self.root._view_container, t=h_d / 1.5) + Animation( + size=self.size, pos=self.pos, radius=[0] * 4, t=self._t, d=self._d + ).start(self.ids.root_container) + + # header + Animation(height=dp(70), t=self._t, d=self._d).start(self.ids.header) + self.icons_open(opacity_up, opacity_down, h_d) + self.root._view_container.show_child(self._d) + + def _close(self, opacity_down, opacity_up): + h_d = self._d / 2 + + # container + self.root._view_container.size_hint_y = 1 + self.root._view_container.opacity = 1 + + opacity_down.start(self.root._view_container) + + if self.root._view_container in self.ids.root_container.children: + next_frame( + self.ids.root_container.remove_widget, + self.root._view_container, + t=self._d, + ) + + next_frame(setattr, self.root._view_container, "height", dp(56), t=h_d) + + Animation( + size=[self.root.width, dp(56)], + pos=self.root.pos, + radius=[dp(28)] * 4, + t=self._t, + d=self._d, + ).start(self.ids.root_container) + + # header + Animation(height=dp(56), t=self._t, d=self._d).start(self.ids.header) + self.icons_close(opacity_up, opacity_down, h_d) + self.root._view_container.hide_child() + + def icons_close(self, opacity_up, opacity_down, h_d): + opacity_down.start(self.root._view_trailing_container) + opacity_down.start(self.root._view_leading_container) + self.root._bar_leading_container.opacity = 0 + self.root._bar_trailing_container.opacity = 0 + next_frame(self.update_state_closed, t=h_d) + next_frame(opacity_up.start, self.root._bar_trailing_container, t=h_d) + next_frame(opacity_up.start, self.root._bar_leading_container, t=h_d) + + def icons_open(self, opacity_up, opacity_down, h_d): + opacity_down.start(self.root._bar_trailing_container) + opacity_down.start(self.root._bar_leading_container) + self.root._view_leading_container.opacity = 0 + self.root._view_trailing_container.opacity = 0 + next_frame(self.update_state_opened, t=h_d) + next_frame(opacity_up.start, self.root._view_trailing_container, t=h_d) + next_frame(opacity_up.start, self.root._view_leading_container, t=h_d) + + switching_state = False + + def switch_state(self, new_state): + if self.switching_state or new_state == self.state: + return + self.switching_state = True + + opacity_down = Animation(opacity=0, d=self._d / 2) + opacity_up = Animation(opacity=1, d=self._d / 2) + + if self.root.docked: + self.root.width = self.root.docked_width + self.ids.root_container.width = self.root.docked_width + getattr(self, "_docked_" + new_state)(opacity_down, opacity_up) + else: + getattr(self, "_" + new_state)(opacity_down, opacity_up) + + if new_state == "close": + self.ids.text_input.focus = False + + self.state = new_state + Clock.schedule_once(lambda dt: setattr(self, "switching_state", False), self._d) + + def clean_header(self): + for child in self.ids.header.children: + if child.__class__.__name__ != "TextInput": + self.ids.header.remove_widget(child) + + def init_state(self): + if self.root.docked: + self.root.size_hint_x = None + self.root.width = self.root.docked_width + else: + self.root.size_hint_x = 1 + + self.ids.root_container.size = [self.root.width, dp(56)] + + self.update_state_closed() + + def update_state_opened(self, *args): + self.clean_header() + self.ids.header.add_widget(self.root._view_leading_container, index=2) + self.ids.header.add_widget(self.root._view_trailing_container, index=0) + + def update_state_closed(self, *args): + self.clean_header() + self.ids.header.add_widget(self.root._bar_leading_container, index=2) + self.ids.header.add_widget(self.root._bar_trailing_container, index=0) + + +class MDSearchBar(Widget): + """ + Search bar class. + + For more information, see in the + :class:`~kivy.uix.widget.Widget` + class documentation. + """ + + leading_icon = StringProperty("magnify") + """ + Leading icon name. + + :attr:`leading_icon` is an :class:`~kivy.properties.StringProperty` + and defaults to `'magnify'`. + """ + + supporting_text = StringProperty("Hinted search text") + """ + Supporting text. + + :attr:`supporting_text` is an :class:`~kivy.properties.StringProperty` + and defaults to `'Hinted search text'`. + """ + + view_root = ObjectProperty(None) + """ + Root widget for search view. + + :attr:`view_root` is an :class:`~kivy.properties.ObjectProperty` + and defaults to `None`. + """ + + docked_width = NumericProperty(dp(360)) + """ + Docked width. + + :attr:`docked_width` is an :class:`~kivy.properties.NumericProperty` + and defaults to `dp(360)`. + """ + + docked_height = NumericProperty(dp(240)) + """ + Docked height. + + :attr:`docked_height` is an :class:`~kivy.properties.NumericProperty` + and defaults to `dp(240)`. + """ + + docked = BooleanProperty(False) + """ + If `True`, the search bar will be docked. + + :attr:`docked` is an :class:`~kivy.properties.BooleanProperty` + and defaults to `False`. + """ + + text = StringProperty("") + """ + + """ + + # internal props + _search_widget = None + _bar_leading_container = None + _bar_trailing_container = None + _view_leading_container = None + _view_trailing_container = None + _view_container = None + _view_map = { + "MDSearchBarLeadingContainer": "_bar_leading_container", + "MDSearchBarTrailingContainer": "_bar_trailing_container", + "MDSearchViewLeadingContainer": "_view_leading_container", + "MDSearchViewTrailingContainer": "_view_trailing_container", + "MDSearchViewContainer": "_view_container", + } + __events__ = ( + "on_open", + "on_close", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._search_widget = MDSearchWidget(self) + self.bind(pos=self._search_widget.update_bar) + self.bind(size=self._search_widget.update_bar) + self.on_docked(self, self.docked) + + def on_docked(self, instance, docked): + if docked: + self.size_hint_x = None + self.width = self.docked_width + else: + self.size_hint_x = 1 + + def on_supporting_text(self, instance, text): + self._search_widget.ids.text_input.hint_text = text + + def on_view_root(self, *args): + if self._search_widget.parent: + self._search_widget.parent.remove_widget(self._search_widget) + self.view_root.add_widget(self._search_widget) + self._search_widget.init_state() + self._search_widget.update_bar() + self.view_root.bind(size=self._search_widget.update_bar) + + def add_widget(self, widget): + if widget.__class__.__name__ in self._view_map.keys(): + setattr(self, self._view_map[widget.__class__.__name__], widget) + + def close_view(self): + """Closes the search view.""" + + self._search_widget.switch_state("close") + self.dispatch("on_close") + + def open_view(self): + """Opens the search view.""" + + self._search_widget.switch_state("open") + self.dispatch("on_open") + + def on_open(self): + pass + + def on_close(self): + pass + + def on_text(self, *args): + self._search_widget.ids.text_input.text = args[-1] diff --git a/kivymd/utils/__init__.py b/kivymd/utils/__init__.py index e69de29bb..77dc8f54c 100755 --- a/kivymd/utils/__init__.py +++ b/kivymd/utils/__init__.py @@ -0,0 +1,8 @@ +from kivy.clock import Clock + +def next_frame(func, *args, **kwargs): + if time := kwargs.get("t"): + del kwargs["t"] + return Clock.schedule_once(lambda _: func(*args, **kwargs), time) + else: + return Clock.schedule_once(lambda _: func(*args, **kwargs))