Sfoglia il codice sorgente

Add scrollbar indicator to LazyColumn (#7164)

Ivan Iskandar 2 anni fa
parent
commit
3b2362c784

+ 1 - 0
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)
 

+ 2 - 2
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(),
     ) {

+ 2 - 2
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<Extension.Untrusted?>(null) }
 
-    LazyColumn(
+    ScrollbarLazyColumn(
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
     ) {
         items(

+ 2 - 2
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(),
     ) {

+ 2 - 2
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,
     ) {

+ 2 - 2
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(),
     ) {

+ 2 - 2
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<Source?>(null) }
 
-    LazyColumn(
+    ScrollbarLazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollConnection),
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
     ) {

+ 58 - 0
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,
+    )
+}

+ 2 - 2
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<HistoryWithRelations?>(null) }
 
     val scrollState = rememberLazyListState()
-    LazyColumn(
+    ScrollbarLazyColumn(
         modifier = Modifier
             .nestedScroll(nestedScroll),
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,

+ 2 - 2
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(),
     ) {

+ 2 - 2
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(),
     ) {

+ 2 - 2
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<SettingsSection>,
 ) {
-    LazyColumn(
+    ScrollbarLazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollInterop),
         contentPadding = WindowInsets.navigationBars.asPaddingValues(),
     ) {

+ 257 - 0
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<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),
+            )
+        }
+    }
+}

+ 1 - 0
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"