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( PullRefresh(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,
enabled = !state.isLoading, enabled = { !state.isLoading },
) { ) {
when { when {
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.zIndex
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -70,6 +71,7 @@ fun TabbedScreen(
) { ) {
PrimaryTabRow( PrimaryTabRow(
selectedTabIndex = state.currentPage, selectedTabIndex = state.currentPage,
modifier = Modifier.zIndex(1f),
) { ) {
tabs.forEachIndexed { index, tab -> tabs.forEachIndexed { index, tab ->
Tab( Tab(

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,33 @@
package tachiyomi.presentation.core.components.material package tachiyomi.presentation.core.components.material
import androidx.compose.animation.core.animate
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.pow
/** /**
* @param refreshing Whether the layout is currently refreshing * @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 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 indicatorPadding Content padding for the indicator, to inset the indicator in if required.
* @param content The content containing a vertically scrollable composable. * @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 @Composable
fun PullRefresh( fun PullRefresh(
refreshing: Boolean, refreshing: Boolean,
enabled: () -> Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
enabled: Boolean, modifier: Modifier = Modifier,
indicatorPadding: PaddingValues = PaddingValues(0.dp), indicatorPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val state = rememberPullRefreshState( val state = rememberPullToRefreshState(
refreshing = refreshing, extraVerticalOffset = indicatorPadding.calculateTopPadding(),
onRefresh = onRefresh, enabled = enabled,
) )
if (state.isRefreshing) {
Box(Modifier.pullRefresh(state, enabled)) { LaunchedEffect(true) {
content() onRefresh()
Box(
Modifier
.padding(indicatorPadding)
.matchParentSize()
.clipToBounds(),
) {
PullRefreshIndicator(
refreshing = refreshing,
state = state,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
} }
} }
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
} }