Scroll the full-screen carousel down the page, as M3's does
Plan step 19, containment.md C-11. The full-screen layout was another horizontal row with a 28px corner and a mask, where M3's "shows one edge-to-edge large item at a time and scrolls vertically". It is now a vertical scroll-snap column: items fill the row with no corner and no mask, 16dp apart, no end padding, the previous/next buttons point up and down and the arrow keys are Up and Down. The row is capped at the 840px medium window, which with the portrait rule in the header comment is as far as CSS can hold M3's "compact and medium, portrait only". The ported FullScreenCarouselStrategy keylines go with it: nothing measures them any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
9875ba156e
commit
5e58dd5449
+70
-32
@@ -40,8 +40,9 @@
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
|
||||
* (carouselItem, calculateMaxScrollOffset, CarouselDefaults)
|
||||
*
|
||||
* The full-screen arrangement follows material-components-android's
|
||||
* FullScreenCarouselStrategy (one large item the size of the container).
|
||||
* The full-screen layout uses none of it: M3 gives it one edge-to-edge item at a time, scrolled
|
||||
* vertically, so the browser's own scroll snap is the whole of it and this script only works out
|
||||
* where each item comes to rest, for the buttons and the keys.
|
||||
*
|
||||
* Copyright 2023-2024 The Android Open Source Project
|
||||
*
|
||||
@@ -547,11 +548,6 @@ const heroKeylineList = (space, maxItemSize, itemSpacing, itemCount, isCentered)
|
||||
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement)
|
||||
}
|
||||
|
||||
const fullScreenKeylineList = (space, itemSpacing) =>
|
||||
space === 0
|
||||
? EMPTY
|
||||
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, { priority: 0, smallSize: 0, smallCount: 0, mediumSize: 0, mediumCount: 0, largeSize: space, largeCount: 1 })
|
||||
|
||||
// Strategy.kt ------------------------------------------------------------------------------
|
||||
|
||||
const shiftedKeylineListForContentPadding = (from, space, itemSpacing, contentPadding, pivot, pivotIndex) => {
|
||||
@@ -806,6 +802,7 @@ document.addEventListener('alpine:init', () => {
|
||||
snaps: [],
|
||||
maxScroll: 0,
|
||||
rtl: false,
|
||||
vertical: false,
|
||||
frame: null,
|
||||
target: null,
|
||||
targetAt: 0,
|
||||
@@ -861,14 +858,10 @@ document.addEventListener('alpine:init', () => {
|
||||
refresh() {
|
||||
const root = this.$root
|
||||
const scroller = this.$refs.scroller
|
||||
const space = scroller.clientWidth
|
||||
const itemSpacing = parseFloat(getComputedStyle(scroller).columnGap) || 0
|
||||
const padding = Number(root.dataset.padding) || 0
|
||||
const paddingEnd = Number(root.dataset.paddingEnd) || 0
|
||||
const probe = this.$refs.probe
|
||||
const preferred = probe ? probe.getBoundingClientRect().width : null
|
||||
const style = getComputedStyle(scroller)
|
||||
|
||||
state.rtl = getComputedStyle(scroller).direction === 'rtl'
|
||||
state.rtl = style.direction === 'rtl'
|
||||
state.vertical = root.dataset.materialCarousel === 'full-screen'
|
||||
state.items = [...scroller.children]
|
||||
.filter((element) => element.matches(ITEM))
|
||||
.map((element) => ({
|
||||
@@ -879,11 +872,33 @@ document.addEventListener('alpine:init', () => {
|
||||
written: '',
|
||||
}))
|
||||
|
||||
// M3's full-screen layout is one edge-to-edge item at a time, scrolled
|
||||
// vertically: no keylines, no mask, no end padding — the browser's scroll snap
|
||||
// does the whole of it, and this only works out where each item comes to rest.
|
||||
if (state.vertical) {
|
||||
const space = scroller.clientHeight
|
||||
const spacing = parseFloat(style.rowGap) || 0
|
||||
|
||||
state.strategy = null
|
||||
state.maxScroll = Math.max(0, (space + spacing) * state.items.length - spacing - space)
|
||||
state.snaps = state.items.map((_, index) => clamp(index * (space + spacing), 0, state.maxScroll))
|
||||
|
||||
this.render()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const space = scroller.clientWidth
|
||||
const itemSpacing = parseFloat(style.columnGap) || 0
|
||||
const padding = Number(root.dataset.padding) || 0
|
||||
const paddingEnd = Number(root.dataset.paddingEnd) || 0
|
||||
const probe = this.$refs.probe
|
||||
const preferred = probe ? probe.getBoundingClientRect().width : null
|
||||
|
||||
const count = state.items.length
|
||||
const keylines = {
|
||||
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined),
|
||||
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
|
||||
'full-screen': () => fullScreenKeylineList(space, itemSpacing),
|
||||
}[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count))
|
||||
|
||||
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, paddingEnd)
|
||||
@@ -942,10 +957,10 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
state.target = null
|
||||
|
||||
const left = state.snaps[target]
|
||||
const offset = state.snaps[target]
|
||||
|
||||
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
|
||||
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
|
||||
if (offset !== undefined && Math.abs(this.scrollOffset() - offset) > 1) {
|
||||
this.scrollTo(offset, 'instant')
|
||||
}
|
||||
}, SETTLE_MS)
|
||||
},
|
||||
@@ -955,6 +970,14 @@ document.addEventListener('alpine:init', () => {
|
||||
cancelAnimationFrame(state.frame)
|
||||
state.frame = null
|
||||
|
||||
// Nothing is masked in the vertical full-screen layout; only the buttons change.
|
||||
if (state.vertical) {
|
||||
this.buttons()
|
||||
state.mutations?.takeRecords()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const strategy = state.strategy
|
||||
|
||||
if (!strategy?.valid) {
|
||||
@@ -1003,6 +1026,15 @@ document.addEventListener('alpine:init', () => {
|
||||
item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3))
|
||||
})
|
||||
|
||||
this.buttons()
|
||||
|
||||
state.mutations?.takeRecords()
|
||||
},
|
||||
|
||||
/** A control that would scroll past an end is off. */
|
||||
buttons() {
|
||||
const scroll = this.scrollOffset()
|
||||
|
||||
if (this.$refs.previous) {
|
||||
this.$refs.previous.disabled = scroll <= 1
|
||||
}
|
||||
@@ -1010,13 +1042,21 @@ document.addEventListener('alpine:init', () => {
|
||||
if (this.$refs.next) {
|
||||
this.$refs.next.disabled = scroll >= state.maxScroll - 1
|
||||
}
|
||||
|
||||
state.mutations?.takeRecords()
|
||||
},
|
||||
|
||||
/** The scroll offset from the start edge, positive in both directions. */
|
||||
/** The scroll offset from the start edge, positive in every direction. */
|
||||
scrollOffset() {
|
||||
return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll)
|
||||
const scroller = this.$refs.scroller
|
||||
|
||||
return clamp(state.vertical ? scroller.scrollTop : Math.abs(scroller.scrollLeft), 0, state.maxScroll)
|
||||
},
|
||||
|
||||
/** Scrolls to an offset on whichever axis this carousel runs along. */
|
||||
scrollTo(offset, behavior) {
|
||||
this.$refs.scroller.scrollTo({
|
||||
...(state.vertical ? { top: offset } : { left: state.rtl ? -offset : offset }),
|
||||
behavior,
|
||||
})
|
||||
},
|
||||
|
||||
/** The item nearest the current scroll position. */
|
||||
@@ -1052,19 +1092,14 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
scrollToItem(index) {
|
||||
if (!state.strategy?.valid || state.snaps[index] === undefined) {
|
||||
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.target = index
|
||||
state.targetAt = performance.now()
|
||||
|
||||
const left = state.snaps[index]
|
||||
|
||||
this.$refs.scroller.scrollTo({
|
||||
left: state.rtl ? -left : left,
|
||||
behavior: state.reducedMotion.matches ? 'instant' : 'smooth',
|
||||
})
|
||||
this.scrollTo(state.snaps[index], state.reducedMotion.matches ? 'instant' : 'smooth')
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1093,8 +1128,8 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
|
||||
const forward = state.vertical ? 'ArrowDown' : state.rtl ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backward = state.vertical ? 'ArrowUp' : state.rtl ? 'ArrowRight' : 'ArrowLeft'
|
||||
|
||||
const to = {
|
||||
[forward]: index + 1,
|
||||
@@ -1138,8 +1173,11 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
|
||||
isMasked(index) {
|
||||
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
|
||||
return state.vertical
|
||||
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
|
||||
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
|
||||
},
|
||||
|
||||
destroy() {
|
||||
|
||||
Reference in New Issue
Block a user