Skip to content

Commit

Permalink
uix: add MDCarousel
Browse files Browse the repository at this point in the history
  • Loading branch information
T-Dynamos committed Mar 30, 2024
1 parent 46be1e6 commit 4f56571
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 4 deletions.
6 changes: 3 additions & 3 deletions kivymd/_version.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions kivymd/factory_registers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions kivymd/uix/carousel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .carousel import MDCarousel, MDCarouselImageItem
144 changes: 144 additions & 0 deletions kivymd/uix/carousel/arrangement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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
22 changes: 22 additions & 0 deletions kivymd/uix/carousel/carousel.kv
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<MDCarouselImageItem>:
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"

<MDCarousel>:
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
79 changes: 79 additions & 0 deletions kivymd/uix/carousel/carousel.py
Original file line number Diff line number Diff line change
@@ -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):
def __init__(self, *arg, **kwargs):
super().__init__(*arg, **kwargs)


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)
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
131 changes: 131 additions & 0 deletions kivymd/uix/carousel/carousel_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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


class AvaliableStrategies:
avaliable = ["MultiBrowseCarouselStrategy"]

@staticmethod
def get(strategy_name, item_len):
return {
"MultiBrowseCarouselStrategy": MultiBrowseCarouselStrategy,
}[strategy_name](item_len)
Loading

0 comments on commit 4f56571

Please sign in to comment.