diff --git a/packages/SettingsLib/Spa/spa/build.gradle b/packages/SettingsLib/Spa/spa/build.gradle index 16a4a9b2dcaee..640aa01f307f9 100644 --- a/packages/SettingsLib/Spa/spa/build.gradle +++ b/packages/SettingsLib/Spa/spa/build.gradle @@ -106,7 +106,6 @@ task coverageReport(type: JacocoReport, dependsOn: "connectedDebugAndroidTest") // Excludes files forked from Accompanist. "com/android/settingslib/spa/framework/compose/DrawablePainter*", - "com/android/settingslib/spa/framework/compose/Pager*", // Excludes inline functions, which is not covered in Jacoco reports. "com/android/settingslib/spa/framework/util/Collections*", diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Pager.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Pager.kt deleted file mode 100644 index 392089ae7989b..0000000000000 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Pager.kt +++ /dev/null @@ -1,329 +0,0 @@ -/* - * 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.settingslib.spa.framework.compose - -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.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filter - -/** - * ************************************************************************************************* - * This file was forked from - * https://github.com/google/accompanist/blob/main/pager/src/main/java/com/google/accompanist/pager/Pager.kt - * and will be removed once it lands in AndroidX. - */ - -/** - * 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 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]. - */ -@Composable -fun HorizontalPager( - count: Int, - modifier: Modifier = Modifier, - state: PagerState = rememberPagerState(), - reverseLayout: Boolean = false, - itemSpacing: Dp = 0.dp, - contentPadding: PaddingValues = PaddingValues(0.dp), - verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, - key: ((page: Int) -> Any)? = null, - content: @Composable PagerScope.(page: Int) -> Unit, -) { - Pager( - count = count, - state = state, - modifier = modifier, - isVertical = false, - reverseLayout = reverseLayout, - itemSpacing = itemSpacing, - verticalAlignment = verticalAlignment, - 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 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]. - */ -@Composable -fun VerticalPager( - count: Int, - modifier: Modifier = Modifier, - state: PagerState = rememberPagerState(), - reverseLayout: Boolean = false, - itemSpacing: Dp = 0.dp, - contentPadding: PaddingValues = PaddingValues(0.dp), - horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, - key: ((page: Int) -> Any)? = null, - content: @Composable PagerScope.(page: Int) -> Unit, -) { - Pager( - count = count, - state = state, - modifier = modifier, - isVertical = true, - reverseLayout = reverseLayout, - itemSpacing = itemSpacing, - horizontalAlignment = horizontalAlignment, - key = key, - contentPadding = contentPadding, - content = content - ) -} - -@Composable -internal fun Pager( - count: Int, - modifier: Modifier, - state: PagerState, - reverseLayout: Boolean, - itemSpacing: Dp, - isVertical: Boolean, - 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" } - - 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 } - // initially isScrollInProgress is false as well and we want to start receiving - // the events only after the real scroll happens. - .drop(1) - .collect { state.onScrollFinished() } - } - LaunchedEffect(state) { - snapshotFlow { state.mostVisiblePageLayoutInfo?.index } - .distinctUntilChanged() - .collect { state.updateCurrentPageBasedOnLazyListState() } - } - val density = LocalDensity.current - LaunchedEffect(density, state, itemSpacing) { - with(density) { state.itemSpacing = itemSpacing.roundToPx() } - } - - 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 = remember(isVertical) { - ConsumeFlingNestedScrollConnection( - consumeHorizontal = !isVertical, - consumeVertical = isVertical, - pagerState = state, - ) - } - - if (isVertical) { - LazyColumn( - state = state.lazyListState, - verticalArrangement = Arrangement.spacedBy(itemSpacing, verticalAlignment), - horizontalAlignment = horizontalAlignment, - reverseLayout = reverseLayout, - contentPadding = contentPadding, - userScrollEnabled = false, - 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 height to be <= than the height of the pager. - .fillParentMaxHeight() - .wrapContentSize() - ) { - pagerScope.content(page) - } - } - } - } else { - LazyRow( - state = state.lazyListState, - verticalAlignment = verticalAlignment, - horizontalArrangement = Arrangement.spacedBy(itemSpacing, horizontalAlignment), - reverseLayout = reverseLayout, - contentPadding = contentPadding, - userScrollEnabled = false, - 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 width to be <= than the width of the pager. - .fillParentMaxWidth() - .wrapContentSize() - ) { - pagerScope.content(page) - } - } - } - } -} - -private class ConsumeFlingNestedScrollConnection( - private val consumeHorizontal: Boolean, - private val consumeVertical: Boolean, - private val pagerState: PagerState, -) : 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 { - return if (pagerState.currentPageOffset != 0f) { - // The Pager is already scrolling. This means that a nested scroll child was - // scrolled to end, and the Pager can use this fling - Velocity.Zero - } else { - // A nested scroll child is still scrolling. We can consume all post fling - // velocity on the main-axis so that it doesn't propagate up to the Pager - 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. - */ -@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 -} - -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 - */ -fun PagerScope.calculateCurrentOffsetForPage(page: Int): Float { - return (currentPage - page) + currentPageOffset -} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/PagerState.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/PagerState.kt deleted file mode 100644 index 480335dacd368..0000000000000 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/PagerState.kt +++ /dev/null @@ -1,316 +0,0 @@ -/* - * 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.settingslib.spa.framework.compose - -import androidx.annotation.FloatRange -import androidx.annotation.IntRange -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.gestures.ScrollScope -import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import kotlin.math.abs -import kotlin.math.absoluteValue -import kotlin.math.roundToInt - -/** - * ************************************************************************************************* - * This file was forked from - * https://github.com/google/accompanist/blob/main/pager/src/main/java/com/google/accompanist/pager/PagerState.kt - * and will be removed once it lands in AndroidX. - */ - -/** - * Creates a [PagerState] that is remembered across compositions. - * - * Changes to the provided values for [initialPage] will **not** result in the state being - * recreated or changed in any way if it has already - * been created. - * - * @param initialPage the initial value for [PagerState.currentPage] - */ -@Composable -fun rememberPagerState( - @IntRange(from = 0) initialPage: Int = 0, -): PagerState = rememberSaveable(saver = PagerState.Saver) { - PagerState( - currentPage = initialPage, - ) -} - -/** - * A state object that can be hoisted to control and observe scrolling for [HorizontalPager]. - * - * In most cases, this will be created via [rememberPagerState]. - * - * @param currentPage the initial value for [PagerState.currentPage] - */ -@Stable -class PagerState( - @IntRange(from = 0) currentPage: Int = 0, -) : ScrollableState { - // Should this be public? - internal val lazyListState = LazyListState(firstVisibleItemIndex = currentPage) - - private var _currentPage by mutableStateOf(currentPage) - - // finds the page which has larger visible area within the viewport not including paddings - internal val mostVisiblePageLayoutInfo: LazyListItemInfo? - get() { - val layoutInfo = lazyListState.layoutInfo - return layoutInfo.visibleItemsInfo.maxByOrNull { - val start = maxOf(it.offset, 0) - val end = minOf( - it.offset + it.size, - layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding - ) - end - start - } - } - - internal var itemSpacing by mutableStateOf(0) - - private val currentPageLayoutInfo: LazyListItemInfo? - get() = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull { - it.index == currentPage - } - - /** - * [InteractionSource] that will be used to dispatch drag events when this - * list is being dragged. If you want to know whether the fling (or animated scroll) is in - * progress, use [isScrollInProgress]. - */ - val interactionSource: InteractionSource - get() = lazyListState.interactionSource - - /** - * The number of pages to display. - */ - @get:IntRange(from = 0) - val pageCount: Int by derivedStateOf { - lazyListState.layoutInfo.totalItemsCount - } - - /** - * The index of the currently selected page. This may not be the page which is - * currently displayed on screen. - * - * To update the scroll position, use [scrollToPage] or [animateScrollToPage]. - */ - @get:IntRange(from = 0) - var currentPage: Int - get() = _currentPage - internal set(value) { - if (value != _currentPage) { - _currentPage = value - } - } - - /** - * The current offset from the start of [currentPage], as a ratio of the page width. - * - * To update the scroll position, use [scrollToPage] or [animateScrollToPage]. - */ - val currentPageOffset: Float by derivedStateOf { - currentPageLayoutInfo?.let { - (-it.offset / (it.size + itemSpacing).toFloat()).coerceIn(-0.5f, 0.5f) - } ?: 0f - } - - /** - * The target page for any on-going animations. - */ - private var animationTargetPage: Int? by mutableStateOf(null) - - /** - * Animate (smooth scroll) to the given page to the middle of the viewport. - * - * Cancels the currently running scroll, if any, and suspends until the cancellation is - * complete. - * - * @param page the page to animate to. Must be >= 0. - * @param pageOffset the percentage of the page size to offset, from the start of [page]. - * Must be in the range -1f..1f. - */ - suspend fun animateScrollToPage( - @IntRange(from = 0) page: Int, - @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f, - ) { - requireCurrentPage(page, "page") - requireCurrentPageOffset(pageOffset, "pageOffset") - try { - animationTargetPage = page - - // pre-jump to nearby item for long jumps as an optimization - // the same trick is done in ViewPager2 - val oldPage = lazyListState.firstVisibleItemIndex - if (abs(page - oldPage) > 3) { - lazyListState.scrollToItem(if (page > oldPage) page - 3 else page + 3) - } - - if (pageOffset.absoluteValue <= 0.005f) { - // If the offset is (close to) zero, just call animateScrollToItem and we're done - lazyListState.animateScrollToItem(index = page) - } else { - // Else we need to figure out what the offset is in pixels... - lazyListState.scroll { } // this will await for the first layout. - val layoutInfo = lazyListState.layoutInfo - var target = layoutInfo.visibleItemsInfo - .firstOrNull { it.index == page } - - if (target != null) { - // If we have access to the target page layout, we can calculate the pixel - // offset from the size - lazyListState.animateScrollToItem( - index = page, - scrollOffset = ((target.size + itemSpacing) * pageOffset).roundToInt() - ) - } else if (layoutInfo.visibleItemsInfo.isNotEmpty()) { - // If we don't, we use the current page size as a guide - val currentSize = layoutInfo.visibleItemsInfo.first().size + itemSpacing - lazyListState.animateScrollToItem( - index = page, - scrollOffset = (currentSize * pageOffset).roundToInt() - ) - - // The target should be visible now - target = layoutInfo.visibleItemsInfo.firstOrNull { it.index == page } - - if (target != null && target.size + itemSpacing != currentSize) { - // If the size we used for calculating the offset differs from the actual - // target page size, we need to scroll again. This doesn't look great, - // but there's not much else we can do. - lazyListState.animateScrollToItem( - index = page, - scrollOffset = ((target.size + itemSpacing) * pageOffset).roundToInt() - ) - } - } - } - } finally { - // We need to manually call this, as the `animateScrollToItem` call above will happen - // in 1 frame, which is usually too fast for the LaunchedEffect in Pager to detect - // the change. This is especially true when running unit tests. - onScrollFinished() - } - } - - /** - * Instantly brings the item at [page] to the middle of the viewport. - * - * Cancels the currently running scroll, if any, and suspends until the cancellation is - * complete. - * - * @param page the page to snap to. Must be >= 0. - * @param pageOffset the percentage of the page size to offset, from the start of [page]. - * Must be in the range -1f..1f. - */ - suspend fun scrollToPage( - @IntRange(from = 0) page: Int, - @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f, - ) { - requireCurrentPage(page, "page") - requireCurrentPageOffset(pageOffset, "pageOffset") - try { - animationTargetPage = page - - // First scroll to the given page. It will now be laid out at offset 0 - lazyListState.scrollToItem(index = page) - updateCurrentPageBasedOnLazyListState() - - // If we have a start spacing, we need to offset (scroll) by that too - if (pageOffset.absoluteValue > 0.0001f) { - currentPageLayoutInfo?.let { - scroll { - scrollBy((it.size + itemSpacing) * pageOffset) - } - } - } - } finally { - // We need to manually call this, as the `scroll` call above will happen in 1 frame, - // which is usually too fast for the LaunchedEffect in Pager to detect the change. - // This is especially true when running unit tests. - onScrollFinished() - } - } - - internal fun updateCurrentPageBasedOnLazyListState() { - // Then update the current page to our layout page - mostVisiblePageLayoutInfo?.let { - currentPage = it.index - } - } - - internal fun onScrollFinished() { - // Clear the animation target page - animationTargetPage = null - } - - override suspend fun scroll( - scrollPriority: MutatePriority, - block: suspend ScrollScope.() -> Unit - ) = lazyListState.scroll(scrollPriority, block) - - override fun dispatchRawDelta(delta: Float): Float { - return lazyListState.dispatchRawDelta(delta) - } - - override val isScrollInProgress: Boolean - get() = lazyListState.isScrollInProgress - - override fun toString(): String = "PagerState(" + - "pageCount=$pageCount, " + - "currentPage=$currentPage, " + - "currentPageOffset=$currentPageOffset" + - ")" - - private fun requireCurrentPage(value: Int, name: String) { - require(value >= 0) { "$name[$value] must be >= 0" } - } - - private fun requireCurrentPageOffset(value: Float, name: String) { - require(value in -1f..1f) { "$name must be >= -1 and <= 1" } - } - - companion object { - /** - * The default [Saver] implementation for [PagerState]. - */ - val Saver: Saver = listSaver( - save = { - listOf( - it.currentPage, - ) - }, - restore = { - PagerState( - currentPage = it[0] as Int, - ) - } - ) - } -} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt index e0e9b951065bc..c6e13a14063f3 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt @@ -16,19 +16,21 @@ package com.android.settingslib.spa.widget.scaffold +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.TabRow import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.android.settingslib.spa.framework.compose.HorizontalPager -import com.android.settingslib.spa.framework.compose.rememberPagerState import com.android.settingslib.spa.framework.theme.SettingsDimension import kotlin.math.absoluteValue import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationApi::class) @Composable fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit) { check(titles.isNotEmpty()) @@ -52,7 +54,7 @@ fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit SettingsTab( title = title, selected = pagerState.currentPage == page, - currentPageOffset = pagerState.currentPageOffset.absoluteValue, + currentPageOffset = pagerState.currentPageOffsetFraction.absoluteValue, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(page) @@ -62,7 +64,7 @@ fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit } } - HorizontalPager(count = titles.size, state = pagerState) { page -> + HorizontalPager(pageCount = titles.size, state = pagerState) { page -> content(page) } }