ソースを参照

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

Ivan Iskandar 1 年間 前
コミット
d59cb9c1e3

+ 1 - 1
app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt

@@ -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))

+ 2 - 0
app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt

@@ -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(

+ 1 - 1
app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt

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

+ 5 - 1
app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt

@@ -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,

+ 90 - 89
app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt

@@ -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),

+ 1 - 1
app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt

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

+ 242 - 25
presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt

@@ -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
 }