[go: nahoru, domu]

blob: 484c6ad08162c32bdb2faf16588fd86563ac9085 [file] [log] [blame]
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.carousel
import androidx.annotation.VisibleForTesting
import androidx.collection.FloatList
import androidx.collection.mutableFloatListOf
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt
/**
* Contains default values used across Strategies
*/
internal object StrategyDefaults {
val minSmallSize = 40.dp
val maxSmallSize = 56.dp
val anchorSize = 10.dp
}
/**
* Helper method to create a default start-aligned [Strategy] contained within the bounds of
* [availableSpace] and based on the given [Arrangement].
*
* @param availableSpace the available space to contain the [Strategy] within
* @param arrangement the arrangement containing information on the sizes and counts of the
* items in the [Strategy].
* @param anchorSize the size that the anchor keylines should be in the strategy. The smaller
* this is, the more the item will shrink as it moves off-screen.
*/
internal fun createStartAlignedStrategy(
availableSpace: Float,
arrangement: Arrangement,
anchorSize: Float,
): Strategy {
val keylineList = keylineListOf(availableSpace, CarouselAlignment.Start) {
add(anchorSize, isAnchor = true)
repeat(arrangement.largeCount) { add(arrangement.largeSize) }
repeat(arrangement.mediumCount) { add(arrangement.mediumSize) }
repeat(arrangement.smallCount) { add(arrangement.smallSize) }
add(anchorSize, isAnchor = true)
}
return Strategy.create(availableSpace, keylineList)
}
/**
* A class that provides [Strategy] instances to a scrollable component.
*
* [StrategyProvider.createStrategy] will be called any time properties which affect a carousel's
* arrangement change. It is the implementation's responsibility to create an arrangement for the
* given parameters and return a [Strategy] by calling [Strategy.create].
*/
internal sealed class StrategyProvider() {
/**
* Create and return a new [Strategy] for the given carousel size.
*
* @param density The current density value
* @param carouselMainAxisSize the size of the carousel in the main axis in pixels
* @param itemSpacing The spacing in between the items that are not a part of the item size
*/
internal abstract fun createStrategy(
density: Density,
carouselMainAxisSize: Float,
itemSpacing: Int,
): Strategy?
}
/**
* A class which supplies carousel with the appropriate [KeylineList] for any given scroll offset.
*
* All items in a carousel need the opportunity to pass through the focal keyline range. Depending
* on where the focal range is located within the scrolling container, some items, like those at
* the beginning or end of the list, might not reach the focal range. To account for this,
* [Strategy] manages shifting the focal keylines to the beginning of the list when scrolled an
* offset of 0 and the end of the list when scrolled to the list's max offset. [StrategyProvider]
* needs only to create a "default" [KeylineList] (where keylines should be placed when scrolling
* in the middle of the list) and call [Strategy.create] to have [Strategy] generate the steps
* needed to move the focal range to the start and end of the scroll container. When scrolling, the
* scrollable component can access the correct [KeylineList] for any given offset using
* [getKeylineListForScrollOffset].
*
* @param defaultKeylines the [KeylineList] used when anywhere in the center of the list
* @param startKeylineSteps a list of [KeylineList]s that will be moved through when approaching
* the beginning of the list
* @param endKeylineSteps a list o [KeylineList]s that will be moved through when appraoching
* the end of the list
* @param startShiftDistance the scroll distance it should take to move through all the steps in
* [startKeylineSteps]
* @param endShiftDistance the scroll distance it should take to move through all the steps in the
* [endKeylineSteps]
* @param startShiftPoints a list of floats between 0-1 that define the percentage of shift distance
* at which the start keyline step at the corresponding index should be used
* @param endShiftPoints a list of floats between 0-1 that define the percentage of shift distance
* at which the end keyline step at the corresponding index should be used
*/
internal class Strategy private constructor(
private val defaultKeylines: KeylineList,
private val startKeylineSteps: List<KeylineList>,
private val endKeylineSteps: List<KeylineList>,
private val startShiftDistance: Float,
private val endShiftDistance: Float,
private val startShiftPoints: FloatList,
private val endShiftPoints: FloatList
) {
/**
* The size of items when in focus and fully unmasked.
*/
internal val itemMainAxisSize = defaultKeylines.firstFocal.size
/**
* Returns the [KeylineList] that should be used for the current [scrollOffset].
*
* @param scrollOffset the current scroll offset of the scrollable component
* @param maxScrollOffset the maximum scroll offset
* @param roundToNearestStep true if the KeylineList returned should be a complete shift step
*/
internal fun getKeylineListForScrollOffset(
scrollOffset: Float,
maxScrollOffset: Float,
roundToNearestStep: Boolean = false
): KeylineList {
val startShiftOffset = startShiftDistance
val endShiftOffset = maxScrollOffset - endShiftDistance
// If we're not within either shift range, return the default keylines
if (scrollOffset in startShiftOffset..endShiftOffset) {
return defaultKeylines
}
var interpolation = lerp(
outputMin = 1f,
outputMax = 0f,
inputMin = 0f,
inputMax = startShiftOffset,
value = scrollOffset
)
var shiftPoints = startShiftPoints
var steps = startKeylineSteps
if (scrollOffset > endShiftOffset) {
interpolation = lerp(
outputMin = 0f,
outputMax = 1f,
inputMin = endShiftOffset,
inputMax = maxScrollOffset,
value = scrollOffset
)
shiftPoints = endShiftPoints
steps = endKeylineSteps
}
val shiftPointRange = getShiftPointRange(
steps.size,
shiftPoints,
interpolation
)
if (roundToNearestStep) {
val roundedStepIndex = if (shiftPointRange.steppedInterpolation.roundToInt() == 0) {
shiftPointRange.fromStepIndex
} else {
shiftPointRange.toStepIndex
}
return steps[roundedStepIndex]
}
return lerp(
steps[shiftPointRange.fromStepIndex],
steps[shiftPointRange.toStepIndex],
shiftPointRange.steppedInterpolation
)
}
@VisibleForTesting
internal fun getDefaultKeylines(): KeylineList {
return defaultKeylines
}
@VisibleForTesting
internal fun getEndKeylines(): KeylineList {
return endKeylineSteps.last()
}
@VisibleForTesting
internal fun getStartKeylines(): KeylineList {
return startKeylineSteps.last()
}
companion object {
/**
* Creates a new [Strategy] based on a default [keylineList].
*
* The [keylineList] passed to this method will be the keylines used when the carousel is
* scrolled anywhere in the middle of the list (not the beginning or end). From these
* default keylines, additional [KeylineList]s will be created which move the focal range
* to the beginning of the carousel container when scrolled to the beginning of the list and
* the end of the container when scrolled to the end of the list.
*
* @param carouselMainAxisSize the size of the carousel container in scrolling axis
* @param keylineList the default keylines that will be used to create the strategy
*/
fun create(
/** The size of the carousel in the main axis. */
carouselMainAxisSize: Float,
/** The keylines along the main axis */
keylineList: KeylineList
): Strategy {
val startKeylineSteps = getStartKeylineSteps(keylineList, carouselMainAxisSize)
val endKeylineSteps =
getEndKeylineSteps(keylineList, carouselMainAxisSize)
// TODO: Update this to use the first/last focal keylines to calculate shift?
val startShiftDistance = startKeylineSteps.last().first().unadjustedOffset -
keylineList.first().unadjustedOffset
val endShiftDistance = keylineList.last().unadjustedOffset -
endKeylineSteps.last().last().unadjustedOffset
return Strategy(
defaultKeylines = keylineList,
startKeylineSteps = startKeylineSteps,
endKeylineSteps = endKeylineSteps,
startShiftDistance = startShiftDistance,
endShiftDistance = endShiftDistance,
startShiftPoints = getStepInterpolationPoints(
startShiftDistance,
startKeylineSteps,
true
),
endShiftPoints = getStepInterpolationPoints(
endShiftDistance,
endKeylineSteps,
false
)
)
}
/**
* Generates discreet steps which move the focal range from its original position until
* it reaches the start of the carousel container.
*
* Each step can only move the focal range by one keyline at a time to ensure every
* item in the list passes through the focal range. Each step removes the keyline at the
* start of the container and re-inserts it after the focal range in an order that retains
* visual balance. This is repeated until the first focal keyline is at the start of the
* container. Re-inserting keylines after the focal range in a balanced way is done by
* looking at the size of they keyline next to the keyline that is being re-positioned
* and finding a match on the other side of the focal range.
*
* The first state in the returned list is always the default [KeylineList] while
* the last state will be the start state or the state that has the focal range at the
* beginning of the carousel.
*/
private fun getStartKeylineSteps(
defaultKeylines: KeylineList,
carouselMainAxisSize: Float
): List<KeylineList> {
val steps: MutableList<KeylineList> = mutableListOf()
steps.add(defaultKeylines)
if (defaultKeylines.isFirstFocalItemAtStartOfContainer()) {
return steps
}
val startIndex = defaultKeylines.firstNonAnchorIndex
val endIndex = defaultKeylines.firstFocalIndex
val numberOfSteps = endIndex - startIndex
// If there are no steps but we need to account for a cutoff, create a
// list of keylines shifted for the cutoff.
if (numberOfSteps <= 0 && defaultKeylines.firstFocal.cutoff > 0) {
steps.add(
moveKeylineAndCreateShiftedKeylineList(
from = defaultKeylines,
srcIndex = 0,
dstIndex = 0,
carouselMainAxisSize = carouselMainAxisSize
)
)
return steps
}
var i = 0
while (i < numberOfSteps) {
val prevStep = steps.last()
val originalItemIndex = startIndex + i
var dstIndex = defaultKeylines.lastIndex
if (originalItemIndex > 0) {
val originalNeighborBeforeSize = defaultKeylines[originalItemIndex - 1].size
dstIndex = prevStep.firstIndexAfterFocalRangeWithSize(
originalNeighborBeforeSize
) - 1
}
steps.add(
moveKeylineAndCreateShiftedKeylineList(
from = prevStep,
srcIndex = defaultKeylines.firstNonAnchorIndex,
dstIndex = dstIndex,
carouselMainAxisSize = carouselMainAxisSize
)
)
i++
}
return steps
}
/**
* Generates discreet steps which move the focal range from its original position until
* it reaches the end of the carousel container.
*
* Each step can only move the focal range by one keyline at a time to ensure every
* item in the list passes through the focal range. Each step removes the keyline at the
* end of the container and re-inserts it before the focal range in an order that retains
* visual balance. This is repeated until the last focal keyline is at the start of the
* container. Re-inserting keylines before the focal range in a balanced way is done by
* looking at the size of they keyline next to the keyline that is being re-positioned
* and finding a match on the other side of the focal range.
*
* The first state in the returned list is always the default [KeylineList] while
* the last state will be the end state or the state that has the focal range at the
* end of the carousel.
*/
private fun getEndKeylineSteps(
defaultKeylines: KeylineList,
carouselMainAxisSize: Float
): List<KeylineList> {
val steps: MutableList<KeylineList> = mutableListOf()
steps.add(defaultKeylines)
if (defaultKeylines.isLastFocalItemAtEndOfContainer(carouselMainAxisSize)) {
return steps
}
val startIndex = defaultKeylines.lastFocalIndex
val endIndex = defaultKeylines.lastNonAnchorIndex
val numberOfSteps = endIndex - startIndex
// If there are no steps but we need to account for a cutoff, create a
// list of keylines shifted for the cutoff.
if (numberOfSteps <= 0 && defaultKeylines.lastFocal.cutoff > 0) {
steps.add(
moveKeylineAndCreateShiftedKeylineList(
from = defaultKeylines,
srcIndex = 0,
dstIndex = 0,
carouselMainAxisSize = carouselMainAxisSize
)
)
return steps
}
var i = 0
while (i < numberOfSteps) {
val prevStep = steps.last()
val originalItemIndex = endIndex - i
var dstIndex = 0
if (originalItemIndex < defaultKeylines.lastIndex) {
val originalNeighborAfterSize = defaultKeylines[originalItemIndex + 1].size
dstIndex = prevStep.lastIndexBeforeFocalRangeWithSize(
originalNeighborAfterSize
) + 1
}
val keylines = moveKeylineAndCreateShiftedKeylineList(
from = prevStep,
srcIndex = defaultKeylines.lastNonAnchorIndex,
dstIndex = dstIndex,
carouselMainAxisSize = carouselMainAxisSize
)
steps.add(keylines)
i++
}
return steps
}
/**
* Returns a new [KeylineList] where the keyline at [srcIndex] is moved to [dstIndex] and
* with updated pivot and offsets that reflect any change in focal shift.
*/
private fun moveKeylineAndCreateShiftedKeylineList(
from: KeylineList,
srcIndex: Int,
dstIndex: Int,
carouselMainAxisSize: Float
): KeylineList {
// -1 if the pivot is shifting left/top, 1 if shifting right/bottom
val pivotDir = if (srcIndex > dstIndex) 1 else -1
val pivotDelta = (from[srcIndex].size - from[srcIndex].cutoff) * pivotDir
val newPivotIndex = from.pivotIndex + pivotDir
val newPivotOffset = from.pivot.offset + pivotDelta
return keylineListOf(carouselMainAxisSize, newPivotIndex, newPivotOffset) {
from.toMutableList()
.move(srcIndex, dstIndex)
.fastForEach { k -> add(k.size, k.isAnchor) }
}
}
/**
* Creates and returns a list of float values containing points between 0 and 1 that
* represent interpolation values for when the [KeylineList] at the corresponding index in
* [steps] should be visible.
*
* For example, if [steps] has a size of 4, this method will return an array of 4 float
* values that could look like [0, .33, .66, 1]. When interpolating through a list of
* [KeylineList]s, an interpolation value will be between 0-1. This interpolation will be
* used to find the range it falls within from this method's returned value. If
* interpolation is .25, that would fall between the 0 and .33, the 0th and 1st indices
* of the float array. Meaning the 0th and 1st items from [steps] should be the current
* [KeylineList]s being interpolated. This is an example with equally distributed values
* but these values will typically be unequally distributed since their size depends on
* the distance keylines shift between each step.
*
* @see [lerp] for more details on how interpolation points are used
* @see [getKeylineListForScrollOffset] for more details on how interpolation points
* are used
*
* @param totalShiftDistance the total distance keylines will shift between the first and
* last [KeylineList] of [steps]
* @param steps the steps to find interpolation points for
* @param isShiftingLeft true if this method should find interpolation points for shifting
* keylines to the left/top of a carousel, false if this method should find interpolation
* points for shifting keylines to the right/bottom of a carousel
* @return a list of floats, equal in size to [steps] that contains points between 0-1
* that align with when a [KeylineList] from [steps should be shown for a 0-1
* interpolation value
*/
private fun getStepInterpolationPoints(
totalShiftDistance: Float,
steps: List<KeylineList>,
isShiftingLeft: Boolean
): FloatList {
val points = mutableFloatListOf(0f)
if (totalShiftDistance == 0f) {
return points
}
(1 until steps.size).map { i ->
val prevKeylines = steps[i - 1]
val currKeylines = steps[i]
val distanceShifted = if (isShiftingLeft) {
currKeylines.first().unadjustedOffset - prevKeylines.first().unadjustedOffset
} else {
prevKeylines.last().unadjustedOffset - currKeylines.last().unadjustedOffset
}
val stepPercentage = distanceShifted / totalShiftDistance
val point = if (i == steps.lastIndex) 1f else points[i - 1] + stepPercentage
points.add(point)
}
return points
}
private data class ShiftPointRange(
val fromStepIndex: Int,
val toStepIndex: Int,
val steppedInterpolation: Float
)
private fun getShiftPointRange(
stepsCount: Int,
shiftPoint: FloatList,
interpolation: Float
): ShiftPointRange {
var lowerBounds = shiftPoint[0]
(1 until stepsCount).forEach { i ->
val upperBounds = shiftPoint[i]
if (interpolation <= upperBounds) {
return ShiftPointRange(
fromStepIndex = i - 1,
toStepIndex = i,
steppedInterpolation = lerp(0f, 1f, lowerBounds, upperBounds, interpolation)
)
}
lowerBounds = upperBounds
}
return ShiftPointRange(
fromStepIndex = 0,
toStepIndex = 0,
steppedInterpolation = 0f)
}
private fun MutableList<Keyline>.move(srcIndex: Int, dstIndex: Int): MutableList<Keyline> {
val keyline = get(srcIndex)
removeAt(srcIndex)
add(dstIndex, keyline)
return this
}
}
}
private fun lerp(
outputMin: Float,
outputMax: Float,
inputMin: Float,
inputMax: Float,
value: Float
): Float {
if (value <= inputMin) {
return outputMin
} else if (value >= inputMax) {
return outputMax
}
return lerp(outputMin, outputMax, (value - inputMin) / (inputMax - inputMin))
}