From b7813d45f4f72aa05691714258326521d6ea1094 Mon Sep 17 00:00:00 2001 From: Matt Vaughn <58408581+mattttvaughn@users.noreply.github.com> Date: Wed, 11 May 2022 11:26:37 -0400 Subject: [PATCH] Add a scrollbar to the article screen (#63) Co-authored-by: Matt Vaughn --- app/build.gradle | 1 + .../me/ash/reader/ui/ext/ScrollbarsExt.kt | 320 ++++++++++++++++++ .../ash/reader/ui/page/home/read/ReadPage.kt | 106 +++--- .../reader/ui/page/home/read/ReadViewModel.kt | 15 +- 4 files changed, 370 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt diff --git a/app/build.gradle b/app/build.gradle index 1528f1e..585743f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -149,6 +149,7 @@ dependencies { implementation "androidx.compose.animation:animation-graphics:$compose" // https://developer.android.com/jetpack/androidx/releases/compose-ui implementation "androidx.compose.ui:ui:$compose" + implementation "androidx.compose.ui:ui-util:$compose" // https://developer.android.com/jetpack/androidx/releases/compose-material implementation "androidx.compose.material:material:$compose" implementation "androidx.compose.material:material-icons-extended:$compose" diff --git a/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt b/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt new file mode 100644 index 0000000..ce85a11 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt @@ -0,0 +1,320 @@ +package me.ash.reader.ui.ext + +// From gist: https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a/ + +/* + * 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.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.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.LocalDensity +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) + +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: ScrollState, + orientation: Orientation, + reverseScrolling: Boolean +): Modifier = drawScrollbar( + orientation, reverseScrolling +) { reverseDirection, atEnd, thickness, color, alpha -> + val showScrollbar = 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 + val drawScrollbar = onDrawScrollbar( + orientation, reverseDirection, atEnd, showScrollbar, + thickness, color, alpha, thumbSize, startOffset + ) + onDrawWithContent { + drawContent() + drawScrollbar() + } +} + +private fun Modifier.drawScrollbar( + state: LazyListState, + 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.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 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 + } + val drawScrollbar = onDrawScrollbar( + orientation, reverseDirection, atEnd, showScrollbar, + thickness, color, alpha, thumbSize, startOffset + ) + onDrawWithContent { + drawContent() + drawScrollbar() + } +} + +private fun CacheDrawScope.onDrawScrollbar( + orientation: Orientation, + reverseDirection: Boolean, + atEnd: Boolean, + showScrollbar: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + thumbSize: Float, + startOffset: Float +): DrawScope.() -> Unit { + val topLeft = if (orientation == Orientation.Horizontal) { + Offset( + if (reverseDirection) size.width - startOffset - thumbSize else startOffset, + if (atEnd) size.height - thickness else 0f + ) + } else { + Offset( + if (atEnd) size.width - thickness else 0f, + if (reverseDirection) size.height - startOffset - thumbSize else startOffset + ) + } + 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) + 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 + + // Calculate thickness here to workaround https://issuetracker.google.com/issues/206972664 + val thickness = with(LocalDensity.current) { Thickness.toPx() } + val color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + Modifier + .nestedScroll(nestedScrollConnection) + .drawWithCache { + onBuildDrawCache(reverseDirection, atEnd, thickness, color, alpha::value) + } +} + +private val Thickness = 4.dp +private val FadeOutAnimationSpec = + tween(durationMillis = ViewConfiguration.getScrollBarFadeDuration()) + +@Preview(widthDp = 400, heightDp = 400, showBackground = true) +@Composable +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 +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 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 +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/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt index 812fe0d..d9e0fd8 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt @@ -2,10 +2,10 @@ package me.ash.reader.ui.page.home.read import android.util.Log import androidx.compose.animation.* +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Headphones import androidx.compose.material.icons.outlined.MoreVert @@ -25,6 +25,7 @@ import me.ash.reader.data.entity.ArticleWithFeed import me.ash.reader.ui.component.FeedbackIconButton import me.ash.reader.ui.component.WebView import me.ash.reader.ui.ext.collectAsStateValue +import me.ash.reader.ui.ext.drawVerticalScrollbar @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,21 +44,14 @@ fun ReadPage( } } - if (viewState.listState.isScrollInProgress) { + if (viewState.scrollState.isScrollInProgress) { LaunchedEffect(Unit) { Log.i("RLog", "scroll: start") } - val preItemIndex by remember { mutableStateOf(viewState.listState.firstVisibleItemIndex) } - val preScrollStartOffset by remember { mutableStateOf(viewState.listState.firstVisibleItemScrollOffset) } - val currentItemIndex = viewState.listState.firstVisibleItemIndex - val currentScrollStartOffset = viewState.listState.firstVisibleItemScrollOffset - - isScrollDown = when { - currentItemIndex > preItemIndex -> true - currentItemIndex < preItemIndex -> false - else -> currentScrollStartOffset > preScrollStartOffset - } + val preScrollOffset by remember { mutableStateOf(viewState.scrollState.value) } + val currentOffset = viewState.scrollState.value + isScrollDown = currentOffset > preScrollOffset DisposableEffect(Unit) { onDispose { @@ -98,7 +92,7 @@ fun ReadPage( content = viewState.content ?: "", articleWithFeed = viewState.articleWithFeed, viewState = viewState, - LazyListState = viewState.listState, + scrollState = viewState.scrollState, ) Box( modifier = Modifier @@ -182,10 +176,14 @@ private fun Content( content: String, articleWithFeed: ArticleWithFeed?, viewState: ReadViewState, - LazyListState: LazyListState = rememberLazyListState(), + scrollState: ScrollState = rememberScrollState(), ) { Column( - modifier = Modifier.statusBarsPadding(), + modifier = Modifier + .statusBarsPadding() + .navigationBarsPadding() + .drawVerticalScrollbar(scrollState) + .verticalScroll(scrollState), ) { if (articleWithFeed == null) { Spacer(modifier = Modifier.height(64.dp)) @@ -196,54 +194,44 @@ private fun Content( // url = "https://assets8.lottiefiles.com/packages/lf20_jm7mv1ib.json", // ) } else { - LazyColumn( - state = LazyListState, - ) { - item { - Spacer(modifier = Modifier.height(64.dp)) + Column { + Spacer(modifier = Modifier.height(64.dp)) + Spacer(modifier = Modifier.height(2.dp)) + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + ) { + Header(articleWithFeed) } - item { - Spacer(modifier = Modifier.height(2.dp)) - Column( - modifier = Modifier - .padding(horizontal = 12.dp) + Spacer(modifier = Modifier.height(22.dp)) + AnimatedVisibility( + visible = viewState.isLoading, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - Header(articleWithFeed) - } - } - item { - Spacer(modifier = Modifier.height(22.dp)) - AnimatedVisibility( - visible = viewState.isLoading, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column { - Spacer(modifier = Modifier.height(22.dp)) - CircularProgressIndicator( - modifier = Modifier - .size(30.dp), - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(22.dp)) - } + Column { + Spacer(modifier = Modifier.height(22.dp)) + CircularProgressIndicator( + modifier = Modifier + .size(30.dp), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(22.dp)) } } - if (!viewState.isLoading) { - WebView( - content = content - ) - Spacer(modifier = Modifier.height(50.dp)) - } } - item { - Spacer(modifier = Modifier.height(64.dp)) - Spacer(modifier = Modifier.height(64.dp)) + if (!viewState.isLoading) { + WebView( + content = content + ) + Spacer(modifier = Modifier.height(50.dp)) } + Spacer(modifier = Modifier.height(64.dp)) + Spacer(modifier = Modifier.height(64.dp)) } } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt index 5b92fe7..a947583 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt @@ -1,7 +1,7 @@ package me.ash.reader.ui.page.home.read import android.util.Log -import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.ScrollState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,7 +31,6 @@ class ReadViewModel @Inject constructor( is ReadViewAction.RenderFullContent -> renderFullContent() is ReadViewAction.MarkUnread -> markUnread(action.isUnread) is ReadViewAction.MarkStarred -> markStarred(action.isStarred) - is ReadViewAction.ScrollToItem -> scrollToItem(action.index) is ReadViewAction.ClearArticle -> clearArticle() is ReadViewAction.ChangeLoading -> changeLoading(action.isLoading) } @@ -130,12 +129,6 @@ class ReadViewModel @Inject constructor( } } - private fun scrollToItem(index: Int) { - viewModelScope.launch { - _viewState.value.listState.scrollToItem(index) - } - } - private fun clearArticle() { _viewState.update { it.copy(articleWithFeed = null) @@ -153,7 +146,7 @@ data class ReadViewState( val articleWithFeed: ArticleWithFeed? = null, val content: String? = null, val isLoading: Boolean = true, - val listState: LazyListState = LazyListState(), + val scrollState: ScrollState = ScrollState(0), ) sealed class ReadViewAction { @@ -173,10 +166,6 @@ sealed class ReadViewAction { val isStarred: Boolean, ) : ReadViewAction() - data class ScrollToItem( - val index: Int - ) : ReadViewAction() - object ClearArticle : ReadViewAction() data class ChangeLoading(