|
@@ -0,0 +1,257 @@
|
|
|
+package eu.kanade.presentation.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.CacheDrawScope
|
|
|
+import androidx.compose.ui.draw.DrawResult
|
|
|
+import androidx.compose.ui.draw.drawWithCache
|
|
|
+import androidx.compose.ui.geometry.Offset
|
|
|
+import androidx.compose.ui.geometry.Size
|
|
|
+import androidx.compose.ui.graphics.Color
|
|
|
+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.fastSumBy
|
|
|
+import kotlinx.coroutines.channels.BufferOverflow
|
|
|
+import kotlinx.coroutines.flow.MutableSharedFlow
|
|
|
+import kotlinx.coroutines.flow.collectLatest
|
|
|
+
|
|
|
+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)
|
|
|
+
|
|
|
+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
|
|
|
+ .first()
|
|
|
+ .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,
|
|
|
+ )
|
|
|
+ onDrawWithContent {
|
|
|
+ drawContent()
|
|
|
+ drawScrollbar()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private fun CacheDrawScope.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,
|
|
|
+ onBuildDrawCache: CacheDrawScope.(
|
|
|
+ reverseDirection: Boolean,
|
|
|
+ atEnd: Boolean,
|
|
|
+ thickness: Float,
|
|
|
+ color: Color,
|
|
|
+ alpha: () -> Float,
|
|
|
+ ) -> DrawResult,
|
|
|
+): Modifier = composed {
|
|
|
+ val scrolled = remember {
|
|
|
+ MutableSharedFlow<Unit>(
|
|
|
+ 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.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)
|
|
|
+ .drawWithCache {
|
|
|
+ onBuildDrawCache(reverseDirection, atEnd, thickness, color, alpha::value)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private val FadeOutAnimationSpec = tween<Float>(
|
|
|
+ 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),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|