1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.material3.carousel
18
19 import androidx.collection.FloatList
20 import androidx.collection.mutableFloatListOf
21 import androidx.compose.ui.util.fastFilter
22 import androidx.compose.ui.util.fastForEach
23 import androidx.compose.ui.util.fastMapIndexed
24 import androidx.compose.ui.util.lerp
25 import kotlin.math.abs
26 import kotlin.math.max
27 import kotlin.math.roundToInt
28
29 /**
30 * An immutable class responsible for supplying carousel with a [KeylineList] that is corrected for
31 * scroll offset, layout direction, and snapping behaviors.
32 *
33 * @param defaultKeylines the keylines that define how items should be arranged in their default
34 * state
35 * @param startKeylineSteps a list of [KeylineList]s that move the focal range from its position in
36 * [defaultKeylines] to the start of the carousel container, one keyline at a time per step
37 * @param endKeylineSteps a list of [KeylineList]s that move the focal range from its position in
38 * [defaultKeylines] to the end of the carousel container, one keyline at a time per step.
39 * [endKeylineSteps] and whose value is the percentage of [endShiftDistance] that should be
40 * scrolled when the end step is used.
41 * @param availableSpace the available space in the main axis
42 * @param itemSpacing the spacing between each item
43 * @param beforeContentPadding the padding preceding the first item in the list
44 * @param afterContentPadding the padding proceeding the last item in the list
45 */
46 internal class Strategy
47 private constructor(
48 val defaultKeylines: KeylineList,
49 val startKeylineSteps: List<KeylineList>,
50 val endKeylineSteps: List<KeylineList>,
51 val availableSpace: Float,
52 val itemSpacing: Float,
53 val beforeContentPadding: Float,
54 val afterContentPadding: Float,
55 ) {
56
57 /**
58 * Creates a new [Strategy] for a keyline list and set of carousel container parameters.
59 *
60 * The [defaultKeylines] are a list of keylines that defines how items should be arranged, from
61 * left-to-right (or top-to-bottom), to achieve the carousel's desired appearance. For example,
62 * a start-aligned large item, followed by a medium and a small item for a multi-browse
63 * carousel. Or a small item, a center-aligned large item, and a small item for a centered hero
64 * carousel. This method will use the [defaultKeylines] to then derive new scroll and layout
65 * direction-aware [KeylineList]s to be used by carousel. For example, when a device is running
66 * in a right-to-left layout direction, Strategy will handle reversing the default
67 * [KeylineList]. Or if the default keylines use a center-aligned large item, Strategy will
68 * generate additional KeylineLists that handle shifting the large item to the start or end of
69 * the screen when the carousel is scrolled to the start or end of the list, letting all items
70 * become large without having them detach from the edges of the scroll container.
71 *
72 * @param defaultKeylines a default [KeylineList] that represents the arrangement of items in a
73 * left-to-right (or top-to-bottom) layout.
74 * @param availableSpace the size of the carousel container in scrolling axis
75 * @param beforeContentPadding the padding to add before the list content
76 * @param afterContentPadding the padding to add after the list content
77 */
78 constructor(
79 defaultKeylines: KeylineList,
80 availableSpace: Float,
81 itemSpacing: Float,
82 beforeContentPadding: Float,
83 afterContentPadding: Float
84 ) : this(
85 defaultKeylines = defaultKeylines,
86 startKeylineSteps =
87 getStartKeylineSteps(
88 defaultKeylines,
89 availableSpace,
90 itemSpacing,
91 beforeContentPadding
92 ),
93 endKeylineSteps =
94 getEndKeylineSteps(defaultKeylines, availableSpace, itemSpacing, afterContentPadding),
95 availableSpace = availableSpace,
96 itemSpacing = itemSpacing,
97 beforeContentPadding = beforeContentPadding,
98 afterContentPadding = afterContentPadding,
99 )
100
101 /** The scroll distance needed to move through all steps in [startKeylineSteps]. */
102 private val startShiftDistance = getStartShiftDistance(startKeylineSteps, beforeContentPadding)
103 /** The scroll distance needed to move through all steps in [endKeylineSteps]. */
104 private val endShiftDistance = getEndShiftDistance(endKeylineSteps, afterContentPadding)
105 /**
106 * A list of floats whose index aligns with a [KeylineList] from [startKeylineSteps] and whose
107 * value is the percentage of [startShiftDistance] that should be scrolled when the start step
108 * is used.
109 */
110 private val startShiftPoints =
111 getStepInterpolationPoints(startShiftDistance, startKeylineSteps, true)
112 /**
113 * A list of floats whose index aligns with a [KeylineList] from [endKeylineSteps] and whose
114 * value is the percentage of [endShiftDistance] that should be scrolled when the end step is
115 * used.
116 */
117 private val endShiftPoints =
118 getStepInterpolationPoints(endShiftDistance, endKeylineSteps, false)
119
120 /** The size of items when in focus and fully unmasked. */
121 val itemMainAxisSize: Float
122 get() = defaultKeylines.firstFocal.size
123
124 /** True if this strategy contains a valid arrangement of keylines for a valid container */
125 val isValid: Boolean =
126 defaultKeylines.isNotEmpty() && availableSpace != 0f && itemMainAxisSize != 0f
127
128 /**
129 * Returns the [KeylineList] that should be used for the current [scrollOffset].
130 *
131 * @param scrollOffset the current scroll offset of the scrollable component
132 * @param maxScrollOffset the maximum scroll offset
133 * @param roundToNearestStep true if the KeylineList returned should be a complete shift step
134 */
135 internal fun getKeylineListForScrollOffset(
136 scrollOffset: Float,
137 maxScrollOffset: Float,
138 roundToNearestStep: Boolean = false
139 ): KeylineList {
140 // The scroll offset could sometimes be slightly negative due to rounding; it should always
141 // be positive
142 val positiveScrollOffset = max(0f, scrollOffset)
143 val startShiftOffset = startShiftDistance
144 val endShiftOffset = max(0f, maxScrollOffset - endShiftDistance)
145
146 // If we're not within either shift range, return the default keylines
147 if (positiveScrollOffset in startShiftOffset..endShiftOffset) {
148 return defaultKeylines
149 }
150
151 var interpolation =
152 lerp(
153 outputMin = 1f,
154 outputMax = 0f,
155 inputMin = 0f,
156 inputMax = startShiftOffset,
157 value = positiveScrollOffset
158 )
159 var shiftPoints = startShiftPoints
160 var steps = startKeylineSteps
161
162 if (positiveScrollOffset > endShiftOffset) {
163 interpolation =
164 lerp(
165 outputMin = 0f,
166 outputMax = 1f,
167 inputMin = endShiftOffset,
168 inputMax = maxScrollOffset,
169 value = positiveScrollOffset
170 )
171 shiftPoints = endShiftPoints
172 steps = endKeylineSteps
173 }
174
175 val shiftPointRange = getShiftPointRange(steps.size, shiftPoints, interpolation)
176
177 if (roundToNearestStep) {
178 val roundedStepIndex =
179 if (shiftPointRange.steppedInterpolation.roundToInt() == 0) {
180 shiftPointRange.fromStepIndex
181 } else {
182 shiftPointRange.toStepIndex
183 }
184 return steps[roundedStepIndex]
185 }
186
187 return lerp(
188 steps[shiftPointRange.fromStepIndex],
189 steps[shiftPointRange.toStepIndex],
190 shiftPointRange.steppedInterpolation
191 )
192 }
193
194 override fun equals(other: Any?): Boolean {
195 if (this === other) return true
196 if (other !is Strategy) return false
197
198 // If neither strategy is valid, they should be considered equal
199 if (!isValid && !other.isValid) return true
200
201 if (isValid != other.isValid) return false
202 if (availableSpace != other.availableSpace) return false
203 if (itemSpacing != other.itemSpacing) return false
204 if (beforeContentPadding != other.beforeContentPadding) return false
205 if (afterContentPadding != other.afterContentPadding) return false
206 if (itemMainAxisSize != other.itemMainAxisSize) return false
207 if (startShiftDistance != other.startShiftDistance) return false
208 if (endShiftDistance != other.endShiftDistance) return false
209 if (startShiftPoints != other.startShiftPoints) return false
210 if (endShiftPoints != other.endShiftPoints) return false
211 // Only check default keyline equality since all other keylines are
212 // derived from the defaults
213 if (defaultKeylines != other.defaultKeylines) return false
214
215 return true
216 }
217
218 override fun hashCode(): Int {
219 if (!isValid) return isValid.hashCode()
220
221 var result = isValid.hashCode()
222 result = 31 * result + availableSpace.hashCode()
223 result = 31 * result + itemSpacing.hashCode()
224 result = 31 * result + beforeContentPadding.hashCode()
225 result = 31 * result + afterContentPadding.hashCode()
226 result = 31 * result + itemMainAxisSize.hashCode()
227 result = 31 * result + startShiftDistance.hashCode()
228 result = 31 * result + endShiftDistance.hashCode()
229 result = 31 * result + startShiftPoints.hashCode()
230 result = 31 * result + endShiftPoints.hashCode()
231 result = 31 * result + defaultKeylines.hashCode()
232 return result
233 }
234
235 companion object {
236 val Empty =
237 Strategy(
238 defaultKeylines = emptyKeylineList(),
239 startKeylineSteps = emptyList(),
240 endKeylineSteps = emptyList(),
241 availableSpace = 0f,
242 itemSpacing = 0f,
243 beforeContentPadding = 0f,
244 afterContentPadding = 0f,
245 )
246 }
247 }
248
249 /**
250 * Returns the total scroll offset needed to move through the entire list of [startKeylineSteps].
251 */
getStartShiftDistancenull252 private fun getStartShiftDistance(
253 startKeylineSteps: List<KeylineList>,
254 beforeContentPadding: Float
255 ): Float {
256 if (startKeylineSteps.isEmpty()) return 0f
257 return max(
258 startKeylineSteps.last().first().unadjustedOffset -
259 startKeylineSteps.first().first().unadjustedOffset,
260 beforeContentPadding
261 )
262 }
263
264 /** Returns the total scroll offset needed to move through the entire list of [endKeylineSteps]. */
getEndShiftDistancenull265 private fun getEndShiftDistance(
266 endKeylineSteps: List<KeylineList>,
267 afterContentPadding: Float
268 ): Float {
269 if (endKeylineSteps.isEmpty()) return 0f
270 return max(
271 endKeylineSteps.first().last().unadjustedOffset -
272 endKeylineSteps.last().last().unadjustedOffset,
273 afterContentPadding
274 )
275 }
276
277 /**
278 * Generates discreet steps which move the focal range from its original position until it reaches
279 * the start of the carousel container.
280 *
281 * Each step can only move the focal range by one keyline at a time to ensure every item in the list
282 * passes through the focal range. Each step removes the keyline at the start of the container and
283 * re-inserts it after the focal range in an order that retains visual balance. This is repeated
284 * until the first focal keyline is at the start of the container. Re-inserting keylines after the
285 * focal range in a balanced way is done by looking at the size of they keyline next to the keyline
286 * that is being re-positioned and finding a match on the other side of the focal range.
287 *
288 * The first state in the returned list is always the default [KeylineList] while the last state
289 * will be the start state or the state that has the focal range at the beginning of the carousel.
290 */
getStartKeylineStepsnull291 private fun getStartKeylineSteps(
292 defaultKeylines: KeylineList,
293 carouselMainAxisSize: Float,
294 itemSpacing: Float,
295 beforeContentPadding: Float
296 ): List<KeylineList> {
297 if (defaultKeylines.isEmpty()) return emptyList()
298
299 val steps: MutableList<KeylineList> = mutableListOf()
300 steps.add(defaultKeylines)
301
302 if (defaultKeylines.isFirstFocalItemAtStartOfContainer()) {
303 if (beforeContentPadding != 0f) {
304 steps.add(
305 createShiftedKeylineListForContentPadding(
306 defaultKeylines,
307 carouselMainAxisSize,
308 itemSpacing,
309 beforeContentPadding,
310 defaultKeylines.firstFocal,
311 defaultKeylines.firstFocalIndex
312 )
313 )
314 }
315 return steps
316 }
317
318 val startIndex = defaultKeylines.firstNonAnchorIndex
319 val endIndex = defaultKeylines.firstFocalIndex
320 val numberOfSteps = endIndex - startIndex
321
322 // If there are no steps but we need to account for a cutoff, create a
323 // list of keylines shifted for the cutoff.
324 if (numberOfSteps <= 0 && defaultKeylines.firstFocal.cutoff > 0) {
325 steps.add(
326 moveKeylineAndCreateShiftedKeylineList(
327 from = defaultKeylines,
328 srcIndex = 0,
329 dstIndex = 0,
330 carouselMainAxisSize = carouselMainAxisSize,
331 itemSpacing = itemSpacing
332 )
333 )
334 return steps
335 }
336
337 var i = 0
338 while (i < numberOfSteps) {
339 val prevStep = steps.last()
340 val originalItemIndex = startIndex + i
341 var dstIndex = defaultKeylines.lastIndex
342 if (originalItemIndex > 0) {
343 val originalNeighborBeforeSize = defaultKeylines[originalItemIndex - 1].size
344 dstIndex = prevStep.firstIndexAfterFocalRangeWithSize(originalNeighborBeforeSize) - 1
345 }
346
347 steps.add(
348 moveKeylineAndCreateShiftedKeylineList(
349 from = prevStep,
350 srcIndex = defaultKeylines.firstNonAnchorIndex,
351 dstIndex = dstIndex,
352 carouselMainAxisSize = carouselMainAxisSize,
353 itemSpacing = itemSpacing
354 )
355 )
356 i++
357 }
358
359 if (beforeContentPadding != 0f) {
360 steps[steps.lastIndex] =
361 createShiftedKeylineListForContentPadding(
362 steps.last(),
363 carouselMainAxisSize,
364 itemSpacing,
365 beforeContentPadding,
366 steps.last().firstFocal,
367 steps.last().firstFocalIndex
368 )
369 }
370
371 return steps
372 }
373
374 /**
375 * Generates discreet steps which move the focal range from its original position until it reaches
376 * the end of the carousel container.
377 *
378 * Each step can only move the focal range by one keyline at a time to ensure every item in the list
379 * passes through the focal range. Each step removes the keyline at the end of the container and
380 * re-inserts it before the focal range in an order that retains visual balance. This is repeated
381 * until the last focal keyline is at the start of the container. Re-inserting keylines before the
382 * focal range in a balanced way is done by looking at the size of they keyline next to the keyline
383 * that is being re-positioned and finding a match on the other side of the focal range.
384 *
385 * The first state in the returned list is always the default [KeylineList] while the last state
386 * will be the end state or the state that has the focal range at the end of the carousel.
387 */
getEndKeylineStepsnull388 private fun getEndKeylineSteps(
389 defaultKeylines: KeylineList,
390 carouselMainAxisSize: Float,
391 itemSpacing: Float,
392 afterContentPadding: Float
393 ): List<KeylineList> {
394 if (defaultKeylines.isEmpty()) return emptyList()
395 val steps: MutableList<KeylineList> = mutableListOf()
396 steps.add(defaultKeylines)
397
398 if (defaultKeylines.isLastFocalItemAtEndOfContainer(carouselMainAxisSize)) {
399 if (afterContentPadding != 0f) {
400 steps.add(
401 createShiftedKeylineListForContentPadding(
402 defaultKeylines,
403 carouselMainAxisSize,
404 itemSpacing,
405 -afterContentPadding,
406 defaultKeylines.lastFocal,
407 defaultKeylines.lastFocalIndex
408 )
409 )
410 }
411 return steps
412 }
413
414 val startIndex = defaultKeylines.lastFocalIndex
415 val endIndex = defaultKeylines.lastNonAnchorIndex
416 val numberOfSteps = endIndex - startIndex
417
418 // If there are no steps but we need to account for a cutoff, create a
419 // list of keylines shifted for the cutoff.
420 if (numberOfSteps <= 0 && defaultKeylines.lastFocal.cutoff > 0) {
421 steps.add(
422 moveKeylineAndCreateShiftedKeylineList(
423 from = defaultKeylines,
424 srcIndex = 0,
425 dstIndex = 0,
426 carouselMainAxisSize = carouselMainAxisSize,
427 itemSpacing = itemSpacing
428 )
429 )
430 return steps
431 }
432
433 var i = 0
434 while (i < numberOfSteps) {
435 val prevStep = steps.last()
436 val originalItemIndex = endIndex - i
437 var dstIndex = 0
438
439 if (originalItemIndex < defaultKeylines.lastIndex) {
440 val originalNeighborAfterSize = defaultKeylines[originalItemIndex + 1].size
441 dstIndex = prevStep.lastIndexBeforeFocalRangeWithSize(originalNeighborAfterSize) + 1
442 }
443
444 val keylines =
445 moveKeylineAndCreateShiftedKeylineList(
446 from = prevStep,
447 srcIndex = defaultKeylines.lastNonAnchorIndex,
448 dstIndex = dstIndex,
449 carouselMainAxisSize = carouselMainAxisSize,
450 itemSpacing = itemSpacing
451 )
452 steps.add(keylines)
453 i++
454 }
455
456 if (afterContentPadding != 0f) {
457 steps[steps.lastIndex] =
458 createShiftedKeylineListForContentPadding(
459 steps.last(),
460 carouselMainAxisSize,
461 itemSpacing,
462 -afterContentPadding,
463 steps.last().lastFocal,
464 steps.last().lastFocalIndex
465 )
466 }
467
468 return steps
469 }
470
471 /**
472 * Returns a new [KeylineList] identical to [from] but with each keyline's offset shifted by
473 * [contentPadding].
474 */
createShiftedKeylineListForContentPaddingnull475 private fun createShiftedKeylineListForContentPadding(
476 from: KeylineList,
477 carouselMainAxisSize: Float,
478 itemSpacing: Float,
479 contentPadding: Float,
480 pivot: Keyline,
481 pivotIndex: Int
482 ): KeylineList {
483 val numberOfNonAnchorKeylines = from.fastFilter { !it.isAnchor }.count()
484 val sizeReduction = contentPadding / numberOfNonAnchorKeylines
485 // Let keylineListOf create a new keyline list with offsets adjusted for each item's
486 // reduction in size
487 val newKeylines =
488 keylineListOf(
489 carouselMainAxisSize = carouselMainAxisSize,
490 itemSpacing = itemSpacing,
491 pivotIndex = pivotIndex,
492 pivotOffset = pivot.offset - (sizeReduction / 2f) + contentPadding
493 ) {
494 from.fastForEach { k -> add(k.size - abs(sizeReduction), k.isAnchor) }
495 }
496
497 // Then reset each item's unadjusted offset back to their original value from the
498 // incoming keyline list. This is necessary because Pager will still be laying out items
499 // end-to-end with the original page size and not the new reduced size.
500 return KeylineList(
501 newKeylines.fastMapIndexed { i, k -> k.copy(unadjustedOffset = from[i].unadjustedOffset) }
502 )
503 }
504
505 /**
506 * Returns a new [KeylineList] where the keyline at [srcIndex] is moved to [dstIndex] and with
507 * updated pivot and offsets that reflect any change in focal shift.
508 */
moveKeylineAndCreateShiftedKeylineListnull509 private fun moveKeylineAndCreateShiftedKeylineList(
510 from: KeylineList,
511 srcIndex: Int,
512 dstIndex: Int,
513 carouselMainAxisSize: Float,
514 itemSpacing: Float
515 ): KeylineList {
516 // -1 if the pivot is shifting left/top, 1 if shifting right/bottom
517 val pivotDir = if (srcIndex > dstIndex) 1 else -1
518 val pivotDelta = (from[srcIndex].size - from[srcIndex].cutoff + itemSpacing) * pivotDir
519 val newPivotIndex = from.pivotIndex + pivotDir
520 val newPivotOffset = from.pivot.offset + pivotDelta
521 return keylineListOf(carouselMainAxisSize, itemSpacing, newPivotIndex, newPivotOffset) {
522 from.toMutableList().move(srcIndex, dstIndex).fastForEach { k -> add(k.size, k.isAnchor) }
523 }
524 }
525
526 /**
527 * Creates and returns a list of float values containing points between 0 and 1 that represent
528 * interpolation values for when the [KeylineList] at the corresponding index in [steps] should be
529 * visible.
530 *
531 * For example, if [steps] has a size of 4, this method will return an array of 4 float values that
532 * could look like [0, .33, .66, 1]. When interpolating through a list of [KeylineList]s, an
533 * interpolation value will be between 0-1. This interpolation will be used to find the range it
534 * falls within from this method's returned value. If interpolation is .25, that would fall between
535 * the 0 and .33, the 0th and 1st indices of the float array. Meaning the 0th and 1st items from
536 * [steps] should be the current [KeylineList]s being interpolated. This is an example with equally
537 * distributed values but these values will typically be unequally distributed since their size
538 * depends on the distance keylines shift between each step.
539 *
540 * @param totalShiftDistance the total distance keylines will shift between the first and last
541 * [KeylineList] of [steps]
542 * @param steps the steps to find interpolation points for
543 * @param isShiftingLeft true if this method should find interpolation points for shifting keylines
544 * to the left/top of a carousel, false if this method should find interpolation points for
545 * shifting keylines to the right/bottom of a carousel
546 * @return a list of floats, equal in size to [steps] that contains points between 0-1 that align
547 * with when a [KeylineList] from [steps should be shown for a 0-1 interpolation value
548 * @see [lerp] for more details on how interpolation points are used
549 * @see [Strategy.getKeylineListForScrollOffset] for more details on how interpolation points are
550 * used
551 */
getStepInterpolationPointsnull552 private fun getStepInterpolationPoints(
553 totalShiftDistance: Float,
554 steps: List<KeylineList>,
555 isShiftingLeft: Boolean
556 ): FloatList {
557 val points = mutableFloatListOf(0f)
558 if (totalShiftDistance == 0f || steps.isEmpty()) {
559 return points
560 }
561
562 (1 until steps.size).map { i ->
563 val prevKeylines = steps[i - 1]
564 val currKeylines = steps[i]
565 val distanceShifted =
566 if (isShiftingLeft) {
567 currKeylines.first().unadjustedOffset - prevKeylines.first().unadjustedOffset
568 } else {
569 prevKeylines.last().unadjustedOffset - currKeylines.last().unadjustedOffset
570 }
571 val stepPercentage = distanceShifted / totalShiftDistance
572 val point = if (i == steps.lastIndex) 1f else points[i - 1] + stepPercentage
573 points.add(point)
574 }
575 return points
576 }
577
578 private data class ShiftPointRange(
579 val fromStepIndex: Int,
580 val toStepIndex: Int,
581 val steppedInterpolation: Float
582 )
583
getShiftPointRangenull584 private fun getShiftPointRange(
585 stepsCount: Int,
586 shiftPoint: FloatList,
587 interpolation: Float
588 ): ShiftPointRange {
589 var lowerBounds = shiftPoint[0]
590 (1 until stepsCount).forEach { i ->
591 val upperBounds = shiftPoint[i]
592 if (interpolation <= upperBounds) {
593 return ShiftPointRange(
594 fromStepIndex = i - 1,
595 toStepIndex = i,
596 steppedInterpolation = lerp(0f, 1f, lowerBounds, upperBounds, interpolation)
597 )
598 }
599 lowerBounds = upperBounds
600 }
601 return ShiftPointRange(fromStepIndex = 0, toStepIndex = 0, steppedInterpolation = 0f)
602 }
603
movenull604 private fun MutableList<Keyline>.move(srcIndex: Int, dstIndex: Int): MutableList<Keyline> {
605 val keyline = get(srcIndex)
606 removeAt(srcIndex)
607 add(dstIndex, keyline)
608 return this
609 }
610
lerpnull611 private fun lerp(
612 outputMin: Float,
613 outputMax: Float,
614 inputMin: Float,
615 inputMax: Float,
616 value: Float
617 ): Float {
618 if (value <= inputMin) {
619 return outputMin
620 } else if (value >= inputMax) {
621 return outputMax
622 }
623
624 return lerp(outputMin, outputMax, (value - inputMin) / (inputMax - inputMin))
625 }
626