Merge "Import Accompanist Pager"

This commit is contained in:
Chaohui Wang
2022-08-24 10:48:19 +00:00
committed by Android (Google) Code Review
8 changed files with 786 additions and 50 deletions

View File

@@ -1,6 +1,15 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>

View File

@@ -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")
}
}
}

View File

@@ -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
}

View File

@@ -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<PagerState, *> = listSaver(
save = {
listOf<Any>(
it.currentPage,
)
},
restore = {
PagerState(
currentPage = it[0] as Int,
)
}
)
}
}

View File

@@ -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() {

View File

@@ -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<String>, content: @Composable (page: Int) -> Unit) {
@@ -37,10 +38,11 @@ fun SettingsPager(titles: List<String>, 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<String>, 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)
}
}
}

View File

@@ -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") {}
}
}

View File

@@ -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) {}
}
}
}