diff --git a/CHANGELOG.md b/CHANGELOG.md index f18297b06e..0f635ea875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - `Other` - for technical stuff. ## [Unreleased] +### Fixed +- Make the scrollbar on the anime screen less buggy ([@Secozzi](https://github.com/Secozzi)) ([#118](https://github.com/quickdesh/Animiru/pull/118)) ## [v0.19.3.0] - 2025-12-25 ### Fixed diff --git a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt index 3f47f277b8..974c6b87cc 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt @@ -89,7 +89,8 @@ import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import tachiyomi.i18n.aniyomi.AYMR -import tachiyomi.presentation.core.components.FastScrollLazyVerticalGrid +import tachiyomi.presentation.core.components.FastScrollIrregularLazyVerticalGrid +import tachiyomi.presentation.core.components.Scroller.EXACT_HEIGHT_KEY_PREFIX import tachiyomi.presentation.core.components.TwoPanelBox import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton import tachiyomi.presentation.core.components.material.PullRefresh @@ -511,7 +512,7 @@ private fun AnimeScreenSmallImpl( ) { val layoutDirection = LocalLayoutDirection.current // AY --> - FastScrollLazyVerticalGrid( + FastScrollIrregularLazyVerticalGrid( modifier = Modifier.fillMaxHeight(), state = itemListState, columns = if (gridSize == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(gridSize), @@ -520,10 +521,11 @@ private fun AnimeScreenSmallImpl( end = GRID_PADDING + contentPadding.calculateEndPadding(layoutDirection), bottom = contentPadding.calculateBottomPadding(), ), + topContentPadding = contentPadding.calculateTopPadding(), ) { // <-- AY item( - key = AnimeScreenItem.INFO_BOX, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.INFO_BOX, contentType = AnimeScreenItem.INFO_BOX, // AY --> span = { GridItemSpan(maxLineSpan) }, @@ -544,7 +546,7 @@ private fun AnimeScreenSmallImpl( } item( - key = AnimeScreenItem.ACTION_ROW, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.ACTION_ROW, contentType = AnimeScreenItem.ACTION_ROW, // AY --> span = { GridItemSpan(maxLineSpan) }, @@ -568,7 +570,7 @@ private fun AnimeScreenSmallImpl( } item( - key = AnimeScreenItem.DESCRIPTION_WITH_TAG, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.DESCRIPTION_WITH_TAG, contentType = AnimeScreenItem.DESCRIPTION_WITH_TAG, // AY --> span = { GridItemSpan(maxLineSpan) }, @@ -589,7 +591,7 @@ private fun AnimeScreenSmallImpl( } item( - key = AnimeScreenItem.EPISODE_HEADER, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.EPISODE_HEADER, contentType = AnimeScreenItem.EPISODE_HEADER, // AY --> span = { GridItemSpan(maxLineSpan) }, @@ -636,7 +638,7 @@ private fun AnimeScreenSmallImpl( // AY --> if (state.airingTime > 0L) { item( - key = AnimeScreenItem.AIRING_TIME, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.AIRING_TIME, contentType = AnimeScreenItem.AIRING_TIME, span = { GridItemSpan(maxLineSpan) }, ) { @@ -940,7 +942,7 @@ fun AnimeScreenLargeImpl( }, endContent = { // AY --> - FastScrollLazyVerticalGrid( + FastScrollIrregularLazyVerticalGrid( modifier = Modifier.fillMaxHeight(), state = itemListState, columns = if (gridSize == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(gridSize), @@ -950,10 +952,11 @@ fun AnimeScreenLargeImpl( top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding(), ), + topContentPadding = contentPadding.calculateTopPadding(), ) { // <-- AY item( - key = AnimeScreenItem.EPISODE_HEADER, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.EPISODE_HEADER, contentType = AnimeScreenItem.EPISODE_HEADER, // AY --> span = { GridItemSpan(maxLineSpan) }, @@ -1000,7 +1003,7 @@ fun AnimeScreenLargeImpl( // AY --> if (state.airingTime > 0L) { item( - key = AnimeScreenItem.AIRING_TIME, + key = EXACT_HEIGHT_KEY_PREFIX + AnimeScreenItem.AIRING_TIME, contentType = AnimeScreenItem.AIRING_TIME, ) { // Handles the second by second countdown diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/LazyGrid.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/LazyGrid.kt index f0061e11a9..514d449a42 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/LazyGrid.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/LazyGrid.kt @@ -56,3 +56,48 @@ fun FastScrollLazyVerticalGrid( ) } } + +// AM --> +@Composable +fun FastScrollIrregularLazyVerticalGrid( + columns: GridCells, + modifier: Modifier = Modifier, + state: LazyGridState = rememberLazyGridState(), + thumbAllowed: () -> Boolean = { true }, + thumbColor: Color = MaterialTheme.colorScheme.primary, + contentPadding: PaddingValues = PaddingValues(0.dp), + topContentPadding: Dp = Dp.Hairline, + bottomContentPadding: Dp = Dp.Hairline, + endContentPadding: Dp = Dp.Hairline, + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + userScrollEnabled: Boolean = true, + content: LazyGridScope.() -> Unit, +) { + IrregularVerticalGridFastScroller( + state = state, + columns = columns, + arrangement = horizontalArrangement, + contentPadding = contentPadding, + modifier = modifier, + thumbAllowed = thumbAllowed, + thumbColor = thumbColor, + topContentPadding = topContentPadding, + bottomContentPadding = bottomContentPadding, + endContentPadding = endContentPadding, + ) { + LazyVerticalGrid( + columns = columns, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalArrangement = horizontalArrangement, + userScrollEnabled = userScrollEnabled, + content = content, + ) + } +} +// <-- AM diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt index 269aebc588..84fa208364 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -51,6 +52,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample +import tachiyomi.presentation.core.components.Scroller.EXACT_HEIGHT_KEY_PREFIX import tachiyomi.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX import kotlin.math.abs import kotlin.math.max @@ -454,16 +456,257 @@ private fun computeGridScrollRange(state: LazyGridState, columnCount: Int): Int return (endSpacing + (laidOutArea.toFloat() / laidOutRows) * totalRows).roundToInt() } +// AM --> +// Based on https://github.com/ztimms73/shirizu/blob/master/app/src/main/java/org/xtimms/shirizu/core/components/VerticalFastScroller.kt +// TODO: Make less buggy when dragging the thumb +@Composable +fun IrregularVerticalGridFastScroller( + state: LazyGridState, + columns: GridCells, + arrangement: Arrangement.Horizontal, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, + thumbAllowed: () -> Boolean = { true }, + thumbColor: Color = MaterialTheme.colorScheme.primary, + topContentPadding: Dp = Dp.Hairline, + bottomContentPadding: Dp = Dp.Hairline, + endContentPadding: Dp = Dp.Hairline, + content: @Composable () -> Unit, +) { + val slotSizesSums = rememberColumnWidthSums( + columns = columns, + horizontalArrangement = arrangement, + contentPadding = contentPadding, + ) + + val headerHeights = remember { mutableStateMapOf() } + LaunchedEffect(state.layoutInfo.totalItemsCount) { + headerHeights.clear() + } + var cachedGridItemHeight by remember { mutableFloatStateOf(0f) } + + SubcomposeLayout(modifier = modifier) { constraints -> + val contentPlaceable = subcompose("content", content).map { it.measure(constraints) } + val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0 + val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0 + + val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val scrollerPlaceable = subcompose("scroller") { + val layoutInfo = state.layoutInfo + val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount + if (!showScroller) return@subcompose + + LaunchedEffect(layoutInfo) { + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return@LaunchedEffect + visibleItems.fastForEach { item -> + if ((item.key as? String?)?.startsWith(EXACT_HEIGHT_KEY_PREFIX) == true) { + headerHeights[item.index] = item.size.height + } + } + + val knownHeaderIndexes = headerHeights.keys + val gridItems = visibleItems.filter { it.index !in knownHeaderIndexes } + + if (gridItems.isNotEmpty()) { + val start = gridItems.first() + val end = gridItems.last() + val laidOutArea = (end.offset.y + end.size.height) - start.offset.y + val laidOutRange = abs(start.index - end.index) + 1 + if (laidOutRange > 0) { + cachedGridItemHeight = laidOutArea.toFloat() / laidOutRange + } + } + } + + val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() } + var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) } + + val dragInteractionSource = remember { MutableInteractionSource() } + val isThumbDragged by dragInteractionSource.collectIsDraggedAsState() + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + + val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() } + val heightPx = contentHeight.toFloat() - + thumbTopPadding - + thumbBottomPadding - + state.layoutInfo.afterContentPadding + val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() } + val trackHeightPx = heightPx - thumbHeightPx + + val columnCount = remember { slotSizesSums(constraints).size } + + // When thumb dragged + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx + val scrollItem = layoutInfo.totalItemsCount * scrollRatio + // I can't think of anything else rn but this'll do + val scrollItemWhole = scrollItem.toInt() + val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount + val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole + val offsetPerItem = 1f / columnCount + val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1)) + + // TODO: Sometimes item height is not available when scrolling up + val scrollItemSize = (1..columnCount).maxOf { num -> + val actualIndex = if (num != columnNum) { + scrollItemWhole + num - columnCount + } else { + scrollItemWhole + } + layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0 + } + val scrollItemOffset = scrollItemSize * offsetRatio + + state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt()) + scrolled.tryEmit(Unit) + } + + // When list scrolled + LaunchedEffect(state.firstVisibleItemScrollOffset, layoutInfo) { + if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect + val scrollOffset = computeScrollOffset( + state = state, + headerHeights = headerHeights, + cachedGridItemHeight = cachedGridItemHeight, + ) + val scrollRange = computeScrollRange( + state = state, + headerHeights = headerHeights, + cachedGridItemHeight = cachedGridItemHeight, + ) + val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx) + thumbOffsetY = trackHeightPx * proportion + thumbTopPadding + scrolled.tryEmit(Unit) + } + + // Thumb alpha + val alpha = remember { Animatable(0f) } + val isThumbVisible = alpha.value > 0f + LaunchedEffect(scrolled, alpha) { + scrolled + .sample(100) + .collectLatest { + if (thumbAllowed()) { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } else { + alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec) + } + } + } + + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetY.roundToInt()) } + .then( + // Recompose opts + if (isThumbVisible && !state.isScrollInProgress) { + Modifier.draggable( + interactionSource = dragInteractionSource, + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + val newOffsetY = thumbOffsetY + delta + thumbOffsetY = newOffsetY.coerceIn( + thumbTopPadding, + thumbTopPadding + trackHeightPx, + ) + }, + ) + } else { + Modifier + }, + ) + .then( + // Exclude thumb from gesture area only when needed + if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) { + Modifier.systemGestureExclusion() + } else { + Modifier + }, + ) + .height(ThumbLength) + .padding(end = endContentPadding) + .width(ThumbThickness) + .alpha(alpha.value) + .background(color = thumbColor, shape = ThumbShape), + ) + }.map { it.measure(scrollerConstraints) } + val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0 + + layout(contentWidth, contentHeight) { + contentPlaceable.fastForEach { + it.place(0, 0) + } + scrollerPlaceable.fastForEach { + it.placeRelative(contentWidth - scrollerWidth, 0) + } + } + } +} + +private fun computeScrollOffset( + state: LazyGridState, + headerHeights: Map, + cachedGridItemHeight: Float, +): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems.first() + val avgCellHeight = if (cachedGridItemHeight > 0) cachedGridItemHeight else 1f + val itemsBefore = startChild.index + val knownHeaderIndexes = headerHeights.keys + val knownHeadersBefore = knownHeaderIndexes.filter { it < itemsBefore } + val normalItemsBefore = itemsBefore - knownHeadersBefore.size + val estimatedOffset = knownHeadersBefore.sumOf { headerHeights[it] ?: 0 } + + (normalItemsBefore * avgCellHeight) + val startDecoratedTop = startChild.offset.y + return (estimatedOffset + (0 - startDecoratedTop)).roundToInt() +} + +private fun computeScrollRange( + state: LazyGridState, + headerHeights: Map, + cachedGridItemHeight: Float, +): Int { + val totalCount = state.layoutInfo.totalItemsCount + if (totalCount == 0) return 0 + val avgCellHeight = if (cachedGridItemHeight > 0) cachedGridItemHeight else 1f + val knownHeadersHeight = headerHeights.values.sum() + val knownHeadersCount = headerHeights.size + val normalItemCount = (totalCount - knownHeadersCount).coerceAtLeast(0) + return knownHeadersHeight + (normalItemCount * avgCellHeight).roundToInt() +} +// <-- AM + private class MutableData(var value: T) object Scroller { const val STICKY_HEADER_KEY_PREFIX = "sticky:" + + // AM --> + const val EXACT_HEIGHT_KEY_PREFIX = "exact:" + // <-- AM" } private val ThumbLength = 48.dp private val ThumbThickness = 12.dp private val ThumbShape = RoundedCornerShape(ThumbThickness / 2) private val ScrollBarVisibilityDurationMillis = 2000L + +// AM --> +private val FadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = 2000, +) + +// <-- AM private val ImmediateFadeOutAnimationSpec = tween( durationMillis = ViewConfiguration.getScrollBarFadeDuration(), )