package tachiyomi.presentation.core.util /* * MIT License * * Copyright (c) 2022 Albert Chang * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ /** * Code taken from https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a * with some modifications to handle contentPadding. * * Modifiers for regular scrollable list is omitted. */ import android.view.ViewConfiguration import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope 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.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastSumBy import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import tachiyomi.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX /** * Draws horizontal scrollbar to a LazyList. * * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. */ fun Modifier.drawHorizontalScrollbar( state: LazyListState, reverseScrolling: Boolean = false, // The amount of offset the scrollbar position towards the top of the layout positionOffsetPx: Float = 0f, ): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx) /** * Draws vertical scrollbar to a LazyList. * * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. */ fun Modifier.drawVerticalScrollbar( state: LazyListState, reverseScrolling: Boolean = false, // The amount of offset the scrollbar position towards the start of the layout positionOffsetPx: Float = 0f, ): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx) private fun Modifier.drawScrollbar( state: LazyListState, orientation: Orientation, reverseScrolling: Boolean, positionOffset: Float, ): Modifier = drawScrollbar( orientation, reverseScrolling, ) { reverseDirection, atEnd, thickness, color, alpha -> val layoutInfo = state.layoutInfo val viewportSize = if (orientation == Orientation.Horizontal) { layoutInfo.viewportSize.width } else { layoutInfo.viewportSize.height } - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding val items = layoutInfo.visibleItemsInfo val itemsSize = items.fastSumBy { it.size } val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size val totalSize = estimatedItemSize * layoutInfo.totalItemsCount val thumbSize = viewportSize / totalSize * viewportSize val startOffset = if (items.isEmpty()) { 0f } else { items .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!! .run { val startPadding = if (reverseDirection) { layoutInfo.afterContentPadding } else { layoutInfo.beforeContentPadding } startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize) } } val drawScrollbar = onDrawScrollbar( orientation, reverseDirection, atEnd, showScrollbar, thickness, color, alpha, thumbSize, startOffset, positionOffset, ) drawContent() drawScrollbar() } private fun ContentDrawScope.onDrawScrollbar( orientation: Orientation, reverseDirection: Boolean, atEnd: Boolean, showScrollbar: Boolean, thickness: Float, color: Color, alpha: () -> Float, thumbSize: Float, scrollOffset: Float, positionOffset: Float, ): DrawScope.() -> Unit { val topLeft = if (orientation == Orientation.Horizontal) { Offset( if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset, if (atEnd) size.height - positionOffset - thickness else positionOffset, ) } else { Offset( if (atEnd) size.width - positionOffset - thickness else positionOffset, if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset, ) } val size = if (orientation == Orientation.Horizontal) { Size(thumbSize, thickness) } else { Size(thickness, thumbSize) } return { if (showScrollbar) { drawRect( color = color, topLeft = topLeft, size = size, alpha = alpha(), ) } } } private fun Modifier.drawScrollbar( orientation: Orientation, reverseScrolling: Boolean, onDraw: ContentDrawScope.( reverseDirection: Boolean, atEnd: Boolean, thickness: Float, color: Color, alpha: () -> Float, ) -> Unit, ): Modifier = composed { val scrolled = remember { MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) } val nestedScrollConnection = remember(orientation, scrolled) { object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y if (delta != 0f) scrolled.tryEmit(Unit) return Offset.Zero } } } val alpha = remember { Animatable(0f) } LaunchedEffect(scrolled, alpha) { scrolled .sample(100) .collectLatest { alpha.snapTo(1f) alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) } } val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val reverseDirection = if (orientation == Orientation.Horizontal) { if (isLtr) reverseScrolling else !reverseScrolling } else { reverseScrolling } val atEnd = if (orientation == Orientation.Vertical) isLtr else true val context = LocalContext.current val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() } val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f) Modifier .nestedScroll(nestedScrollConnection) .drawWithContent { onDraw(reverseDirection, atEnd, thickness, color, alpha::value) } } private val FadeOutAnimationSpec = tween( durationMillis = ViewConfiguration.getScrollBarFadeDuration(), delayMillis = ViewConfiguration.getScrollDefaultDelay(), ) @Preview(widthDp = 400, heightDp = 400, showBackground = true) @Composable fun LazyListScrollbarPreview() { val state = rememberLazyListState() LazyColumn( modifier = Modifier.drawVerticalScrollbar(state), state = state, ) { items(50) { Text( text = "Item ${it + 1}", modifier = Modifier .fillMaxWidth() .padding(16.dp), ) } } } @Preview(widthDp = 400, showBackground = true) @Composable fun LazyListHorizontalScrollbarPreview() { val state = rememberLazyListState() LazyRow( modifier = Modifier.drawHorizontalScrollbar(state), state = state, ) { items(50) { Text( text = (it + 1).toString(), modifier = Modifier .padding(horizontal = 8.dp, vertical = 16.dp), ) } } }