|
|
@ -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 =
|
|
|
|
state = state,
|
|
|
|
indicatorPadding.calculateRightPadding(layoutDirection)
|
|
|
|
modifier = Modifier.align(Alignment.TopCenter),
|
|
|
|
|
|
|
|
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
|
|
override fun calculateBottomPadding(): Dp =
|
|
|
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|