diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index c914d8e81..3469af6ca 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/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)) diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt index e7f185b71..81ad61f2a 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/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( diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt index 61da10345..f0a63f597 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt +++ b/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, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt index 3200d437d..1904d39c2 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt +++ b/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, diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index c8db0c304..dda12b12b 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/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), diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index 15f1f57cb..3fd9cd0a4 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/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( diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt index 2200b5354..c68dd300f 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt +++ b/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, ) - - Box(Modifier.pullRefresh(state, enabled)) { - 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, - ) + if (state.isRefreshing) { + LaunchedEffect(true) { + onRefresh() } } + 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( + 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 }