-
-
Save mxalbert1996/33a360fcab2105a31e5355af98216f5a to your computer and use it in GitHub Desktop.
/* | |
* 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. | |
*/ | |
import android.view.ViewConfiguration | |
import androidx.compose.animation.core.Animatable | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.ScrollState | |
import androidx.compose.foundation.gestures.Orientation | |
import androidx.compose.foundation.horizontalScroll | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
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.grid.LazyGridState | |
import androidx.compose.foundation.lazy.rememberLazyListState | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.verticalScroll | |
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.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.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.delay | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.collectLatest | |
fun Modifier.drawHorizontalScrollbar( | |
state: ScrollState, | |
reverseScrolling: Boolean = false | |
): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) | |
fun Modifier.drawVerticalScrollbar( | |
state: ScrollState, | |
reverseScrolling: Boolean = false | |
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) | |
private fun Modifier.drawScrollbar( | |
state: ScrollState, | |
orientation: Orientation, | |
reverseScrolling: Boolean | |
): Modifier = drawScrollbar( | |
orientation, reverseScrolling | |
) { reverseDirection, atEnd, color, alpha -> | |
if (state.maxValue > 0) { | |
val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height | |
val totalSize = canvasSize + state.maxValue | |
val thumbSize = canvasSize / totalSize * canvasSize | |
val startOffset = state.value / totalSize * canvasSize | |
drawScrollbar( | |
orientation, reverseDirection, atEnd, color, alpha, thumbSize, startOffset | |
) | |
} | |
} | |
fun Modifier.drawHorizontalScrollbar( | |
state: LazyListState, | |
reverseScrolling: Boolean = false | |
): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) | |
fun Modifier.drawVerticalScrollbar( | |
state: LazyListState, | |
reverseScrolling: Boolean = false | |
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) | |
private fun Modifier.drawScrollbar( | |
state: LazyListState, | |
orientation: Orientation, | |
reverseScrolling: Boolean | |
): Modifier = drawScrollbar( | |
orientation, reverseScrolling | |
) { reverseDirection, atEnd, color, alpha -> | |
val layoutInfo = state.layoutInfo | |
val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset | |
val items = layoutInfo.visibleItemsInfo | |
val itemsSize = items.fastSumBy { it.size } | |
if (items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize) { | |
val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size | |
val totalSize = estimatedItemSize * layoutInfo.totalItemsCount | |
val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height | |
val thumbSize = viewportSize / totalSize * canvasSize | |
val startOffset = if (items.isEmpty()) 0f else items.first().run { | |
(estimatedItemSize * index - offset) / totalSize * canvasSize | |
} | |
drawScrollbar( | |
orientation, reverseDirection, atEnd, color, alpha, thumbSize, startOffset | |
) | |
} | |
} | |
fun Modifier.drawVerticalScrollbar( | |
state: LazyGridState, | |
spanCount: Int, | |
reverseScrolling: Boolean = false | |
): Modifier = drawScrollbar( | |
Orientation.Vertical, reverseScrolling | |
) { reverseDirection, atEnd, color, alpha -> | |
val layoutInfo = state.layoutInfo | |
val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset | |
val items = layoutInfo.visibleItemsInfo | |
val rowCount = (items.size + spanCount - 1) / spanCount | |
var itemsSize = 0 | |
for (i in 0 until rowCount) { | |
itemsSize += items[i * spanCount].size.height | |
} | |
if (items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize) { | |
val estimatedItemSize = if (rowCount == 0) 0f else itemsSize.toFloat() / rowCount | |
val totalRow = (layoutInfo.totalItemsCount + spanCount - 1) / spanCount | |
val totalSize = estimatedItemSize * totalRow | |
val canvasSize = size.height | |
val thumbSize = viewportSize / totalSize * canvasSize | |
val startOffset = if (rowCount == 0) 0f else items.first().run { | |
val rowIndex = index / spanCount | |
(estimatedItemSize * rowIndex - offset.y) / totalSize * canvasSize | |
} | |
drawScrollbar( | |
Orientation.Vertical, reverseDirection, atEnd, color, alpha, thumbSize, startOffset | |
) | |
} | |
} | |
private fun DrawScope.drawScrollbar( | |
orientation: Orientation, | |
reverseDirection: Boolean, | |
atEnd: Boolean, | |
color: Color, | |
alpha: () -> Float, | |
thumbSize: Float, | |
startOffset: Float | |
) { | |
val thicknessPx = Thickness.toPx() | |
val topLeft = if (orientation == Orientation.Horizontal) { | |
Offset( | |
if (reverseDirection) size.width - startOffset - thumbSize else startOffset, | |
if (atEnd) size.height - thicknessPx else 0f | |
) | |
} else { | |
Offset( | |
if (atEnd) size.width - thicknessPx else 0f, | |
if (reverseDirection) size.height - startOffset - thumbSize else startOffset | |
) | |
} | |
val size = if (orientation == Orientation.Horizontal) { | |
Size(thumbSize, thicknessPx) | |
} else { | |
Size(thicknessPx, thumbSize) | |
} | |
drawRect( | |
color = color, | |
topLeft = topLeft, | |
size = size, | |
alpha = alpha() | |
) | |
} | |
private fun Modifier.drawScrollbar( | |
orientation: Orientation, | |
reverseScrolling: Boolean, | |
onDraw: DrawScope.( | |
reverseDirection: Boolean, | |
atEnd: Boolean, | |
color: Color, | |
alpha: () -> Float | |
) -> Unit | |
): 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) | |
delay(ViewConfiguration.getScrollDefaultDelay().toLong()) | |
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 color = BarColor | |
Modifier | |
.nestedScroll(nestedScrollConnection) | |
.drawWithContent { | |
drawContent() | |
onDraw(reverseDirection, atEnd, color, alpha::value) | |
} | |
} | |
private val BarColor: Color | |
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) | |
private val Thickness = 4.dp | |
private val FadeOutAnimationSpec = | |
tween<Float>(durationMillis = ViewConfiguration.getScrollBarFadeDuration()) | |
@Preview(widthDp = 400, heightDp = 400, showBackground = true) | |
@Composable | |
internal fun ScrollbarPreview() { | |
val state = rememberScrollState() | |
Column( | |
modifier = Modifier | |
.drawVerticalScrollbar(state) | |
.verticalScroll(state), | |
) { | |
repeat(50) { | |
Text( | |
text = "Item ${it + 1}", | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(16.dp) | |
) | |
} | |
} | |
} | |
@Preview(widthDp = 400, heightDp = 400, showBackground = true) | |
@Composable | |
internal 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 | |
internal fun HorizontalScrollbarPreview() { | |
val state = rememberScrollState() | |
Row( | |
modifier = Modifier | |
.drawHorizontalScrollbar(state) | |
.horizontalScroll(state) | |
) { | |
repeat(50) { | |
Text( | |
text = (it + 1).toString(), | |
modifier = Modifier | |
.padding(horizontal = 8.dp, vertical = 16.dp) | |
) | |
} | |
} | |
} | |
@Preview(widthDp = 400, showBackground = true) | |
@Composable | |
internal 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) | |
) | |
} | |
} | |
} |
I want to add the Scrollbar on LazyVerticalGrid
. For this, I have modified the function but It's not displaying properly.
private fun Modifier.drawScrollbar(
state: LazyGridState,
orientation: Orientation,
reverseScrolling: Boolean
): Modifier = drawScrollbar(
orientation, reverseScrolling
) { reverseDirection, atEnd, thickness, color, alpha ->
val layoutInfo = state.layoutInfo
val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val items = layoutInfo.visibleItemsInfo
val itemsSize = items.fastMap { it.size }.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 canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height
val thumbSize = viewportSize / totalSize * canvasSize
val startOffset = if (items.isEmpty()) 0f else items
.first()
.run {
(estimatedItemSize * index - offset.y) / totalSize * canvasSize
}
val drawScrollbar = onDrawScrollbar(
orientation, reverseDirection, atEnd, showScrollbar,
thickness, color, alpha, thumbSize, startOffset
)
onDrawWithContent {
drawContent()
drawScrollbar()
}
}
I'm experiencing the same issue that @alashow mentioned. With Compose 1.3.0 and 1.3.1, the scrollbar is no longer updated during the scroll, only after the scroll is finished (i.e. when the fade-out animation starts).
It looks like state reads inside drawWithCache
are no longer getting picked up in Compose 1.3.0+. I'm not sure whether this is a bug in Compose or in the scrollbar implementation, but as a quick and dirty workaround, accessing the same states from a drawBehind
or drawWithContent
modifier seems to resolve the issue.
For example: https://gist.github.com/DonTomika/7d161a188722f00d4f580cf3f355958d/revisions
Yeah it seems that the original version is broken when using Compose 1.3 so I've updated the gist.
Pls, create a lib project and add the file. And upload to https://mvnrepository.com/artifact/
It's in backlog now.. need to implement Modifier.Node implementation of this until then
For some reason scrollbar stopped moving when scrolling, it shows up when scroll stops