Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit d0d6108d authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Automerger Merge Worker
Browse files

Merge changes from topic "move-gallery" into tm-qpr-dev am: d5cbd9a7

parents 03a4e9a0 d5cbd9a7
Loading
Loading
Loading
Loading
+356 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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 com.android.systemui.compose.layout.pager

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

/** Library-wide switch to turn on debug logging. */
internal const val DebugLog = false

@RequiresOptIn(message = "Accompanist Pager is experimental. The API may be changed in the future.")
@Retention(AnnotationRetention.BINARY)
annotation class ExperimentalPagerApi

/** Contains the default values used by [HorizontalPager] and [VerticalPager]. */
@ExperimentalPagerApi
object PagerDefaults {
    /**
     * Remember the default [FlingBehavior] that represents the scroll curve.
     *
     * @param state The [PagerState] to update.
     * @param decayAnimationSpec The decay animation spec to use for decayed flings.
     * @param snapAnimationSpec The animation spec to use when snapping.
     */
    @Composable
    fun flingBehavior(
        state: PagerState,
        decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
        snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
    ): FlingBehavior =
        rememberSnappingFlingBehavior(
            lazyListState = state.lazyListState,
            decayAnimationSpec = decayAnimationSpec,
            snapAnimationSpec = snapAnimationSpec,
        )

    @Deprecated(
        "Replaced with PagerDefaults.flingBehavior()",
        ReplaceWith("PagerDefaults.flingBehavior(state, decayAnimationSpec, snapAnimationSpec)")
    )
    @Composable
    fun rememberPagerFlingConfig(
        state: PagerState,
        decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
        snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
    ): FlingBehavior = flingBehavior(state, decayAnimationSpec, snapAnimationSpec)
}

/**
 * A horizontally scrolling layout that allows users to flip between items to the left and right.
 *
 * @sample com.google.accompanist.sample.pager.HorizontalPagerSample
 *
 * @param count the number of pages.
 * @param modifier the modifier to apply to this layout.
 * @param state the state object to be used to control or observe the pager's state.
 * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
 * composed from the end to the start and [PagerState.currentPage] == 0 will mean the first item is
 * located at the end.
 * @param itemSpacing horizontal spacing to add between items.
 * @param flingBehavior logic describing fling behavior.
 * @param key the scroll position will be maintained based on the key, which means if you add/remove
 * items before the current visible item the item with the given key will be kept as the first
 * visible one.
 * @param content a block which describes the content. Inside this block you can reference
 * [PagerScope.currentPage] and other properties in [PagerScope].
 */
@ExperimentalPagerApi
@Composable
fun HorizontalPager(
    count: Int,
    modifier: Modifier = Modifier,
    state: PagerState = rememberPagerState(),
    reverseLayout: Boolean = false,
    itemSpacing: Dp = 0.dp,
    flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(state),
    verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
    key: ((page: Int) -> Any)? = null,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    content: @Composable PagerScope.(page: Int) -> Unit,
) {
    Pager(
        count = count,
        state = state,
        modifier = modifier,
        isVertical = false,
        reverseLayout = reverseLayout,
        itemSpacing = itemSpacing,
        verticalAlignment = verticalAlignment,
        flingBehavior = flingBehavior,
        key = key,
        contentPadding = contentPadding,
        content = content
    )
}

/**
 * A vertically scrolling layout that allows users to flip between items to the top and bottom.
 *
 * @sample com.google.accompanist.sample.pager.VerticalPagerSample
 *
 * @param count the number of pages.
 * @param modifier the modifier to apply to this layout.
 * @param state the state object to be used to control or observe the pager's state.
 * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
 * composed from the bottom to the top and [PagerState.currentPage] == 0 will mean the first item is
 * located at the bottom.
 * @param itemSpacing vertical spacing to add between items.
 * @param flingBehavior logic describing fling behavior.
 * @param key the scroll position will be maintained based on the key, which means if you add/remove
 * items before the current visible item the item with the given key will be kept as the first
 * visible one.
 * @param content a block which describes the content. Inside this block you can reference
 * [PagerScope.currentPage] and other properties in [PagerScope].
 */
@ExperimentalPagerApi
@Composable
fun VerticalPager(
    count: Int,
    modifier: Modifier = Modifier,
    state: PagerState = rememberPagerState(),
    reverseLayout: Boolean = false,
    itemSpacing: Dp = 0.dp,
    flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(state),
    horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    key: ((page: Int) -> Any)? = null,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    content: @Composable PagerScope.(page: Int) -> Unit,
) {
    Pager(
        count = count,
        state = state,
        modifier = modifier,
        isVertical = true,
        reverseLayout = reverseLayout,
        itemSpacing = itemSpacing,
        horizontalAlignment = horizontalAlignment,
        flingBehavior = flingBehavior,
        key = key,
        contentPadding = contentPadding,
        content = content
    )
}

@ExperimentalPagerApi
@Composable
internal fun Pager(
    count: Int,
    modifier: Modifier,
    state: PagerState,
    reverseLayout: Boolean,
    itemSpacing: Dp,
    isVertical: Boolean,
    flingBehavior: FlingBehavior,
    key: ((page: Int) -> Any)?,
    contentPadding: PaddingValues,
    verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
    horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    content: @Composable PagerScope.(page: Int) -> Unit,
) {
    require(count >= 0) { "pageCount must be >= 0" }

    // Provide our PagerState with access to the SnappingFlingBehavior animation target
    // TODO: can this be done in a better way?
    state.flingAnimationTarget = { (flingBehavior as? SnappingFlingBehavior)?.animationTarget }

    LaunchedEffect(count) {
        state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0)
    }

    // Once a fling (scroll) has finished, notify the state
    LaunchedEffect(state) {
        // When a 'scroll' has finished, notify the state
        snapshotFlow { state.isScrollInProgress }
            .filter { !it }
            .collect { state.onScrollFinished() }
    }

    val pagerScope = remember(state) { PagerScopeImpl(state) }

    // We only consume nested flings in the main-axis, allowing cross-axis flings to propagate
    // as normal
    val consumeFlingNestedScrollConnection =
        ConsumeFlingNestedScrollConnection(
            consumeHorizontal = !isVertical,
            consumeVertical = isVertical,
        )

    if (isVertical) {
        LazyColumn(
            state = state.lazyListState,
            verticalArrangement = Arrangement.spacedBy(itemSpacing, verticalAlignment),
            horizontalAlignment = horizontalAlignment,
            flingBehavior = flingBehavior,
            reverseLayout = reverseLayout,
            contentPadding = contentPadding,
            modifier = modifier,
        ) {
            items(
                count = count,
                key = key,
            ) { page ->
                Box(
                    Modifier
                        // We don't any nested flings to continue in the pager, so we add a
                        // connection which consumes them.
                        // See: https://github.com/google/accompanist/issues/347
                        .nestedScroll(connection = consumeFlingNestedScrollConnection)
                        // Constraint the content to be <= than the size of the pager.
                        .fillParentMaxHeight()
                        .wrapContentSize()
                ) { pagerScope.content(page) }
            }
        }
    } else {
        LazyRow(
            state = state.lazyListState,
            verticalAlignment = verticalAlignment,
            horizontalArrangement = Arrangement.spacedBy(itemSpacing, horizontalAlignment),
            flingBehavior = flingBehavior,
            reverseLayout = reverseLayout,
            contentPadding = contentPadding,
            modifier = modifier,
        ) {
            items(
                count = count,
                key = key,
            ) { page ->
                Box(
                    Modifier
                        // We don't any nested flings to continue in the pager, so we add a
                        // connection which consumes them.
                        // See: https://github.com/google/accompanist/issues/347
                        .nestedScroll(connection = consumeFlingNestedScrollConnection)
                        // Constraint the content to be <= than the size of the pager.
                        .fillParentMaxWidth()
                        .wrapContentSize()
                ) { pagerScope.content(page) }
            }
        }
    }
}

private class ConsumeFlingNestedScrollConnection(
    private val consumeHorizontal: Boolean,
    private val consumeVertical: Boolean,
) : NestedScrollConnection {
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset =
        when (source) {
            // We can consume all resting fling scrolls so that they don't propagate up to the
            // Pager
            NestedScrollSource.Fling -> available.consume(consumeHorizontal, consumeVertical)
            else -> Offset.Zero
        }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        // We can consume all post fling velocity on the main-axis
        // so that it doesn't propagate up to the Pager
        return available.consume(consumeHorizontal, consumeVertical)
    }
}

private fun Offset.consume(
    consumeHorizontal: Boolean,
    consumeVertical: Boolean,
): Offset =
    Offset(
        x = if (consumeHorizontal) this.x else 0f,
        y = if (consumeVertical) this.y else 0f,
    )

private fun Velocity.consume(
    consumeHorizontal: Boolean,
    consumeVertical: Boolean,
): Velocity =
    Velocity(
        x = if (consumeHorizontal) this.x else 0f,
        y = if (consumeVertical) this.y else 0f,
    )

/** Scope for [HorizontalPager] content. */
@ExperimentalPagerApi
@Stable
interface PagerScope {
    /** Returns the current selected page */
    val currentPage: Int

    /** The current offset from the start of [currentPage], as a ratio of the page width. */
    val currentPageOffset: Float
}

@ExperimentalPagerApi
private class PagerScopeImpl(
    private val state: PagerState,
) : PagerScope {
    override val currentPage: Int
        get() = state.currentPage
    override val currentPageOffset: Float
        get() = state.currentPageOffset
}

/**
 * Calculate the offset for the given [page] from the current scroll position. This is useful when
 * using the scroll position to apply effects or animations to items.
 *
 * The returned offset can positive or negative, depending on whether which direction the [page] is
 * compared to the current scroll position.
 *
 * @sample com.google.accompanist.sample.pager.HorizontalPagerWithOffsetTransition
 */
@ExperimentalPagerApi
fun PagerScope.calculateCurrentOffsetForPage(page: Int): Float {
    return (currentPage + currentPageOffset) - page
}
+348 −0

File added.

Preview size limit exceeded, changes collapsed.

+270 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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 com.android.systemui.compose.layout.pager

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.core.spring
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlin.math.abs

/** Default values used for [SnappingFlingBehavior] & [rememberSnappingFlingBehavior]. */
internal object SnappingFlingBehaviorDefaults {
    /** TODO */
    val snapAnimationSpec: AnimationSpec<Float> = spring(stiffness = 600f)
}

/**
 * Create and remember a snapping [FlingBehavior] to be used with [LazyListState].
 *
 * TODO: move this to a new module and make it public
 *
 * @param lazyListState The [LazyListState] to update.
 * @param decayAnimationSpec The decay animation spec to use for decayed flings.
 * @param snapAnimationSpec The animation spec to use when snapping.
 */
@Composable
internal fun rememberSnappingFlingBehavior(
    lazyListState: LazyListState,
    decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
    snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
): SnappingFlingBehavior =
    remember(lazyListState, decayAnimationSpec, snapAnimationSpec) {
        SnappingFlingBehavior(
            lazyListState = lazyListState,
            decayAnimationSpec = decayAnimationSpec,
            snapAnimationSpec = snapAnimationSpec,
        )
    }

/**
 * A snapping [FlingBehavior] for [LazyListState]. Typically this would be created via
 * [rememberSnappingFlingBehavior].
 *
 * @param lazyListState The [LazyListState] to update.
 * @param decayAnimationSpec The decay animation spec to use for decayed flings.
 * @param snapAnimationSpec The animation spec to use when snapping.
 */
internal class SnappingFlingBehavior(
    private val lazyListState: LazyListState,
    private val decayAnimationSpec: DecayAnimationSpec<Float>,
    private val snapAnimationSpec: AnimationSpec<Float>,
) : FlingBehavior {
    /** The target item index for any on-going animations. */
    var animationTarget: Int? by mutableStateOf(null)
        private set

    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        val itemInfo = currentItemInfo ?: return initialVelocity

        // If the decay fling can scroll past the current item, fling with decay
        return if (decayAnimationSpec.canFlingPastCurrentItem(itemInfo, initialVelocity)) {
            performDecayFling(initialVelocity, itemInfo)
        } else {
            // Otherwise we 'spring' to current/next item
            performSpringFling(
                index =
                    when {
                        // If the velocity is greater than 1 item per second (velocity is px/s),
                        // spring
                        // in the relevant direction
                        initialVelocity > itemInfo.size -> {
                            (itemInfo.index + 1).coerceAtMost(
                                lazyListState.layoutInfo.totalItemsCount - 1
                            )
                        }
                        initialVelocity < -itemInfo.size -> itemInfo.index
                        // If the velocity is 0 (or less than the size of the item), spring to
                        // whichever item is closest to the snap point
                        itemInfo.offset < -itemInfo.size / 2 -> itemInfo.index + 1
                        else -> itemInfo.index
                    },
                initialVelocity = initialVelocity,
            )
        }
    }

    private suspend fun ScrollScope.performDecayFling(
        initialVelocity: Float,
        startItem: LazyListItemInfo,
    ): Float {
        val index =
            when {
                initialVelocity > 0 -> startItem.index + 1
                else -> startItem.index
            }
        val forward = index > (currentItemInfo?.index ?: return initialVelocity)

        // Update the animationTarget
        animationTarget = index

        var velocityLeft = initialVelocity
        var lastValue = 0f
        AnimationState(
                initialValue = 0f,
                initialVelocity = initialVelocity,
            )
            .animateDecay(decayAnimationSpec) {
                val delta = value - lastValue
                val consumed = scrollBy(delta)
                lastValue = value
                velocityLeft = this.velocity

                val current = currentItemInfo
                if (current == null) {
                    cancelAnimation()
                    return@animateDecay
                }

                if (
                    !forward &&
                        (current.index < index || current.index == index && current.offset >= 0)
                ) {
                    // 'snap back' to the item as we may have scrolled past it
                    scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
                    cancelAnimation()
                } else if (
                    forward &&
                        (current.index > index || current.index == index && current.offset <= 0)
                ) {
                    // 'snap back' to the item as we may have scrolled past it
                    scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
                    cancelAnimation()
                } else if (abs(delta - consumed) > 0.5f) {
                    // avoid rounding errors and stop if anything is unconsumed
                    cancelAnimation()
                }
            }
        animationTarget = null
        return velocityLeft
    }

    private suspend fun ScrollScope.performSpringFling(
        index: Int,
        scrollOffset: Int = 0,
        initialVelocity: Float = 0f,
    ): Float {
        // If we don't have a current layout, we can't snap
        val initialItem = currentItemInfo ?: return initialVelocity

        val forward = index > initialItem.index
        // We add 10% on to the size of the current item, to compensate for any item spacing, etc
        val target = (if (forward) initialItem.size else -initialItem.size) * 1.1f

        // Update the animationTarget
        animationTarget = index

        var velocityLeft = initialVelocity
        var lastValue = 0f
        AnimationState(
                initialValue = 0f,
                initialVelocity = initialVelocity,
            )
            .animateTo(
                targetValue = target,
                animationSpec = snapAnimationSpec,
            ) {
                // Springs can overshoot their target, clamp to the desired range
                val coercedValue =
                    if (forward) {
                        value.coerceAtMost(target)
                    } else {
                        value.coerceAtLeast(target)
                    }
                val delta = coercedValue - lastValue
                val consumed = scrollBy(delta)
                lastValue = coercedValue
                velocityLeft = this.velocity

                val current = currentItemInfo
                if (current == null) {
                    cancelAnimation()
                    return@animateTo
                }

                if (scrolledPastItem(initialVelocity, current, index, scrollOffset)) {
                    // If we've scrolled to/past the item, stop the animation. We may also need to
                    // 'snap back' to the item as we may have scrolled past it
                    scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
                    cancelAnimation()
                } else if (abs(delta - consumed) > 0.5f) {
                    // avoid rounding errors and stop if anything is unconsumed
                    cancelAnimation()
                }
            }
        animationTarget = null
        return velocityLeft
    }

    private fun LazyListState.calculateScrollOffsetToItem(index: Int): Int {
        return layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.offset ?: 0
    }

    private val currentItemInfo: LazyListItemInfo?
        get() =
            lazyListState.layoutInfo.visibleItemsInfo
                .asSequence()
                .filter { it.offset <= 0 && it.offset + it.size > 0 }
                .lastOrNull()
}

private fun scrolledPastItem(
    initialVelocity: Float,
    currentItem: LazyListItemInfo,
    targetIndex: Int,
    targetScrollOffset: Int = 0,
): Boolean {
    return if (initialVelocity > 0) {
        // forward
        currentItem.index > targetIndex ||
            (currentItem.index == targetIndex && currentItem.offset <= targetScrollOffset)
    } else {
        // backwards
        currentItem.index < targetIndex ||
            (currentItem.index == targetIndex && currentItem.offset >= targetScrollOffset)
    }
}

private fun DecayAnimationSpec<Float>.canFlingPastCurrentItem(
    currentItem: LazyListItemInfo,
    initialVelocity: Float,
): Boolean {
    val targetValue =
        calculateTargetValue(
            initialValue = currentItem.offset.toFloat(),
            initialVelocity = initialVelocity,
        )
    return when {
        // forward. We add 10% onto the size to cater for any item spacing
        initialVelocity > 0 -> targetValue <= -(currentItem.size * 1.1f)
        // backwards. We add 10% onto the size to cater for any item spacing
        else -> targetValue >= (currentItem.size * 0.1f)
    }
}
+142 −0

File added.

Preview size limit exceeded, changes collapsed.

+247 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading