Migrate to M3 pull-to-refresh (#10164)

This commit is contained in:
Ivan Iskandar 2023-11-22 10:09:41 +07:00 committed by GitHub
parent 3006604922
commit d59cb9c1e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 344 additions and 120 deletions

View File

@ -73,7 +73,7 @@ fun ExtensionScreen(
PullRefresh(
refreshing = state.isRefreshing,
onRefresh = onRefresh,
enabled = !state.isLoading,
enabled = { !state.isLoading },
) {
when {
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))

View File

@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.zIndex
import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -70,6 +71,7 @@ fun TabbedScreen(
) {
PrimaryTabRow(
selectedTabIndex = state.currentPage,
modifier = Modifier.zIndex(1f),
) {
tabs.forEachIndexed { index, tab ->
Tab(

View File

@ -93,7 +93,7 @@ fun LibraryContent(
isRefreshing = false
}
},
enabled = notSelectionMode,
enabled = { notSelectionMode },
) {
LibraryPager(
state = pagerState,

View File

@ -7,7 +7,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import eu.kanade.presentation.category.visualName
import tachiyomi.domain.category.model.Category
import tachiyomi.presentation.core.components.material.TabText
@ -19,7 +21,9 @@ internal fun LibraryTabs(
getNumberOfMangaForCategory: (Category) -> Int?,
onTabItemClick: (Int) -> Unit,
) {
Column {
Column(
modifier = Modifier.zIndex(1f),
) {
PrimaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 0.dp,

View File

@ -364,8 +364,8 @@ private fun MangaScreenSmallImpl(
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = !isAnySelected,
indicatorPadding = WindowInsets.systemBars.only(WindowInsetsSides.Top).asPaddingValues(),
enabled = { !isAnySelected },
indicatorPadding = PaddingValues(top = topPadding),
) {
val layoutDirection = LocalLayoutDirection.current
VerticalFastScroller(
@ -529,97 +529,98 @@ fun MangaScreenLargeImpl(
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
var topBarHeight by remember { mutableIntStateOf(0) }
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = !isAnySelected,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
) {
val chapterListState = rememberLazyListState()
val internalOnBackPressed = {
if (isAnySelected) {
onAllChapterSelected(false)
} else {
onBackClicked()
}
val chapterListState = rememberLazyListState()
val internalOnBackPressed = {
if (isAnySelected) {
onAllChapterSelected(false)
} else {
onBackClicked()
}
BackHandler(onBack = internalOnBackPressed)
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
topBar = {
val selectedChapterCount = remember(chapters) {
chapters.count { it.selected }
Scaffold(
topBar = {
val selectedChapterCount = remember(chapters) {
chapters.count { it.selected }
}
MangaToolbar(
modifier = Modifier.onSizeChanged { topBarHeight = it.height },
title = state.manga.title,
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
backgroundAlphaProvider = { 1f },
hasFilters = state.filterActive,
onBackClicked = internalOnBackPressed,
onClickFilter = onFilterButtonClicked,
onClickShare = onShareClicked,
onClickDownload = onDownloadActionClicked,
onClickEditCategory = onEditCategoryClicked,
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
actionModeCounter = selectedChapterCount,
onSelectAll = { onAllChapterSelected(true) },
onInvertSelection = { onInvertSelection() },
)
},
bottomBar = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
val selectedChapters = remember(chapters) {
chapters.filter { it.selected }
}
MangaToolbar(
modifier = Modifier.onSizeChanged { topBarHeight = it.height },
title = state.manga.title,
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
backgroundAlphaProvider = { 1f },
hasFilters = state.filterActive,
onBackClicked = internalOnBackPressed,
onClickFilter = onFilterButtonClicked,
onClickShare = onShareClicked,
onClickDownload = onDownloadActionClicked,
onClickEditCategory = onEditCategoryClicked,
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
actionModeCounter = selectedChapterCount,
onSelectAll = { onAllChapterSelected(true) },
onInvertSelection = { onInvertSelection() },
SharedMangaBottomActionMenu(
selected = selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onDownloadChapter = onDownloadChapter,
onMultiDeleteClicked = onMultiDeleteClicked,
fillFraction = 0.5f,
)
},
bottomBar = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
val selectedChapters = remember(chapters) {
chapters.filter { it.selected }
}
SharedMangaBottomActionMenu(
selected = selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onDownloadChapter = onDownloadChapter,
onMultiDeleteClicked = onMultiDeleteClicked,
fillFraction = 0.5f,
)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(
text = stringResource(
if (isReading) MR.strings.action_resume else MR.strings.action_start,
),
)
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
)
}
},
) { contentPadding ->
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(
text = stringResource(
if (isReading) MR.strings.action_resume else MR.strings.action_start,
),
)
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
)
}
},
) { contentPadding ->
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = { !isAnySelected },
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
) {
TwoPanelBox(
modifier = Modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),

View File

@ -104,7 +104,7 @@ fun UpdateScreen(
isRefreshing = false
}
},
enabled = !state.selectionMode,
enabled = { !state.selectionMode },
indicatorPadding = contentPadding,
) {
FastScrollLazyColumn(

View File

@ -1,17 +1,33 @@
package tachiyomi.presentation.core.components.material
import androidx.compose.animation.core.animate
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.pow
/**
* @param refreshing Whether the layout is currently refreshing
@ -19,38 +35,239 @@ import androidx.compose.ui.unit.dp
* @param enabled Whether the the layout should react to swipe gestures or not.
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
* @param content The content containing a vertically scrollable composable.
*
* Code reference: [Accompanist SwipeRefresh](https://github.com/google/accompanist/blob/677bc4ca0ee74677a8ba73793d04d85fe4ab55fb/swiperefresh/src/main/java/com/google/accompanist/swiperefresh/SwipeRefresh.kt#L265-L283)
*/
@Composable
fun PullRefresh(
refreshing: Boolean,
enabled: () -> Boolean,
onRefresh: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
indicatorPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable () -> Unit,
) {
val state = rememberPullRefreshState(
refreshing = refreshing,
onRefresh = onRefresh,
val state = rememberPullToRefreshState(
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
enabled = enabled,
)
Box(Modifier.pullRefresh(state, enabled)) {
content()
Box(
Modifier
.padding(indicatorPadding)
.matchParentSize()
.clipToBounds(),
) {
PullRefreshIndicator(
refreshing = refreshing,
state = state,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
if (state.isRefreshing) {
LaunchedEffect(true) {
onRefresh()
}
}
LaunchedEffect(refreshing) {
if (refreshing && !state.isRefreshing) {
state.startRefreshAnimated()
} else if (!refreshing && state.isRefreshing) {
state.endRefreshAnimated()
}
}
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
content()
val contentPadding = remember(indicatorPadding) {
object : PaddingValues {
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateLeftPadding(layoutDirection)
override fun calculateTopPadding(): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateRightPadding(layoutDirection)
override fun calculateBottomPadding(): Dp =
indicatorPadding.calculateBottomPadding()
}
}
PullToRefreshContainer(
state = state,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(contentPadding),
)
}
}
@Composable
private fun rememberPullToRefreshState(
extraVerticalOffset: Dp,
positionalThreshold: Dp = 64.dp,
enabled: () -> Boolean = { true },
): PullToRefreshStateImpl {
val density = LocalDensity.current
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
return rememberSaveable(
extraVerticalOffset,
positionalThresholdPx,
enabled,
saver = PullToRefreshStateImpl.Saver(
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
),
) {
PullToRefreshStateImpl(
initialRefreshing = false,
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
)
}
}
/**
* Creates a [PullToRefreshState].
*
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
* @param enabled a callback used to determine whether scroll events are to be handled by this
* [PullToRefreshState]
*/
private class PullToRefreshStateImpl(
initialRefreshing: Boolean,
private val extraVerticalOffset: Float,
override val positionalThreshold: Float,
enabled: () -> Boolean,
) : PullToRefreshState {
override val progress get() = adjustedDistancePulled / positionalThreshold
override var verticalOffset by mutableFloatStateOf(0f)
override var isRefreshing by mutableStateOf(initialRefreshing)
override fun startRefresh() {
isRefreshing = true
verticalOffset = positionalThreshold + extraVerticalOffset
}
suspend fun startRefreshAnimated() {
isRefreshing = true
animateTo(positionalThreshold + extraVerticalOffset)
}
override fun endRefresh() {
verticalOffset = 0f
isRefreshing = false
}
suspend fun endRefreshAnimated() {
animateTo(0f)
isRefreshing = false
}
override var nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping up
source == NestedScrollSource.Drag && available.y < 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping down
source == NestedScrollSource.Drag && available.y > 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
}
/** Helper method for nested scroll connection */
fun consumeAvailableOffset(available: Offset): Offset {
val y = if (isRefreshing) {
0f
} else {
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress)
dragConsumed
}
return Offset(0f, y)
}
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
suspend fun onRelease(velocity: Float): Float {
if (isRefreshing) return 0f // Already refreshing, do nothing
// Trigger refresh
if (adjustedDistancePulled > positionalThreshold) {
startRefreshAnimated()
} else {
animateTo(0f)
}
val consumed = when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
suspend fun animateTo(offset: Float) {
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
verticalOffset = value
}
}
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
fun calculateVerticalOffset(): Float = when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = positionalThreshold * tensionPercent
positionalThreshold + extraOffset
}
}
companion object {
/** The default [Saver] for [PullToRefreshStateImpl]. */
fun Saver(
extraVerticalOffset: Float,
positionalThreshold: Float,
enabled: () -> Boolean,
) = Saver<PullToRefreshStateImpl, Boolean>(
save = { it.isRefreshing },
restore = { isRefreshing ->
PullToRefreshStateImpl(
initialRefreshing = isRefreshing,
extraVerticalOffset = extraVerticalOffset,
positionalThreshold = positionalThreshold,
enabled = enabled,
)
},
)
}
private var distancePulled by mutableFloatStateOf(0f)
private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
}