mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-25 06:11:04 +03:00
Migrate to M3 pull-to-refresh (#10164)
This commit is contained in:
parent
3006604922
commit
d59cb9c1e3
7 changed files with 344 additions and 120 deletions
|
@ -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))
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -93,7 +93,7 @@ fun LibraryContent(
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = notSelectionMode,
|
enabled = { notSelectionMode },
|
||||||
) {
|
) {
|
||||||
LibraryPager(
|
LibraryPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,16 +529,7 @@ 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 chapterListState = rememberLazyListState()
|
||||||
|
|
||||||
val internalOnBackPressed = {
|
val internalOnBackPressed = {
|
||||||
|
@ -620,6 +611,16 @@ fun MangaScreenLargeImpl(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { 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(
|
TwoPanelBox(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
start = contentPadding.calculateStartPadding(layoutDirection),
|
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||||
|
|
|
@ -104,7 +104,7 @@ fun UpdateScreen(
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !state.selectionMode,
|
enabled = { !state.selectionMode },
|
||||||
indicatorPadding = contentPadding,
|
indicatorPadding = contentPadding,
|
||||||
) {
|
) {
|
||||||
FastScrollLazyColumn(
|
FastScrollLazyColumn(
|
||||||
|
|
|
@ -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) {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(refreshing) {
|
||||||
|
if (refreshing && !state.isRefreshing) {
|
||||||
|
state.startRefreshAnimated()
|
||||||
|
} else if (!refreshing && state.isRefreshing) {
|
||||||
|
state.endRefreshAnimated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.pullRefresh(state, enabled)) {
|
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
|
||||||
content()
|
content()
|
||||||
|
|
||||||
Box(
|
val contentPadding = remember(indicatorPadding) {
|
||||||
Modifier
|
object : PaddingValues {
|
||||||
.padding(indicatorPadding)
|
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
|
||||||
.matchParentSize()
|
indicatorPadding.calculateLeftPadding(layoutDirection)
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
override fun calculateTopPadding(): Dp = 0.dp
|
||||||
PullRefreshIndicator(
|
|
||||||
refreshing = refreshing,
|
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
|
||||||
|
indicatorPadding.calculateRightPadding(layoutDirection)
|
||||||
|
|
||||||
|
override fun calculateBottomPadding(): Dp =
|
||||||
|
indicatorPadding.calculateBottomPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PullToRefreshContainer(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
modifier = Modifier
|
||||||
backgroundColor = MaterialTheme.colorScheme.primary,
|
.align(Alignment.TopCenter)
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
.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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue