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.compose.ui.util.fastFirstOrNull
20 import androidx.compose.ui.util.fastForEach
21 import androidx.compose.ui.util.fastForEachIndexed
22 import androidx.compose.ui.util.fastMapIndexed
23 import kotlin.math.abs
24 
25 /**
26  * A structure that is fixed at a specific [offset] along a scrolling axis and defines properties of
27  * an item when its center is located at [offset].
28  *
29  * [Keyline] is the primary structure of any carousel. When multiple keylines are placed along a
30  * carousel's axis and an item is scrolled, that item will always be between two keylines. The
31  * item's distance between its two surrounding keylines can be used as a fraction to create an
32  * interpolated keyline that the item uses to set its size and translation.
33  *
34  * @param size the size an item should be in pixels when its center is at [offset]
35  * @param offset the location of the keyline along the scrolling axis and where the center of an
36  *   item should be (usually translated to) when it is at [unadjustedOffset] in the end-to-end
37  *   scrolling model
38  * @param unadjustedOffset the location of:445 the keyline in the end-to-end scrolling model (when
39  *   all items are laid out with their full size and placed end-to-end)
40  * @param isFocal whether an item at this keyline is focal or fully "viewable"
41  * @param isAnchor true if this keyline is able to be shifted within a list of keylines
42  * @param isPivot true if this is the keyline that was used to calculate all other keyline offsets
43  *   and unadjusted offsets in a list
44  * @param cutoff the amount this item bleeds beyond the bounds of the container - 0 if the item is
45  *   fully in-bounds or fully out-of-bounds
46  */
47 internal data class Keyline(
48     val size: Float,
49     val offset: Float,
50     val unadjustedOffset: Float,
51     val isFocal: Boolean,
52     val isAnchor: Boolean,
53     val isPivot: Boolean,
54     val cutoff: Float,
55 )
56 
57 /**
58  * A [List] of [Keyline]s with additional functionality specific to carousel.
59  *
60  * Note that [KeylineList]'s constructor should only be used when creating an interpolated
61  * KeylineList. If creating a new KeylineList - for a strategy or shifted step - prefer using the
62  * [keylineListOf] method which will handle setting all offsets and unadjusted offsets based on a
63  * pivot keyline.
64  */
65 internal class KeylineList internal constructor(keylines: List<Keyline>) :
66     List<Keyline> by keylines {
67 
68     /**
69      * Returns the index of the pivot keyline used to calculate all other keyline offsets and
70      * unadjusted offsets.
71      */
72     val pivotIndex: Int = indexOfFirst { it.isPivot }
73 
74     /** Returns the keyline used to calculate all other keyline offsets and unadjusted offsets. */
75     val pivot: Keyline
76         get() = get(pivotIndex)
77 
78     /**
79      * Returns the index of the first non-anchor keyline or -1 if the list does not contain a
80      * non-anchor keyline.
81      */
82     val firstNonAnchorIndex: Int = indexOfFirst { !it.isAnchor }
83 
84     /**
85      * Returns the first non-anchor [Keyline].
86      *
87      * @throws [NoSuchElementException] if there are no non-anchor keylines.
88      */
89     val firstNonAnchor: Keyline
90         get() = get(firstNonAnchorIndex)
91 
92     /**
93      * Returns the index of the last non-anchor keyline or -1 if the list does not contain a
94      * non-anchor keyline.
95      */
96     val lastNonAnchorIndex: Int = indexOfLast { !it.isAnchor }
97 
98     /**
99      * Returns the last non-anchor [Keyline].
100      *
101      * @throws [NoSuchElementException] if there are no non-anchor keylines.
102      */
103     val lastNonAnchor: Keyline
104         get() = get(lastNonAnchorIndex)
105 
106     /**
107      * Returns the index of the first focal keyline or -1 if the list does not contain a focal
108      * keyline.
109      */
110     val firstFocalIndex = indexOfFirst { it.isFocal }
111 
112     /**
113      * Returns the first focal [Keyline].
114      *
115      * @throws [NoSuchElementException] if there are no focal keylines.
116      */
117     val firstFocal: Keyline
118         get() =
119             getOrNull(firstFocalIndex)
120                 ?: throw NoSuchElementException(
121                     "All KeylineLists must have at least one focal keyline"
122                 )
123 
124     /**
125      * Returns the index of the last focal keyline or -1 if the list does not contain a focal
126      * keyline.
127      */
128     val lastFocalIndex: Int = indexOfLast { it.isFocal }
129 
130     /**
131      * Returns the last focal [Keyline].
132      *
133      * @throws [NoSuchElementException] if there are no focal keylines.
134      */
135     val lastFocal: Keyline
136         get() =
137             getOrNull(lastFocalIndex)
138                 ?: throw NoSuchElementException(
139                     "All KeylineLists must have at least one focal keyline"
140                 )
141 
142     /**
143      * Returns true if the first focal item's left/top is within the visible bounds of the container
144      * and is the first non-anchor keyline.
145      *
146      * When this is true, it means the focal range cannot be shifted left/top or is shifted as far
147      * left/top as possible. When this is false, there are keylines that can be swapped to shift the
148      * first focal item closer to the left/top of the container while still remaining visible.
149      */
150     fun isFirstFocalItemAtStartOfContainer(): Boolean {
151         val firstFocalLeft = firstFocal.offset - (firstFocal.size / 2)
152         return firstFocalLeft >= 0 && firstFocal == firstNonAnchor
153     }
154 
155     /**
156      * Returns true if the last focal item's right/bottom is within the visible bounds of the
157      * container and is the last non-anchor keyline.
158      *
159      * When this is true, it means the focal range cannot be shifted right/bottom or is shifted as
160      * far right/bottom as possible. When this is false, there are keylines that can be swapped to
161      * shift the last focal item closer to the right/bottom of the container while still remaining
162      * visible.
163      */
164     fun isLastFocalItemAtEndOfContainer(carouselMainAxisSize: Float): Boolean {
165         val lastFocalRight = lastFocal.offset + (lastFocal.size / 2)
166         return lastFocalRight <= carouselMainAxisSize && lastFocal == lastNonAnchor
167     }
168 
169     /**
170      * Returns the index of the first keyline after the focal range where the keyline's size is
171      * equal to [size] or the last index if no keyline is found.
172      *
173      * This is useful when moving keylines from one side of the focal range to the other (shifting).
174      * Find an index on the other side of the focal range where after moving the keyline, the
175      * keyline list will retain its original visual balance.
176      */
177     fun firstIndexAfterFocalRangeWithSize(size: Float): Int {
178         val from = lastFocalIndex
179         val to = lastIndex
180         return (from..to).firstOrNull { i -> this[i].size == size } ?: lastIndex
181     }
182 
183     /**
184      * Returns the index of the last keyline before the focal range where the keyline's size is
185      * equal to [size] or 0 if no keyline is found.
186      *
187      * This is useful when moving keylines from one side of the focal range to the other (shifting).
188      * Find an index on the other side of the focal range where after moving the keyline, the
189      * keyline list will retain its original visual balance.
190      */
191     fun lastIndexBeforeFocalRangeWithSize(size: Float): Int {
192         val from = firstFocalIndex - 1
193         val to = 0
194         return (from downTo to).firstOrNull { i -> this[i].size == size } ?: to
195     }
196 
197     /**
198      * Returns the last [Keyline] with an unadjustedOffset that is less than [unadjustedOffset] or
199      * the first keyline if none is found.
200      */
201     fun getKeylineBefore(unadjustedOffset: Float): Keyline {
202         for (index in indices.reversed()) {
203             val k = get(index)
204             if (k.unadjustedOffset < unadjustedOffset) {
205                 return k
206             }
207         }
208 
209         return first()
210     }
211 
212     /**
213      * Returns the first [Keyline] with an unadjustedOffset that is greater than [unadjustedOffset]
214      * or the last keyline if none is found.
215      */
216     fun getKeylineAfter(unadjustedOffset: Float): Keyline {
217         return fastFirstOrNull { it.unadjustedOffset >= unadjustedOffset } ?: last()
218     }
219 
220     override fun equals(other: Any?): Boolean {
221         if (this === other) return true
222         if (other !is KeylineList) return false
223         if (size != other.size) return false
224 
225         fastForEachIndexed { i, keyline -> if (keyline != other[i]) return false }
226 
227         return true
228     }
229 
230     override fun hashCode(): Int {
231         var result = 0
232         fastForEach { keyline -> result += 31 * keyline.hashCode() }
233         return result
234     }
235 
236     companion object {
237         val Empty = KeylineList(emptyList())
238     }
239 }
240 
emptyKeylineListnull241 internal fun emptyKeylineList() = KeylineList.Empty
242 
243 /** Returns a [KeylineList] by aligning the focal range relative to the carousel container. */
244 internal fun keylineListOf(
245     carouselMainAxisSize: Float,
246     itemSpacing: Float,
247     carouselAlignment: CarouselAlignment,
248     keylines: KeylineListScope.() -> Unit
249 ): KeylineList {
250     val keylineListScope = KeylineListScopeImpl()
251     keylines.invoke(keylineListScope)
252     return keylineListScope.createWithAlignment(
253         carouselMainAxisSize,
254         itemSpacing,
255         carouselAlignment
256     )
257 }
258 
259 /**
260  * Returns a [KeylineList] by using a single pivot keyline to calculate the offset and unadjusted
261  * offset of all keylines in the list.
262  */
keylineListOfnull263 internal fun keylineListOf(
264     carouselMainAxisSize: Float,
265     itemSpacing: Float,
266     pivotIndex: Int,
267     pivotOffset: Float,
268     keylines: KeylineListScope.() -> Unit
269 ): KeylineList {
270     val keylineListScope = KeylineListScopeImpl()
271     keylines.invoke(keylineListScope)
272     return keylineListScope.createWithPivot(
273         carouselMainAxisSize,
274         itemSpacing,
275         pivotIndex,
276         pivotOffset
277     )
278 }
279 
280 /** Receiver scope for creating a [KeylineList] using [keylineListOf] */
281 internal interface KeylineListScope {
282 
283     /**
284      * Adds a keyline to the resulting [KeylineList].
285      *
286      * Note that keylines are added in the order they will appear.
287      *
288      * @param size the size of an item in pixels at this keyline
289      * @param isAnchor true if this keyline should not be shifted - usually the first and last fully
290      *   off-screen keylines
291      */
addnull292     fun add(size: Float, isAnchor: Boolean = false)
293 }
294 
295 private class KeylineListScopeImpl : KeylineListScope {
296 
297     private data class TmpKeyline(val size: Float, val isAnchor: Boolean)
298 
299     private var firstFocalIndex: Int = -1
300     private var focalItemSize: Float = 0f
301     private var pivotIndex: Int = -1
302     private var pivotOffset: Float = 0f
303     private val tmpKeylines = mutableListOf<TmpKeyline>()
304 
305     override fun add(size: Float, isAnchor: Boolean) {
306         tmpKeylines.add(TmpKeyline(size, isAnchor))
307         // Save the first "focal" item by looking for the first index of the largest item added
308         // to the list. The last focal item index will be found when `create` is called by starting
309         // from firstFocalIndex and incrementing the index until the next item's size does not
310         // equal focalItemSize.
311         if (size > focalItemSize) {
312             firstFocalIndex = tmpKeylines.lastIndex
313             focalItemSize = size
314         }
315     }
316 
317     fun createWithPivot(
318         carouselMainAxisSize: Float,
319         itemSpacing: Float,
320         pivotIndex: Int,
321         pivotOffset: Float
322     ): KeylineList {
323         val keylines =
324             createKeylinesWithPivot(
325                 pivotIndex,
326                 pivotOffset,
327                 firstFocalIndex,
328                 findLastFocalIndex(),
329                 itemMainAxisSize = focalItemSize,
330                 carouselMainAxisSize = carouselMainAxisSize,
331                 itemSpacing,
332                 tmpKeylines
333             )
334         return KeylineList(keylines)
335     }
336 
337     fun createWithAlignment(
338         carouselMainAxisSize: Float,
339         itemSpacing: Float,
340         carouselAlignment: CarouselAlignment
341     ): KeylineList {
342         val lastFocalIndex = findLastFocalIndex()
343         val focalItemCount = lastFocalIndex - firstFocalIndex
344 
345         pivotIndex = firstFocalIndex
346         pivotOffset =
347             when (carouselAlignment) {
348                 CarouselAlignment.Center -> {
349                     // If there is an even number of keylines, the itemSpacing will be placed in the
350                     // center of the container. Divide the item spacing by half before subtracting
351                     // the pivot item's center.
352                     val itemSpacingSplit =
353                         if (itemSpacing == 0f || focalItemCount.mod(2) == 0) {
354                             0f
355                         } else {
356                             itemSpacing / 2f
357                         }
358                     (carouselMainAxisSize / 2) -
359                         ((focalItemSize / 2) * focalItemCount) -
360                         itemSpacingSplit
361                 }
362                 CarouselAlignment.End -> carouselMainAxisSize - (focalItemSize / 2)
363                 // Else covers and defaults to CarouselAlignment.Start
364                 else -> focalItemSize / 2
365             }
366 
367         val keylines =
368             createKeylinesWithPivot(
369                 pivotIndex,
370                 pivotOffset,
371                 firstFocalIndex,
372                 lastFocalIndex,
373                 itemMainAxisSize = focalItemSize,
374                 carouselMainAxisSize = carouselMainAxisSize,
375                 itemSpacing,
376                 tmpKeylines
377             )
378         return KeylineList(keylines)
379     }
380 
381     private fun findLastFocalIndex(): Int {
382         // Find the last focal index. Start from the first focal index and walk up the indices
383         // while items remain the same size as the first focal item size - finding a contiguous
384         // range of indices where item size is equal to focalItemSize.
385         var lastFocalIndex = firstFocalIndex
386         while (
387             lastFocalIndex < tmpKeylines.lastIndex &&
388                 tmpKeylines[lastFocalIndex + 1].size == focalItemSize
389         ) {
390             lastFocalIndex++
391         }
392         return lastFocalIndex
393     }
394 
395     /**
396      * Converts a list of [TmpKeyline] to a list of [Keyline]s whose offset, unadjusted offset, and
397      * cutoff are calculated from a pivot.
398      *
399      * Pivoting is useful when aligning the entire arrangement relative to the scrolling container.
400      * When creating a keyline list with the first focal keyline aligned to the start of the
401      * container, use the first focal item as the pivot and set the pivot offset to where that first
402      * focal item's center should be placed (carouselStart + (item size / 2)). All keylines before
403      * and after the pivot will have their offset, unadjusted offset, and cutoff calculated based on
404      * the pivot offset. When shifting keylines and moving the carousel's alignment from start to
405      * end, use setPivot to align the last focal keyline to the end of the container.
406      *
407      * @param pivotIndex the index of the keyline from [tmpKeylines] that is used to align the
408      *   entire arrangement
409      * @param pivotOffset the offset along the scrolling axis where the pivot keyline should be
410      *   placed and where keylines before and after will have their offset, unadjustedOffset, and
411      *   cutoff calculated from
412      * @param firstFocalIndex the index of the first focal item in the [tmpKeylines] list
413      * @param lastFocalIndex the index of the last focal item in the [tmpKeylines] list
414      * @param itemMainAxisSize the size of focal, or fully unmasked/clipped, items
415      * @param carouselMainAxisSize the size of the carousel container in the scrolling axis
416      */
417     private fun createKeylinesWithPivot(
418         pivotIndex: Int,
419         pivotOffset: Float,
420         firstFocalIndex: Int,
421         lastFocalIndex: Int,
422         itemMainAxisSize: Float,
423         carouselMainAxisSize: Float,
424         itemSpacing: Float,
425         tmpKeylines: List<TmpKeyline>
426     ): List<Keyline> {
427         val pivot = tmpKeylines[pivotIndex]
428         val keylines = mutableListOf<Keyline>()
429 
430         val pivotCutoff: Float =
431             when {
432                 isCutoffLeft(pivot.size, pivotOffset) -> pivotOffset - (pivot.size / 2)
433                 isCutoffRight(pivot.size, pivotOffset, carouselMainAxisSize) ->
434                     (pivotOffset + (pivot.size / 2)) - carouselMainAxisSize
435                 else -> 0f
436             }
437         keylines.add(
438             // Add the pivot keyline first
439             Keyline(
440                 size = pivot.size,
441                 offset = pivotOffset,
442                 unadjustedOffset = pivotOffset,
443                 isFocal = pivotIndex in firstFocalIndex..lastFocalIndex,
444                 isAnchor = pivot.isAnchor,
445                 isPivot = true,
446                 cutoff = pivotCutoff
447             )
448         )
449 
450         // Convert all TmpKeylines before the pivot to Keylines by calculating their offset,
451         // unadjustedOffset, and cutoff and insert them at the beginning of the keyline list,
452         // maintaining the tmpKeyline list's original order.
453         var offset = pivotOffset - (itemMainAxisSize / 2) - itemSpacing
454         var unadjustedOffset = pivotOffset - (itemMainAxisSize / 2) - itemSpacing
455         (pivotIndex - 1 downTo 0).forEach { originalIndex ->
456             val tmp = tmpKeylines[originalIndex]
457             val tmpOffset = offset - (tmp.size / 2)
458             val tmpUnadjustedOffset = unadjustedOffset - (itemMainAxisSize / 2)
459             val cutoff =
460                 if (isCutoffLeft(tmp.size, tmpOffset)) abs(tmpOffset - (tmp.size / 2)) else 0f
461             keylines.add(
462                 0,
463                 Keyline(
464                     size = tmp.size,
465                     offset = tmpOffset,
466                     unadjustedOffset = tmpUnadjustedOffset,
467                     isFocal = originalIndex in firstFocalIndex..lastFocalIndex,
468                     isAnchor = tmp.isAnchor,
469                     isPivot = false,
470                     cutoff = cutoff
471                 )
472             )
473 
474             offset -= tmp.size + itemSpacing
475             unadjustedOffset -= itemMainAxisSize + itemSpacing
476         }
477 
478         // Convert all TmpKeylines after the pivot to Keylines by calculating their offset,
479         // unadjustedOffset, and cutoff and inserting them at the end of the keyline list,
480         // maintaining the tmpKeyline list's original order.
481         offset = pivotOffset + (itemMainAxisSize / 2) + itemSpacing
482         unadjustedOffset = pivotOffset + (itemMainAxisSize / 2) + itemSpacing
483         (pivotIndex + 1 until tmpKeylines.size).forEach { originalIndex ->
484             val tmp = tmpKeylines[originalIndex]
485             val tmpOffset = offset + (tmp.size / 2)
486             val tmpUnadjustedOffset = unadjustedOffset + (itemMainAxisSize / 2)
487             val cutoff =
488                 if (isCutoffRight(tmp.size, tmpOffset, carouselMainAxisSize)) {
489                     (tmpOffset + (tmp.size / 2)) - carouselMainAxisSize
490                 } else {
491                     0f
492                 }
493             keylines.add(
494                 Keyline(
495                     size = tmp.size,
496                     offset = tmpOffset,
497                     unadjustedOffset = tmpUnadjustedOffset,
498                     isFocal = originalIndex in firstFocalIndex..lastFocalIndex,
499                     isAnchor = tmp.isAnchor,
500                     isPivot = false,
501                     cutoff = cutoff
502                 )
503             )
504 
505             offset += tmp.size + itemSpacing
506             unadjustedOffset += itemMainAxisSize + itemSpacing
507         }
508 
509         return keylines
510     }
511 
512     /**
513      * Returns whether an item of [size] whose center is at [offset] is straddling the carousel
514      * container's left/top.
515      *
516      * This method will return false if the item is either fully visible (its left/top edge comes
517      * after the container's left/top) or fully invisible (its right/bottom edge comes before the
518      * container's left/top).
519      */
520     private fun isCutoffLeft(size: Float, offset: Float): Boolean {
521         return offset - (size / 2) < 0f && offset + (size / 2) > 0f
522     }
523 
524     /**
525      * Returns whether an item of [size] whose center is at [offset] is straddling the carousel
526      * container's right/bottom edge.
527      *
528      * This method will return false if the item is either fully visible (its right/bottom edge
529      * comes before the container's right/bottom) or fully invisible (its left/top edge comes after
530      * the container's right/bottom).
531      */
532     private fun isCutoffRight(size: Float, offset: Float, carouselMainAxisSize: Float): Boolean {
533         return offset - (size / 2) < carouselMainAxisSize &&
534             offset + (size / 2) > carouselMainAxisSize
535     }
536 }
537 
538 /**
539  * Returns an interpolated [Keyline] whose values are all interpolated based on [fraction] between
540  * the [start] and [end] keylines.
541  */
lerpnull542 internal fun lerp(start: Keyline, end: Keyline, fraction: Float): Keyline {
543     return Keyline(
544         size = androidx.compose.ui.util.lerp(start.size, end.size, fraction),
545         offset = androidx.compose.ui.util.lerp(start.offset, end.offset, fraction),
546         unadjustedOffset =
547             androidx.compose.ui.util.lerp(start.unadjustedOffset, end.unadjustedOffset, fraction),
548         isFocal = if (fraction < .5f) start.isFocal else end.isFocal,
549         isAnchor = if (fraction < .5f) start.isAnchor else end.isAnchor,
550         isPivot = if (fraction < .5f) start.isPivot else end.isPivot,
551         cutoff = androidx.compose.ui.util.lerp(start.cutoff, end.cutoff, fraction)
552     )
553 }
554 
555 /**
556  * Returns an interpolated KeylineList between [from] and [to].
557  *
558  * Unlike creating a [KeylineList] using [keylineListOf], this method does not set unadjusted
559  * offsets by calculating them from a pivot index. This method simply interpolates all values of all
560  * keylines between the given pair.
561  */
lerpnull562 internal fun lerp(from: KeylineList, to: KeylineList, fraction: Float): KeylineList {
563     val interpolatedKeylines = from.fastMapIndexed { i, k -> lerp(k, to[i], fraction) }
564     return KeylineList(interpolatedKeylines)
565 }
566