diff --git a/packages/SettingsLib/Spa/.idea/codeStyles/Project.xml b/packages/SettingsLib/Spa/.idea/codeStyles/Project.xml index cb757d34dfb48..9578fcf0f4cd5 100644 --- a/packages/SettingsLib/Spa/.idea/codeStyles/Project.xml +++ b/packages/SettingsLib/Spa/.idea/codeStyles/Project.xml @@ -1,6 +1,15 @@ + + diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/SettingsPagerPage.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/SettingsPagerPage.kt index 8e2c3d6667a9d..df48517fb1fb9 100644 --- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/SettingsPagerPage.kt +++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/SettingsPagerPage.kt @@ -25,8 +25,11 @@ import com.android.settingslib.spa.framework.theme.SettingsTheme import com.android.settingslib.spa.widget.preference.Preference import com.android.settingslib.spa.widget.preference.PreferenceModel import com.android.settingslib.spa.widget.scaffold.SettingsPager +import com.android.settingslib.spa.widget.scaffold.SettingsScaffold import com.android.settingslib.spa.widget.ui.PlaceholderTitle +private const val TITLE = "Sample SettingsPager" + object SettingsPagerPageProvider : SettingsPageProvider { override val name = "SettingsPager" @@ -38,7 +41,7 @@ object SettingsPagerPageProvider : SettingsPageProvider { @Composable fun EntryItem() { Preference(object : PreferenceModel { - override val title = "Sample SettingsPager" + override val title = TITLE override val onClick = navigator(name) }) } @@ -46,8 +49,10 @@ object SettingsPagerPageProvider : SettingsPageProvider { @Composable private fun SettingsPagerPage() { - SettingsPager(listOf("Personal", "Work")) { - PlaceholderTitle("Page $it") + SettingsScaffold(title = TITLE) { + SettingsPager(listOf("Personal", "Work")) { + PlaceholderTitle("Page $it") + } } } 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 new file mode 100644 index 0000000000000..bf338574c42d6 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Pager.kt @@ -0,0 +1,339 @@ +/* + * 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.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +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.platform.LocalLayoutDirection +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.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 + val layoutDirection = LocalLayoutDirection.current + LaunchedEffect(density, contentPadding, isVertical, layoutDirection, reverseLayout, state) { + with(density) { + // this should be exposed on LazyListLayoutInfo instead. b/200920410 + state.afterContentPadding = if (isVertical) { + if (!reverseLayout) { + contentPadding.calculateBottomPadding() + } else { + contentPadding.calculateTopPadding() + } + } else { + if (!reverseLayout) { + contentPadding.calculateEndPadding(layoutDirection) + } else { + contentPadding.calculateStartPadding(layoutDirection) + } + }.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, + ) + } + + if (isVertical) { + LazyColumn( + state = state.lazyListState, + verticalArrangement = Arrangement.spacedBy(itemSpacing, verticalAlignment), + horizontalAlignment = horizontalAlignment, + 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 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, + 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, +) : 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. + */ +@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 new file mode 100644 index 0000000000000..21ba11739af05 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/PagerState.kt @@ -0,0 +1,318 @@ +/* + * 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 - afterContentPadding) + end - start + } + } + + internal var afterContentPadding = 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 { + // We coerce since itemSpacing can make the offset > 1f. + // We don't want to count spacing in the offset so cap it to 1f + (-it.offset / it.size.toFloat()).coerceIn(-1f, 1f) + } ?: 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 * 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 + lazyListState.animateScrollToItem( + index = page, + scrollOffset = (currentSize * pageOffset).roundToInt() + ) + + // The target should be visible now + target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { + it.index == page + } + + if (target != null && target.size != 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 * 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 * 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 >= 0 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/RegularScaffold.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/RegularScaffold.kt index 3325d5382f2e6..9a17b2a8cb78d 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/RegularScaffold.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/RegularScaffold.kt @@ -22,15 +22,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallTopAppBar -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.android.settingslib.spa.framework.theme.SettingsDimension import com.android.settingslib.spa.framework.theme.SettingsTheme /** @@ -39,28 +34,13 @@ import com.android.settingslib.spa.framework.theme.SettingsTheme * For example, this is for the pages with some preferences and is scrollable when the items out of * the screen. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RegularScaffold( title: String, actions: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { - Scaffold( - topBar = { - SmallTopAppBar( - title = { - Text( - text = title, - modifier = Modifier.padding(SettingsDimension.itemPaddingAround), - ) - }, - navigationIcon = { NavigateUp() }, - actions = actions, - colors = settingsTopAppBarColors(), - ) - }, - ) { paddingValues -> + SettingsScaffold(title, actions) { paddingValues -> Column(Modifier.verticalScroll(rememberScrollState())) { Spacer(Modifier.padding(paddingValues)) content() @@ -68,12 +48,6 @@ fun RegularScaffold( } } -@Composable -internal fun settingsTopAppBarColors() = TopAppBarDefaults.largeTopAppBarColors( - containerColor = SettingsTheme.colorScheme.surfaceHeader, - scrolledContainerColor = SettingsTheme.colorScheme.surfaceHeader, -) - @Preview @Composable private fun RegularScaffoldPreview() { 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 1ec2390741ed2..e0e9b951065bc 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 @@ -20,13 +20,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.TabRow import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +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 @Composable fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit) { @@ -37,10 +38,11 @@ fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit } Column { - var currentPage by rememberSaveable { mutableStateOf(0) } + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState() TabRow( - selectedTabIndex = currentPage, + selectedTabIndex = pagerState.currentPage, modifier = Modifier.padding(horizontal = SettingsDimension.itemPaddingEnd), containerColor = Color.Transparent, indicator = {}, @@ -49,12 +51,19 @@ fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit titles.forEachIndexed { page, title -> SettingsTab( title = title, - selected = currentPage == page, - onClick = { currentPage = page }, + selected = pagerState.currentPage == page, + currentPageOffset = pagerState.currentPageOffset.absoluteValue, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(page) + } + }, ) } } - content(currentPage) + HorizontalPager(count = titles.size, state = pagerState) { page -> + content(page) + } } } diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffold.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffold.kt new file mode 100644 index 0000000000000..ee453f2466239 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffold.kt @@ -0,0 +1,73 @@ +/* + * 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.widget.scaffold + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.android.settingslib.spa.framework.theme.SettingsDimension +import com.android.settingslib.spa.framework.theme.SettingsTheme + +/** + * A [Scaffold] which content is can be full screen when needed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScaffold( + title: String, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + topBar = { + SmallTopAppBar( + title = { + Text( + text = title, + modifier = Modifier.padding(SettingsDimension.itemPaddingAround), + ) + }, + navigationIcon = { NavigateUp() }, + actions = actions, + colors = settingsTopAppBarColors(), + ) + }, + content = content, + ) +} + +@Composable +internal fun settingsTopAppBarColors() = TopAppBarDefaults.largeTopAppBarColors( + containerColor = SettingsTheme.colorScheme.surfaceHeader, + scrolledContainerColor = SettingsTheme.colorScheme.surfaceHeader, +) + +@Preview +@Composable +private fun SettingsScaffoldPreview() { + SettingsTheme { + SettingsScaffold(title = "Display") {} + } +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt index 16d8dbc9bf74a..30a4349e942fd 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.android.settingslib.spa.framework.theme.SettingsShape @@ -35,8 +36,12 @@ import com.android.settingslib.spa.framework.theme.SettingsTheme internal fun SettingsTab( title: String, selected: Boolean, + currentPageOffset: Float, onClick: () -> Unit, ) { + // Shows a color transition during pager scroll. + // 0f -> Selected, 1f -> Not selected + val colorFraction = if (selected) (currentPageOffset * 2).coerceAtMost(1f) else 1f Tab( selected = selected, onClick = onClick, @@ -44,29 +49,33 @@ internal fun SettingsTab( .height(48.dp) .padding(horizontal = 4.dp, vertical = 6.dp) .clip(SettingsShape.CornerMedium) - .background(color = when { - selected -> SettingsTheme.colorScheme.primaryContainer - else -> SettingsTheme.colorScheme.surface - }), + .background( + color = lerp( + start = SettingsTheme.colorScheme.primaryContainer, + stop = SettingsTheme.colorScheme.surface, + fraction = colorFraction, + ), + ), ) { Text( text = title, style = MaterialTheme.typography.labelLarge, - color = when { - selected -> SettingsTheme.colorScheme.onPrimaryContainer - else -> SettingsTheme.colorScheme.secondaryText - }, + color = lerp( + start = SettingsTheme.colorScheme.onPrimaryContainer, + stop = SettingsTheme.colorScheme.secondaryText, + fraction = colorFraction, + ), ) } } @Preview @Composable -private fun SettingsTabPreview() { +fun SettingsTabPreview() { SettingsTheme { Column { - SettingsTab(title = "Personal", selected = true) {} - SettingsTab(title = "Work", selected = false) {} + SettingsTab(title = "Personal", selected = true, currentPageOffset = 0f) {} + SettingsTab(title = "Work", selected = false, currentPageOffset = 0f) {} } } }