feat(slint): drag-to-scroll album carousels

Introduces the horizontal drag concept (to be reused widely) on the
discover album Carousel. Replaces the manual page-slide (a clipped
Rectangle translated by -page*step) with a horizontal Flickable:
the row now drags by touch/pointer with kinetic momentum, and card
taps still register because Flickable distinguishes a flick from a
tap — unlike nested TouchAreas, where the card's own TouchArea would
swallow the drag.

  - viewport-height is pinned to the row height so the Flickable
    only scrolls horizontally and never fights the home page's
    vertical Flickable.
  - prev/next buttons nudge viewport-x by one page width (clamped to
    [-max-scroll, 0]); enabled state + the edge fades derive from
    viewport-x.
  - No animate on viewport-x — that would rubber-band the per-frame
    drag updates; button jumps are instant in exchange for smooth
    dragging.

cc
This commit is contained in:
vicrodh committed 2026-05-20 22:15:10 -06:00
1 parent 449d3c9e2a
commit 20b13dbbf1
1 file changed
+22 -19
+22 -19
View File
@@ -78,14 +78,13 @@ export component Carousel inherits VerticalLayout {
property <int> count: root.section.albums.length;
property <int> per-page: Math.max(
1,
Math.floor((viewport.width + root.gap) / (root.card-width + root.gap)));
property <int> page-count: Math.max(1, Math.ceil(root.count / root.per-page));
property <int> page: 0;
// Self-correcting page index — stays valid when the window resizes.
property <int> current-page: Math.min(Math.max(0, root.page), root.page-count - 1);
Math.floor((flick.width + root.gap) / (root.card-width + root.gap)));
property <length> step: root.per-page * (root.card-width + root.gap);
property <length> content-width:
root.count * root.card-width + Math.max(0, root.count - 1) * root.gap;
// Max leftward scroll (viewport-x is 0 at start, negative when
// scrolled right). Zero when everything fits.
property <length> max-scroll: Math.max(0px, root.content-width - flick.width);
spacing: 12px;
@@ -105,9 +104,9 @@ export component Carousel inherits VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: root.current-page > 0;
enabled: flick.viewport-x < -1px;
clicked => {
root.page = root.current-page - 1;
flick.viewport-x = Math.min(0px, flick.viewport-x + root.step);
}
}
}
@@ -115,9 +114,9 @@ export component Carousel inherits VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: root.current-page < root.page-count - 1;
enabled: flick.viewport-x > -root.max-scroll + 1px;
clicked => {
root.page = root.current-page + 1;
flick.viewport-x = Math.max(-root.max-scroll, flick.viewport-x - root.step);
}
}
}
@@ -132,17 +131,21 @@ export component Carousel inherits VerticalLayout {
}
}
// --- Track: clipped viewport, slides by page -------------------------
// --- Track: a horizontal Flickable so the row drags by touch /
// pointer (kinetic), while card taps still register — Flickable
// distinguishes a flick from a tap, unlike nested TouchAreas. The
// prev/next buttons nudge viewport-x by a page. viewport-height ==
// height pins it to horizontal-only so it never fights the home
// page's vertical Flickable.
viewport := Rectangle {
height: root.card-height;
clip: true;
Rectangle {
x: -root.current-page * root.step;
y: 0px;
width: root.content-width;
flick := Flickable {
width: 100%;
height: root.card-height;
animate x { duration: 220ms; easing: ease-in-out; }
viewport-width: root.content-width;
viewport-height: self.height;
HorizontalLayout {
spacing: root.gap;
@@ -158,20 +161,20 @@ export component Carousel inherits VerticalLayout {
}
}
// Cider-style edge fades — clipped cards dissolve into the
// content background instead of being hard-cut.
// Cider-style edge fades — content dissolves into the page
// background at the scrolled edges instead of a hard cut.
Rectangle {
x: 0px;
width: 56px;
background: @linear-gradient(90deg, Theme.surface-main 0%, transparent 100%);
opacity: root.current-page > 0 ? 1.0 : 0.0;
opacity: flick.viewport-x < -1px ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
}
Rectangle {
x: parent.width - self.width;
width: 56px;
background: @linear-gradient(90deg, transparent 0%, Theme.surface-main 100%);
opacity: root.current-page < root.page-count - 1 ? 1.0 : 0.0;
opacity: flick.viewport-x > -root.max-scroll + 1px ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
}
}