Merge "Import Accompanist Pager"
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user