diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt index 31f306b736..698916caba 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Icon @@ -31,7 +32,6 @@ import kotlinx.coroutines.launch import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.material.Divider import tachiyomi.presentation.core.components.material.TabIndicator -import tachiyomi.presentation.core.components.rememberPagerState object TabbedDialogPaddings { val Horizontal = 24.dp @@ -84,7 +84,7 @@ fun TabbedDialog( HorizontalPager( modifier = Modifier.animateContentSize(), - count = tabTitles.size, + pageCount = tabTitles.size, state = pagerState, verticalAlignment = Alignment.Top, ) { page -> 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 65714a8793..3b40008473 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -25,7 +26,6 @@ import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.TabIndicator import tachiyomi.presentation.core.components.material.TabText -import tachiyomi.presentation.core.components.rememberPagerState @Composable fun TabbedScreen( @@ -82,7 +82,7 @@ fun TabbedScreen( } HorizontalPager( - count = tabs.size, + pageCount = tabs.size, modifier = Modifier.fillMaxSize(), state = state, verticalAlignment = Alignment.Top, 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 e8717d177b..a444790487 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,7 +23,6 @@ import tachiyomi.domain.category.model.Category import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryManga import tachiyomi.presentation.core.components.material.PullRefresh -import tachiyomi.presentation.core.components.rememberPagerState import kotlin.time.Duration.Companion.seconds @Composable @@ -60,8 +60,10 @@ fun LibraryContent( var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } if (showPageTabs && categories.size > 1) { - if (categories.size <= pagerState.currentPage) { - pagerState.currentPage = categories.size - 1 + LaunchedEffect(categories) { + if (categories.size <= pagerState.currentPage) { + pagerState.scrollToPage(categories.size - 1) + } } LibraryTabs( categories = categories, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt index c3bba5f473..c27723ba24 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -22,7 +23,6 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryManga import tachiyomi.presentation.core.components.HorizontalPager -import tachiyomi.presentation.core.components.PagerState import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.util.plus @@ -43,7 +43,7 @@ fun LibraryPager( onClickContinueReading: ((LibraryManga) -> Unit)?, ) { HorizontalPager( - count = pageCount, + pageCount = pageCount, modifier = Modifier.fillMaxSize(), state = state, verticalAlignment = Alignment.Top, 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 243ed07647..5b13efcac4 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 @@ -1,6 +1,7 @@ package eu.kanade.presentation.library.components import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -8,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import eu.kanade.presentation.category.visualName import tachiyomi.domain.category.model.Category -import tachiyomi.presentation.core.components.PagerState import tachiyomi.presentation.core.components.material.Divider import tachiyomi.presentation.core.components.material.TabIndicator import tachiyomi.presentation.core.components.material.TabText diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt index 942e074dbb..1f6ead704b 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt @@ -1,300 +1,57 @@ package tachiyomi.presentation.core.components -import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider -import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListLayoutInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerSnapDistance +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Density -import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.fastMaxBy -import androidx.compose.ui.util.fastSumBy -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlin.math.abs +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +/** + * Horizontal Pager with custom SnapFlingBehavior for a more natural swipe feeling + */ @Composable fun HorizontalPager( - count: Int, + pageCount: Int, modifier: Modifier = Modifier, state: PagerState = rememberPagerState(), - key: ((page: Int) -> Any)? = null, - contentPadding: PaddingValues = PaddingValues(), + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondBoundsPageCount: Int = 0, + pageSpacing: Dp = 0.dp, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, userScrollEnabled: Boolean = true, - content: @Composable BoxScope.(page: Int) -> Unit, + reverseLayout: Boolean = false, + key: ((index: Int) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( + Orientation.Horizontal, + ), + pageContent: @Composable (page: Int) -> Unit, ) { - Pager( - count = count, + androidx.compose.foundation.pager.HorizontalPager( + pageCount = pageCount, modifier = modifier, state = state, - isVertical = false, - key = key, contentPadding = contentPadding, + pageSize = pageSize, + beyondBoundsPageCount = beyondBoundsPageCount, + pageSpacing = pageSpacing, verticalAlignment = verticalAlignment, + flingBehavior = PagerDefaults.flingBehavior( + state = state, + pagerSnapDistance = PagerSnapDistance.atMost(0), + ), userScrollEnabled = userScrollEnabled, - content = content, + reverseLayout = reverseLayout, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + pageContent = pageContent, ) } - -@Composable -private fun Pager( - count: Int, - modifier: Modifier, - state: PagerState, - isVertical: Boolean, - key: ((page: Int) -> Any)?, - contentPadding: PaddingValues, - userScrollEnabled: Boolean, - verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, - horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, - content: @Composable BoxScope.(page: Int) -> Unit, -) { - LaunchedEffect(count) { - state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0) - } - - LaunchedEffect(state) { - snapshotFlow { state.mostVisiblePageLayoutInfo?.index } - .distinctUntilChanged() - .collect { state.updateCurrentPageBasedOnLazyListState() } - } - - if (isVertical) { - LazyColumn( - modifier = modifier, - state = state.lazyListState, - contentPadding = contentPadding, - horizontalAlignment = horizontalAlignment, - verticalArrangement = Arrangement.aligned(verticalAlignment), - userScrollEnabled = userScrollEnabled, - flingBehavior = rememberLazyListSnapFlingBehavior(lazyListState = state.lazyListState), - ) { - items( - count = count, - key = key, - ) { page -> - Box( - modifier = Modifier - .fillParentMaxHeight() - .wrapContentSize(), - ) { - content(this, page) - } - } - } - } else { - LazyRow( - modifier = modifier, - state = state.lazyListState, - contentPadding = contentPadding, - verticalAlignment = verticalAlignment, - horizontalArrangement = Arrangement.aligned(horizontalAlignment), - userScrollEnabled = userScrollEnabled, - flingBehavior = rememberLazyListSnapFlingBehavior(lazyListState = state.lazyListState), - ) { - items( - count = count, - key = key, - ) { page -> - Box( - modifier = Modifier - .fillParentMaxWidth() - .wrapContentSize(), - ) { - content(this, page) - } - } - } - } -} - -@Composable -fun rememberPagerState( - initialPage: Int = 0, -) = rememberSaveable(saver = PagerState.Saver) { - PagerState(currentPage = initialPage) -} - -@Stable -class PagerState( - currentPage: Int = 0, -) { - init { check(currentPage >= 0) { "currentPage cannot be less than zero" } } - - val lazyListState = LazyListState(firstVisibleItemIndex = currentPage) - - private val pageSize: Int - get() = visiblePages.firstOrNull()?.size ?: 0 - - private var _currentPage by mutableStateOf(currentPage) - - private val layoutInfo: LazyListLayoutInfo - get() = lazyListState.layoutInfo - - private val visiblePages: List - get() = layoutInfo.visibleItemsInfo - - var currentPage: Int - get() = _currentPage - set(value) { - if (value != _currentPage) { - _currentPage = value - } - } - - val mostVisiblePageLayoutInfo: LazyListItemInfo? - get() { - val layoutInfo = lazyListState.layoutInfo - return layoutInfo.visibleItemsInfo.fastMaxBy { - val start = maxOf(it.offset, 0) - val end = minOf( - it.offset + it.size, - layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding, - ) - end - start - } - } - - private val closestPageToSnappedPosition: LazyListItemInfo? - get() = visiblePages.fastMaxBy { - -abs( - calculateDistanceToDesiredSnapPosition( - layoutInfo, - it, - SnapAlignmentStartToStart, - ), - ) - } - - val currentPageOffsetFraction: Float by derivedStateOf { - val currentPagePositionOffset = closestPageToSnappedPosition?.offset ?: 0 - val pageUsedSpace = pageSize.toFloat() - if (pageUsedSpace == 0f) { - // Default to 0 when there's no info about the page size yet. - 0f - } else { - ((-currentPagePositionOffset) / (pageUsedSpace)).coerceIn( - MinPageOffset, - MaxPageOffset, - ) - } - } - - fun updateCurrentPageBasedOnLazyListState() { - mostVisiblePageLayoutInfo?.let { - currentPage = it.index - } - } - - suspend fun animateScrollToPage(page: Int) { - lazyListState.animateScrollToItem(index = page) - } - - suspend fun scrollToPage(page: Int) { - lazyListState.scrollToItem(index = page) - updateCurrentPageBasedOnLazyListState() - } - - companion object { - val Saver: Saver = listSaver( - save = { listOf(it.currentPage) }, - restore = { PagerState(it[0]) }, - ) - } -} - -private const val MinPageOffset = -0.5f -private const val MaxPageOffset = 0.5f -internal val SnapAlignmentStartToStart: (layoutSize: Float, itemSize: Float) -> Float = - { _, _ -> 0f } - -// https://android.googlesource.com/platform/frameworks/support/+/refs/changes/78/2160778/35/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt -private fun lazyListSnapLayoutInfoProvider( - lazyListState: LazyListState, - positionInLayout: (layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize -> - layoutSize / 2f - itemSize / 2f - }, -) = object : SnapLayoutInfoProvider { - - private val layoutInfo: LazyListLayoutInfo - get() = lazyListState.layoutInfo - - // Single page snapping is the default - override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f - - override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { - var lowerBoundOffset = Float.NEGATIVE_INFINITY - var upperBoundOffset = Float.POSITIVE_INFINITY - - layoutInfo.visibleItemsInfo.fastForEach { item -> - val offset = - calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) - - // Find item that is closest to the center - if (offset <= 0 && offset > lowerBoundOffset) { - lowerBoundOffset = offset - } - - // Find item that is closest to center, but after it - if (offset >= 0 && offset < upperBoundOffset) { - upperBoundOffset = offset - } - } - - return lowerBoundOffset.rangeTo(upperBoundOffset) - } - - override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) { - if (visibleItemsInfo.isNotEmpty()) { - visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat() - } else { - 0f - } - } -} - -@Composable -private fun rememberLazyListSnapFlingBehavior(lazyListState: LazyListState): FlingBehavior { - val snappingLayout = remember(lazyListState) { lazyListSnapLayoutInfoProvider(lazyListState) } - return rememberSnapFlingBehavior(snappingLayout) -} - -private fun calculateDistanceToDesiredSnapPosition( - layoutInfo: LazyListLayoutInfo, - item: LazyListItemInfo, - positionInLayout: (layoutSize: Float, itemSize: Float) -> Float, -): Float { - val containerSize = - with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } - - val desiredDistance = - positionInLayout(containerSize.toFloat(), item.size.toFloat()) - - val itemCurrentPosition = item.offset - return itemCurrentPosition - desiredDistance -} - -private val LazyListLayoutInfo.singleAxisViewportSize: Int - get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width