diff --git a/kivymd/uix/carousel/arrangement.py b/kivymd/uix/carousel/arrangement.py new file mode 100644 index 000000000..3880675c9 --- /dev/null +++ b/kivymd/uix/carousel/arrangement.py @@ -0,0 +1,142 @@ +import math + + +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}, smallCount={self.small_count}," + f" smallSize={self.small_size}, mediumCount={self.medium_count}," + f" mediumSize={self.medium_size}, largeCount={self.large_count}," + f" largeSize={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.py b/kivymd/uix/carousel/carousel.py index 944893e0d..bb2e7b01e 100644 --- a/kivymd/uix/carousel/carousel.py +++ b/kivymd/uix/carousel/carousel.py @@ -1,6 +1,6 @@ import os -from kivy.properties import ColorProperty, ListProperty +from kivy.properties import ColorProperty, ListProperty, BooleanProperty from kivy.metrics import dp from kivy.lang import Builder from kivy.uix.image import AsyncImage @@ -9,6 +9,7 @@ from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.behaviors import StencilBehavior from kivymd.uix.scrollview import MDScrollView +from kivymd.uix.carousel.carousel_strategy import MultiBrowseCarouselStrategy with open( os.path.join(uix_path, "carousel", "carousel.kv"), encoding="utf-8" @@ -25,12 +26,8 @@ def __init__(self, *arg, **kwargs): class MDCarousel(MDScrollView): images = ListProperty([]) - - # Android default sizes - small_item_min = dp(40) - small_item_max = dp(56) - large_item = dp(120) - + is_horizontal = BooleanProperty(True) + alignment = "none" # Axis axis = "x" @@ -51,49 +48,18 @@ def __init__(self, *arg, **kwargs): self._child_layout.size = self.size self._child_layout.spacing = dp(8) self._child_layout.padding = [dp(16), dp(8)] + self._child_layout.is_horizontal = self.is_horizontal + self._child_layout.alignment = self.alignment self.add_widget(self._child_layout) - def get_max_items(self): - return max(2, 3 + round((self.width - self.small_item_min) / self.large_item)) - def init_images(self, images, start_from=0): - """Add items to view and set there initial size""" - max_items = self.get_max_items() - size_hint = "size_hint_{}".format(self.axis) - - # clear previous ones - self._child_layout.clear_widgets() - - # Add required widgets - while len(self._item_widgets) < max_items: - self._item_widgets.append(MDCarouselImageItem(**{size_hint: None})) - # Remove excess if any - while len(self._item_widgets) >= max_items: - self._item_widgets.pop() - - distance_covered = 0 - w_h = "width" if self.axis == "x" else "height" - _is_small = False - for item, widget in zip(images[start_from:max_items], self._item_widgets): - # set props - widget.source = item["source"] - if widget.parent: - self._child_layout.remove_widget(widget) - - # set size - distance_left = getattr(self, w_h) - distance_covered - if distance_left > self.large_item * 2: - setattr(widget, size_hint, None) - setattr(widget, w_h, self.large_item) - distance_covered += getattr(widget, w_h) - elif not _is_small: - setattr(widget, size_hint, 1) - _is_small = True - else: - setattr(widget, size_hint, None) - setattr(widget, w_h, self.small_item_min) - - self._child_layout.add_widget(widget) + for image in images: + self._child_layout.add_widget( MDCarouselImageItem(size_hint_x=None, width=dp(100), source = image["source"]) ) + + clas = MultiBrowseCarouselStrategy().on_first_child_measured_with_margins( + self._child_layout, MDCarouselImageItem(size_hint_x=None, width=dp(100)) + ) + print(clas) def on_images(self, instance, images): self.init_images(images) @@ -101,21 +67,12 @@ def on_images(self, instance, images): def on_size(self, instance, size): self._child_layout.size = self.size self.init_images(self.images) - + _last_touch_pos = [] + def on_touch_down(self, touch): self._last_touch_pos = list(touch.pos) super().on_touch_down(touch) def on_touch_move(self, touch): super().on_touch_move(touch) - - distance = touch.pos[0] - self._last_touch_pos[0] - print(self.scroll_x * self._child_layout.width) - for widget in self._child_layout.children[::-1]: - if widget.width < self.small_item_max: - if self.scroll_x * self._child_layout.width > self.small_item_min: - self._child_layout.remove_widget(widget) - break - widget.width += distance - break diff --git a/kivymd/uix/carousel/carousel_strategy.py b/kivymd/uix/carousel/carousel_strategy.py new file mode 100644 index 000000000..975145427 --- /dev/null +++ b/kivymd/uix/carousel/carousel_strategy.py @@ -0,0 +1,129 @@ +import math +from kivy.metrics import dp +from kivy.uix.widget import Widget +from kivymd.uix.carousel.arrangement import Arrangement + + +class CarouselStrategy: + small_size_min = dp(40) + small_size_max = dp(56) + + def on_first_child_measured_with_margins(carousel: Widget, child: Widget): + pass + + def get_child_mask_percentage( + masked_size: float, unmasked_size: float, child_margins: float + ): + return 1 - ((masked_size - child_margins) / (unmasked_size - child_margins)) + + @staticmethod + def double_counts(count: list): + doubled_count = list() + for i in range(len(count)): + doubled_count[i] = count[i] * 2 + return doubled_count + + @staticmethod + def clamp(value, min_val=0, max_val=0): + return min(max(value, min_val), max_val) + + def is_contained(self): + return True + + def should_refresh_key_line_state(self, carousel: Widget, old_item_count: int): + return False + + def set_small_item_size_min(self, min_small_item_size: float): + self.small_size_min = min_small_item_size + + def set_small_item_size_max(self, max_small_item_size: float): + self.small_size_max = max_small_item_size + + +class MultiBrowseCarouselStrategy(CarouselStrategy): + SMALL_COUNTS = [1] + MEDIUM_COUNTS = [1, 0] + + def on_first_child_measured_with_margins(self, carousel: Widget, child: Widget): + available_space = carousel.height if carousel.is_horizontal else carousel.width + measured_child_size = child.height if carousel.is_horizontal else child.width + small_child_size_min = self.small_size_min + small_child_size_max = max(self.small_size_max, 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 carousel.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(large_count_max - large_count_min + 1) + ] + 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, + ) + + keyline_count = arrangement.get_item_count() + print( len(carousel.children) ) + if self.ensure_arrangement_fits_item_count(arrangement, len(carousel.children)): + arrangement = Arrangement.find_lowest_cost_arrangement( + available_space, + target_small_child_size, + small_child_size_min, + small_child_size_max, + [arrangement.small_count], + target_medium_child_size, + [arrangement.medium_count], + target_large_child_size, + [arrangement.large_count], + ) + + return arrangement + + def ensure_arrangement_fits_item_count( + self, arrangement: Arrangement, carousel_item_count: int + ): + keyline_surplus = arrangement.get_item_count() - carousel_item_count + changed = keyline_surplus > 0 and ( + arrangement.small_count > 0 or arrangement.medium_count > 1 + ) + while keyline_surplus > 0: + if arrangement.small_count > 0: + arrangement.small_count -= 1 + elif arrangement.medium_count > 1: + arrangement.medium_count -= 1 + keyline_surplus -= 1 + return changed + + def should_refresh_keyline_state(carousel: Widget, old_item_count: int): + return ( + old_item_count < keyline_count + and carousel.get_item_count() >= keyline_count + ) or ( + old_item_count >= keyline_count + and carousel.get_item_count() < keyline_count + )