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