|
@@ -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,
|
|
|
)
|
|
|
+ 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()
|
|
|
|
|
|
- Box(
|
|
|
- Modifier
|
|
|
- .padding(indicatorPadding)
|
|
|
- .matchParentSize()
|
|
|
- .clipToBounds(),
|
|
|
- ) {
|
|
|
- PullRefreshIndicator(
|
|
|
- refreshing = refreshing,
|
|
|
- state = state,
|
|
|
- modifier = Modifier.align(Alignment.TopCenter),
|
|
|
- backgroundColor = MaterialTheme.colorScheme.primary,
|
|
|
- contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
|
- )
|
|
|
+ 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
|
|
|
}
|