From 3b2362c784a334161cffb734c46588ee87307950 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Tue, 24 May 2022 05:03:46 +0700 Subject: [PATCH] Add scrollbar indicator to LazyColumn (#7164) --- app/build.gradle.kts | 1 + .../browse/ExtensionDetailsScreen.kt | 4 +- .../presentation/browse/ExtensionsScreen.kt | 4 +- .../presentation/browse/MigrateMangaScreen.kt | 4 +- .../browse/MigrateSourceScreen.kt | 4 +- .../browse/SourcesFilterScreen.kt | 4 +- .../presentation/browse/SourcesScreen.kt | 4 +- .../presentation/components/LazyList.kt | 58 ++++ .../presentation/history/HistoryScreen.kt | 4 +- .../eu/kanade/presentation/more/MoreScreen.kt | 4 +- .../presentation/more/about/AboutScreen.kt | 4 +- .../more/settings/SettingsMainScreen.kt | 4 +- .../eu/kanade/presentation/util/Scrollbar.kt | 257 ++++++++++++++++++ gradle/compose.versions.toml | 1 + 14 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/components/LazyList.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c0dc94f9..aec2a984a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,6 +147,7 @@ dependencies { implementation(compose.material.icons) implementation(compose.animation) implementation(compose.ui.tooling) + implementation(compose.ui.util) implementation(compose.accompanist.webview) implementation(compose.accompanist.swiperefresh) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index c3e83b6c1..bf7dd46c0 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Settings @@ -52,6 +51,7 @@ import eu.kanade.presentation.components.DIVIDER_ALPHA import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.util.horizontalPadding import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.extension.model.Extension @@ -80,7 +80,7 @@ fun ExtensionDetailsScreen( var showNsfwWarning by remember { mutableStateOf(false) } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { 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 6aaef39e4..0c3a7cd41 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -41,6 +40,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.components.SwipeRefreshIndicator import eu.kanade.presentation.theme.header import eu.kanade.presentation.util.horizontalPadding @@ -113,7 +113,7 @@ fun ExtensionContent( ) { var trustState by remember { mutableStateOf(null) } - LazyColumn( + ScrollbarLazyColumn( contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, ) { items( diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt index eaa16dbdd..6dd5e4298 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt @@ -3,7 +3,6 @@ package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +14,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import eu.kanade.domain.manga.model.Manga import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.manga.components.BaseMangaListItem import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaState @@ -54,7 +54,7 @@ fun MigrateMangaContent( EmptyScreen(textResource = R.string.empty_screen) return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt index 58c07e2cf..20a6c5826 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -21,6 +20,7 @@ import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.ItemBadges import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.theme.header import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.plus @@ -62,7 +62,7 @@ fun MigrateSourceList( return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, ) { diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt index 2fcf3c7c1..097a76264 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt @@ -3,7 +3,6 @@ package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Checkbox import androidx.compose.material3.Switch @@ -20,6 +19,7 @@ import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.source.FilterUiModel import eu.kanade.tachiyomi.ui.browse.source.SourceFilterState @@ -60,7 +60,7 @@ fun SourcesFilterContent( return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt index 2e8d703e9..a7230a090 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PushPin @@ -36,6 +35,7 @@ import eu.kanade.domain.source.model.Source import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.theme.header import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.plus @@ -87,7 +87,7 @@ fun SourceList( var sourceState by remember { mutableStateOf(null) } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollConnection), contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, ) { diff --git a/app/src/main/java/eu/kanade/presentation/components/LazyList.kt b/app/src/main/java/eu/kanade/presentation/components/LazyList.kt new file mode 100644 index 000000000..62b004ab9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/LazyList.kt @@ -0,0 +1,58 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.drawVerticalScrollbar + +/** + * LazyColumn with scrollbar. + */ +@Composable +fun ScrollbarLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit, +) { + val direction = LocalLayoutDirection.current + val density = LocalDensity.current + val positionOffset = remember(contentPadding) { + with(density) { contentPadding.calculateEndPadding(direction).toPx() } + } + LazyColumn( + modifier = modifier + .drawVerticalScrollbar( + state = state, + reverseScrolling = reverseLayout, + positionOffsetPx = positionOffset, + ), + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + content = content, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index 719974120..8b1522769 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons @@ -45,6 +44,7 @@ import eu.kanade.domain.history.model.HistoryWithRelations import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.topPaddingValues @@ -107,7 +107,7 @@ fun HistoryContent( var removeState by remember { mutableStateOf(null) } val scrollState = rememberLazyListState() - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier .nestedScroll(nestedScroll), contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index b91dd8d78..ef28b725f 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -3,7 +3,6 @@ package eu.kanade.presentation.more import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.GetApp @@ -24,6 +23,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.components.SwitchPreference import eu.kanade.presentation.util.quantityStringResource import eu.kanade.tachiyomi.R @@ -44,7 +44,7 @@ fun MoreScreen( val uriHandler = LocalUriHandler.current val downloadQueueState by presenter.downloadQueueState.collectAsState() - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt b/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt index c74f41355..d5dd0642e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Public import androidx.compose.runtime.Composable @@ -20,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.LinkIcon import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.more.LogoHeader import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R @@ -37,7 +37,7 @@ fun AboutScreen( val context = LocalContext.current val uriHandler = LocalUriHandler.current - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt index c33070b3d..540dc6b64 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt @@ -4,7 +4,6 @@ import androidx.annotation.StringRes import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -12,13 +11,14 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn @Composable fun SettingsMainScreen( nestedScrollInterop: NestedScrollConnection, sections: List, ) { - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt b/app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt new file mode 100644 index 000000000..6fa48d508 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt @@ -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( + 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( + 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), + ) + } + } +} diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index e55e71b96..dca061f83 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -7,6 +7,7 @@ activity = "androidx.activity:activity-compose:1.6.0-alpha03" foundation = { module = "androidx.compose.foundation:foundation", version.ref="compose" } animation = { module = "androidx.compose.animation:animation", version.ref="compose" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref="compose" } +ui-util = { module = "androidx.compose.ui:ui-util", version.ref="compose" } material3-core = "androidx.compose.material3:material3:1.0.0-alpha12" material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.10"