diff --git a/kivymd/_version.py b/kivymd/_version.py index 5e2b80cd2..b2af6eec5 100644 --- a/kivymd/_version.py +++ b/kivymd/_version.py @@ -1,5 +1,5 @@ release = False __version__ = "2.0.1.dev0" -__hash__ = "f7bde69707ac708a758a02d89f14997ee468d1ee" -__short_hash__ = "f7bde69" -__date__ = "2024-02-27" +__hash__ = "072e6fd15a32c2ce8c9cdaa2a9f59c202b841b9e" +__short_hash__ = "072e6fd" +__date__ = "2024-03-17" diff --git a/kivymd/factory_registers.py b/kivymd/factory_registers.py index 50fcd002c..76adfb410 100644 --- a/kivymd/factory_registers.py +++ b/kivymd/factory_registers.py @@ -11,6 +11,8 @@ register("MDSegmentButtonLabel", module="kivymd.uix.segmentedbutton") register("MDScrollView", module="kivymd.uix.scrollview") register("MDRecycleView", module="kivymd.uix.recycleview") +register("MDCarousel", module="kivymd.uix.carousel") +register("MDCarouselImageItem", module="kivymd.uix.carousel") register("MDResponsiveLayout", module="kivymd.uix.responsivelayout") register("MDSliverAppbar", module="kivymd.uix.sliverappbar") register("MDSliverAppbarContent", module="kivymd.uix.sliverappbar") diff --git a/kivymd/uix/carousel/__init__.py b/kivymd/uix/carousel/__init__.py new file mode 100644 index 000000000..6fbb1d3dc --- /dev/null +++ b/kivymd/uix/carousel/__init__.py @@ -0,0 +1 @@ +from .carousel import MDCarousel, MDCarouselImageItem diff --git a/kivymd/uix/carousel/arrangement.py b/kivymd/uix/carousel/arrangement.py new file mode 100644 index 000000000..1cfc1a04e --- /dev/null +++ b/kivymd/uix/carousel/arrangement.py @@ -0,0 +1,145 @@ +import math + +# Made in reference with +# ~/material-components-android/lib/java/com/google/android/material/carousel/Arrangement.java + + +class Arrangement: + MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1 + + def __init__( + self, + priority, + target_small_size, + min_small_size, + max_small_size, + small_count, + target_medium_size, + medium_count, + target_large_size, + large_count, + available_space, + ): + self.priority = priority + self.small_size = max(min(target_small_size, max_small_size), min_small_size) + self.small_count = small_count + self.medium_size = target_medium_size + self.medium_count = medium_count + self.large_size = target_large_size + self.large_count = large_count + self.fit(available_space, min_small_size, max_small_size, target_large_size) + self.cost = self.calculate_cost(target_large_size) + + def __str__(self): + return ( + f"Arrangement [priority={self.priority}, small_count={self.small_count}," + f" small_size={self.small_size}, medium_count={self.medium_count}," + f" medium_size={self.medium_size}, large_count={self.large_count}," + f" large_size={self.large_size}, cost={self.cost}]" + ) + + def get_space(self): + return ( + (self.large_size * self.large_count) + + (self.medium_size * self.medium_count) + + (self.small_size * self.small_count) + ) + + def fit(self, available_space, min_small_size, max_small_size, target_large_size): + delta = available_space - self.get_space() + if self.small_count > 0 and delta > 0: + self.small_size += min( + delta / self.small_count, max_small_size - self.small_size + ) + elif self.small_count > 0 and delta < 0: + self.small_size += max( + delta / self.small_count, min_small_size - self.small_size + ) + self.small_size = self.small_size if self.small_count > 0 else 0 + self.large_size = self.calculate_large_size( + available_space, min_small_size, max_small_size, target_large_size + ) + self.medium_size = (self.large_size + self.small_size) / 2 + if self.medium_count > 0 and self.large_size != target_large_size: + target_adjustment = (target_large_size - self.large_size) * self.large_count + available_medium_flex = ( + self.medium_size * self.MEDIUM_ITEM_FLEX_PERCENTAGE + ) * self.medium_count + distribute = min(abs(target_adjustment), available_medium_flex) + if target_adjustment > 0: + self.medium_size -= distribute / self.medium_count + self.large_size += distribute / self.large_count + else: + self.medium_size += distribute / self.medium_count + self.large_size -= distribute / self.large_count + + def calculate_large_size( + self, available_space, min_small_size, max_small_size, target_large_size + ): + small_size = self.small_size if self.small_count > 0 else 0 + return ( + available_space + - ( + ((float(self.small_count)) + (float(self.medium_count)) / 2) + * small_size + ) + ) / ((float(self.large_count)) + (float(self.medium_count)) / 2) + + def is_valid(self): + if self.large_count > 0 and self.small_count > 0 and self.medium_count > 0: + return ( + self.large_size > self.medium_size + and self.medium_size > self.small_size + ) + elif self.large_count > 0 and self.small_count > 0: + return self.large_size > self.small_size + return True + + def calculate_cost(self, target_large_size): + if not self.is_valid(): + return float("inf") + return abs(target_large_size - self.large_size) * self.priority + + @staticmethod + def find_lowest_cost_arrangement( + available_space, + target_small_size, + min_small_size, + max_small_size, + small_counts, + target_medium_size, + medium_counts, + target_large_size, + large_counts, + ): + lowest_cost_arrangement = None + priority = 1 + + for large_count in large_counts: + for medium_count in medium_counts: + for small_count in small_counts: + arrangement = Arrangement( + priority, + target_small_size, + min_small_size, + max_small_size, + small_count, + target_medium_size, + medium_count, + target_large_size, + large_count, + available_space, + ) + + if ( + lowest_cost_arrangement is None + or arrangement.cost < lowest_cost_arrangement.cost + ): + lowest_cost_arrangement = arrangement + if lowest_cost_arrangement.cost == 0: + return lowest_cost_arrangement + priority += 1 + return lowest_cost_arrangement + + def get_item_count(self): + return self.small_count + self.medium_count + self.large_count diff --git a/kivymd/uix/carousel/carousel.kv b/kivymd/uix/carousel/carousel.kv new file mode 100644 index 000000000..d427f1ac4 --- /dev/null +++ b/kivymd/uix/carousel/carousel.kv @@ -0,0 +1,22 @@ +: + text:"" + canvas: + Color: + rgba:[1,1,1,0.5] + RoundedRectangle: + size:self.size + pos:self.pos + radius:[dp(25)] * 4 + MDLabel: + text:root.text + halign:"center" + +: + MDBoxLayout: + id:_container + is_horizontal:root.is_horizontal + alignment:root.alignment + spacing:dp(8) + padding:[0, dp(8)] + size_hint:1,1 + adaptive_width:True diff --git a/kivymd/uix/carousel/carousel.py b/kivymd/uix/carousel/carousel.py new file mode 100644 index 000000000..8bdf59a9a --- /dev/null +++ b/kivymd/uix/carousel/carousel.py @@ -0,0 +1,79 @@ +import os +from functools import partial + +from kivy.clock import Clock +from kivy.lang import Builder +from kivy.metrics import dp +from kivy.properties import ( + BooleanProperty, + StringProperty, + OptionProperty, + NumericProperty, + ListProperty, + DictProperty, +) +from kivy.factory import Factory +from kivy.uix.image import AsyncImage +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.stencilview import StencilView + +from kivymd import uix_path +from kivymd.uix.boxlayout import MDBoxLayout +from kivymd.uix.carousel.carousel_strategy import AvaliableStrategies + +with open( + os.path.join(uix_path, "carousel", "carousel.kv"), + encoding="utf-8", +) as kv_file: + Builder.load_string(kv_file.read()) + + +class MDCarouselImageItem(BoxLayout): + pass + + +class MDCarousel(MDBoxLayout, StencilView): + strategy = OptionProperty( + "MultiBrowseCarouselStrategy", options=[AvaliableStrategies.avaliable] + ) + is_horizontal = BooleanProperty(True) + alignment = StringProperty("default") + desired_item_size = NumericProperty(100) + + data = ListProperty([]) + viewclass = StringProperty("MDCarouselImageItem") + + _strategy = None + _variable_item_size = dp(50) + + def __init__(self, *arg, **kwargs): + super().__init__(*arg, **kwargs) + self.bind(data=self.update_strategy) + self.bind(strategy=self.update_strategy) + self.bind(size=self.update_strategy) + + def on_data(self, instance, data): + for widget_data in data: + widget = Factory.get( + self.viewclass + if "viewclass" not in widget_data.keys() + else widget_data["viewclass"] + )(size_hint_x=None) + for key, value in widget_data.items(): + setattr(widget, key, value) + widget.width = 0 + self.ids._container.add_widget(widget) + + def update_strategy(self, *args): + if self.width <= 0: + Clock.schedule_once(self.update_strategy) + return + if self._strategy.__class__.__name__ != self.strategy: + self._strategy = AvaliableStrategies.get(self.strategy, len(self.data)) + self._strategy.arrange(self.alignment, self.width, self.desired_item_size) + Clock.schedule_once(partial(self._strategy.set_init_size, self.ids._container)) + return self._strategy + + def on_touch_move(self, touch): + self._strategy.touch_move(self.ids._container, touch) + super().on_touch_move(touch) diff --git a/kivymd/uix/carousel/carousel_strategy.py b/kivymd/uix/carousel/carousel_strategy.py new file mode 100644 index 000000000..6567a41e1 --- /dev/null +++ b/kivymd/uix/carousel/carousel_strategy.py @@ -0,0 +1,152 @@ +import math +from functools import partial + +from kivy.metrics import dp +from kivy.uix.widget import Widget +from kivy.clock import Clock + +from kivymd.uix.carousel.arrangement import Arrangement + + +class CarouselStrategy: + spacing = dp(8) + + small_size_min = dp(40) + small_size_max = dp(56) + + item_len = 0 + arrangement = None + + def __init__(self, item_len): + self.item_len = item_len + + def arrange( + self, + alignment: str, + available_space: int, + measured_child_size: int, + item_len: int, + ) -> Arrangement: + """Build arrangement based on size""" + + def update_init_widgets(self, container): ... + + def set_init_size(self, container, *args) -> None: + """Set size of visible widgets initially""" + + @staticmethod + def double_counts(count: list): + doubled_count = [] + for i in count: + doubled_count.append(i * 2) + return doubled_count + + @staticmethod + def clamp(value, min_val=0, max_val=0): + return min(max(value, min_val), max_val) + + +class MultiBrowseCarouselStrategy(CarouselStrategy): + small_counts = [1] + medium_counts = [1, 0] + + def arrange( + self, + alignment: str, + available_space: int, + measured_child_size: int, + ) -> Arrangement: + # append default padding + measured_child_size += self.spacing + + small_child_size_min = self.small_size_min + self.spacing + small_child_size_max = max( + self.small_size_max + self.spacing, small_child_size_min + ) + target_large_child_size = min(measured_child_size, available_space) + target_small_child_size = self.clamp( + measured_child_size / 3, small_child_size_min, small_child_size_max + ) + target_medium_child_size = ( + target_large_child_size + target_small_child_size + ) / 2 + small_counts = self.small_counts + if available_space < small_child_size_min * 2: + small_counts = [0] + medium_counts = self.medium_counts + + if alignment == "center": + small_counts = self.double_counts(small_counts) + medium_counts = self.double_counts(medium_counts) + + min_available_large_space = ( + available_space + - (target_medium_child_size * max(medium_counts)) + - (small_child_size_max * max(small_counts)) + ) + large_count_min = max(1, min_available_large_space // target_large_child_size) + large_count_max = math.ceil(available_space / target_large_child_size) + large_counts = [ + large_count_max - i + for i in range(int(large_count_max - large_count_min + 1)) + ] + self.arrangement = Arrangement.find_lowest_cost_arrangement( + available_space, + target_small_child_size, + small_child_size_min, + small_child_size_max, + small_counts, + target_medium_child_size, + medium_counts, + target_large_child_size, + large_counts, + ) + + def set_init_size(self, container, *args): + if len(container.children) < self.arrangement.get_item_count(): + # Reset the size and then retry + for widget in container.children: + widget.width = self.arrangement.large_size - dp(30) + Clock.schedule_once(partial(self.set_init_size, container)) + return + + item_index = 0 + for type_item in ["large", "medium", "small"]: + for _ in range(getattr(self.arrangement, "{}_count".format(type_item))): + widget = container.children[::-1][item_index] + widget.width = getattr( + self.arrangement, "{}_size".format(type_item) + ) - dp(8) + item_index += 1 + + _current_index = 0 + _last_touch_move = [0, 0] + + def touch_move(self, container, touch): + delta_distance = self._last_touch_move[0] - touch.pos[0] + self._last_touch_move = list(touch.pos) + delta_distance = min(abs(delta_distance), 5) + distance_subtracted = False + for child in container.children[::-1][ + self._current_index : self._current_index + + self.arrangement.get_item_count() + ]: + if not distance_subtracted: + child.width -= delta_distance + distance_subtracted = True + if child.width < 0: + self._current_index += 1 + child.opacity = 0 + elif child.width < self.arrangement.large_size: + child.width += delta_distance + break + + +class AvaliableStrategies: + avaliable = ["MultiBrowseCarouselStrategy"] + + @staticmethod + def get(strategy_name, item_len): + return { + "MultiBrowseCarouselStrategy": MultiBrowseCarouselStrategy, + }[strategy_name](item_len) diff --git a/kivymd/uix/scrollview.py b/kivymd/uix/scrollview.py index a484fc6b0..9142db1b5 100644 --- a/kivymd/uix/scrollview.py +++ b/kivymd/uix/scrollview.py @@ -144,7 +144,7 @@ def get_component(self, pos): def convert_overscroll(self, touch): if ( self.scroll_view - and self.target_widget.collide_point(*touch.pos) + and self.scroll_view.collide_point(*touch.pos) and self.is_top_or_bottom() and getattr(self.scroll_view, "do_scroll_" + self.scale_axis) and self.velocity == 0 diff --git a/main.py b/main.py new file mode 100644 index 000000000..ab6c2d1ab --- /dev/null +++ b/main.py @@ -0,0 +1,41 @@ +from glob import glob +from kivy.metrics import dp +from kivy.uix.boxlayout import BoxLayout +from kivy.lang import Builder +from kivymd.app import MDApp + +from examples.common_app import CommonApp, KV + +MAIN_KV = """ +MDScreen: + md_bg_color: app.theme_cls.backgroundColor + BoxLayout: + orientation:"vertical" + padding:[dp(16),0] + MDCarousel: + id:carousel + size_hint_y:None + height:dp(200) + MDSlider: + step: 20 + value: 50 + MDSliderHandle: + MDSliderValueLabel: + Widget: + +""" + +class Example(MDApp, CommonApp): + def build(self): + self.theme_cls.theme_style = "Dark" + self.theme_cls.primary_palette = "Mediumspringgreen" + return Builder.load_string(MAIN_KV) + + def on_start(self): + super().on_start() + self.root.ids.carousel.data = [ + # {"source":path} for path in glob("/home/tdynamos/Pictures/Screenshots/*")[:20] + {"text":str(_+1)} for _ in range(20) + ] + +Example().run()