diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/LazyColumnScrollbar.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/LazyColumnScrollbar.kt new file mode 100644 index 00000000..d9ceb7a7 --- /dev/null +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/LazyColumnScrollbar.kt @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.floor +import kotlinx.coroutines.launch +import tm.alashow.ui.theme.AppTheme + +// from https://github.com/nanihadesuka/LazyColumnScrollbar + +@Composable +fun LazyColumnScrollbar( + listState: LazyListState, + rightSide: Boolean = true, + enabled: Boolean = true, + thickness: Dp = 4.dp, + padding: Dp = AppTheme.specs.paddingSmall, + thumbHeight: Float = 0.1f, + thumbColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f), + thumbSelectedColor: Color = MaterialTheme.colors.secondary, + thumbShape: Shape = CircleShape, + content: @Composable () -> Unit +) { + Box { + content() + if (enabled) + LazyColumnScrollbar( + listState = listState, + rightSide = rightSide, + thickness = thickness, + padding = padding, + thumbHeight = thumbHeight, + thumbColor = thumbColor, + thumbSelectedColor = thumbSelectedColor, + thumbShape = thumbShape, + ) + } +} + +/** + * Scrollbar for LazyColumn + * + * @param rightSide true -> right, false -> left + * @param thickness Thickness of the scrollbar thumb + * @param padding Padding of the scrollbar + * @param thumbHeight Thumb height proportional to total scrollbar's height (eg: 0.1 -> 10% of total) + */ +@Composable +fun LazyColumnScrollbar( + listState: LazyListState, + rightSide: Boolean = true, + thickness: Dp = 4.dp, + padding: Dp = AppTheme.specs.paddingSmall, + thumbHeight: Float = 0.1f, + thumbColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f), + thumbSelectedColor: Color = MaterialTheme.colors.secondary, + thumbShape: Shape = CircleShape +) { + val coroutineScope = rememberCoroutineScope() + var isSelected by remember { mutableStateOf(false) } + var dragOffset by remember { mutableStateOf(0f) } + + fun normalizedOffsetPosition() = listState.layoutInfo.let { + if (it.totalItemsCount == 0 || it.visibleItemsInfo.isEmpty()) 0f + else it.visibleItemsInfo.first().run { index.toFloat() - offset.toFloat() / size.toFloat() } / it.totalItemsCount.toFloat() + } + + fun setScrollOffset(newOffset: Float) { + dragOffset = newOffset.coerceIn(0f, 1f) + + val exactIndex: Float = listState.layoutInfo.totalItemsCount.toFloat() * dragOffset + val index: Int = floor(exactIndex).toInt() + val remainder: Float = exactIndex - floor(exactIndex) + + coroutineScope.launch { + listState.scrollToItem(index = index, scrollOffset = 0) + val offset = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.size?.let { it.toFloat() * remainder }?.toInt() ?: 0 + listState.scrollToItem(index = index, scrollOffset = offset) + } + } + + val isInAction = listState.isScrollInProgress || isSelected + + val alpha by animateFloatAsState( + targetValue = if (isInAction) 1f else 0f, + animationSpec = tween(durationMillis = if (isInAction) 75 else 500, delayMillis = if (isInAction) 0 else 500) + ) + + val displacement by animateFloatAsState( + targetValue = if (isInAction) 0f else 14f, + animationSpec = tween(durationMillis = if (isInAction) 75 else 500, delayMillis = if (isInAction) 0 else 500) + ) + + BoxWithConstraints(Modifier.fillMaxWidth()) { + val dragState = rememberDraggableState { delta -> + setScrollOffset(dragOffset + delta / constraints.maxHeight.toFloat()) + } + + BoxWithConstraints( + Modifier + .align(if (rightSide) Alignment.TopEnd else Alignment.TopStart) + .alpha(alpha) + .fillMaxHeight() + .draggable( + state = dragState, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = { offset -> + val newOffset = offset.y / constraints.maxHeight.toFloat() + val currentOffset = normalizedOffsetPosition() + + if (currentOffset < newOffset && newOffset < currentOffset) + dragOffset = currentOffset + else + setScrollOffset(newOffset) + isSelected = true + }, + onDragStopped = { + isSelected = false + } + ) + .absoluteOffset(x = if (rightSide) displacement.dp else -displacement.dp) + ) { + Box( + Modifier + .align(Alignment.TopEnd) + .graphicsLayer { translationY = constraints.maxHeight.toFloat() * normalizedOffsetPosition() } + .padding(horizontal = padding) + .width(thickness) + .clip(thumbShape) + .background(if (isSelected) thumbSelectedColor else thumbColor) + .fillMaxHeight(thumbHeight) + ) + } + } +} diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/ListScrollbar.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/ListScrollbar.kt deleted file mode 100644 index 64a9d055..00000000 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/ListScrollbar.kt +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright (C) 2022, Alashov Berkeli - * All rights reserved. - */ -package tm.alashow.ui - -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.material.MaterialTheme -import androidx.compose.material.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 - -// From: https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a - -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.colors.onSurface.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/modules/core-ui-media/src/main/java/tm/alashow/datmusic/ui/detail/MediaDetail.kt b/modules/core-ui-media/src/main/java/tm/alashow/datmusic/ui/detail/MediaDetail.kt index d7c31443..719a1975 100644 --- a/modules/core-ui-media/src/main/java/tm/alashow/datmusic/ui/detail/MediaDetail.kt +++ b/modules/core-ui-media/src/main/java/tm/alashow/datmusic/ui/detail/MediaDetail.kt @@ -27,6 +27,7 @@ import tm.alashow.base.util.extensions.orNA import tm.alashow.domain.models.Incomplete import tm.alashow.navigation.LocalNavigator import tm.alashow.navigation.Navigator +import tm.alashow.ui.LazyColumnScrollbar import tm.alashow.ui.LocalAdaptiveColorResult import tm.alashow.ui.adaptiveColor import tm.alashow.ui.components.FullScreenLoading @@ -44,6 +45,7 @@ fun MediaDetail( mediaDetailFail: MediaDetailFail = MediaDetailFail(), mediaDetailEmpty: MediaDetailEmpty = MediaDetailEmpty(), headerCoverIcon: VectorPainter? = null, + scrollbarsEnabled: Boolean = false, isHeaderVisible: Boolean = true, extraHeaderContent: @Composable ColumnScope.() -> Unit = {}, navigator: Navigator = LocalNavigator.current, @@ -65,6 +67,7 @@ fun MediaDetail( onEmptyRetry = onEmptyRetry, onTitleClick = onTitleClick, padding = padding, + scrollbarsEnabled = scrollbarsEnabled, listState = listState, mediaDetailHeader = mediaDetailHeader, mediaDetailContent = mediaDetailContent, @@ -84,6 +87,7 @@ private fun > MediaDetailConten onEmptyRetry: Callback, onTitleClick: Callback, listState: LazyListState, + scrollbarsEnabled: Boolean, mediaDetailContent: MediaDetailContent, mediaDetailHeader: MediaDetailHeader, mediaDetailFail: MediaDetailFail, @@ -115,48 +119,50 @@ private fun > MediaDetailConten val bottomPadding = (if (isHeaderVisible) padding.calculateTopPadding() else 0.dp) + padding.calculateBottomPadding() CompositionLocalProvider(LocalAdaptiveColorResult provides adaptiveColor) { - LazyColumn( - state = listState, - contentPadding = PaddingValues( - top = topPadding, - bottom = bottomPadding, - ), - modifier = listBackgroundMod.fillMaxSize(), - ) { - val details = viewState.details() - val detailsLoading = details is Incomplete + LazyColumnScrollbar(listState = listState, enabled = scrollbarsEnabled) { + LazyColumn( + state = listState, + contentPadding = PaddingValues( + top = topPadding, + bottom = bottomPadding, + ), + modifier = listBackgroundMod.fillMaxSize(), + ) { + val details = viewState.details() + val detailsLoading = details is Incomplete - if (isHeaderVisible) - mediaDetailHeader( + if (isHeaderVisible) + mediaDetailHeader( + list = this, + listState = listState, + headerBackgroundMod = headerBackgroundMod, + title = viewState.title.orNA(), + artwork = artwork, + onTitleClick = onTitleClick, + headerCoverIcon = headerCoverIcon, + extraHeaderContent = extraHeaderContent, + ) + + val isEmpty = mediaDetailContent( list = this, - listState = listState, - headerBackgroundMod = headerBackgroundMod, - title = viewState.title.orNA(), - artwork = artwork, - onTitleClick = onTitleClick, - headerCoverIcon = headerCoverIcon, - extraHeaderContent = extraHeaderContent, + details = details, + detailsLoading = detailsLoading ) - val isEmpty = mediaDetailContent( - list = this, - details = details, - detailsLoading = detailsLoading - ) - - mediaDetailFail( - list = this, - details = details, - onFailRetry = onFailRetry - ) + mediaDetailFail( + list = this, + details = details, + onFailRetry = onFailRetry + ) - mediaDetailEmpty( - list = this, - details = details, - isHeaderVisible = isHeaderVisible, - detailsEmpty = isEmpty, - onEmptyRetry = onEmptyRetry - ) + mediaDetailEmpty( + list = this, + details = details, + isHeaderVisible = isHeaderVisible, + detailsEmpty = isEmpty, + onEmptyRetry = onEmptyRetry + ) + } } } } else FullScreenLoading() diff --git a/modules/ui-downloads/src/main/java/tm/alashow/datmusic/ui/downloads/Downloads.kt b/modules/ui-downloads/src/main/java/tm/alashow/datmusic/ui/downloads/Downloads.kt index 8bb46b81..8eaa5fc5 100644 --- a/modules/ui-downloads/src/main/java/tm/alashow/datmusic/ui/downloads/Downloads.kt +++ b/modules/ui-downloads/src/main/java/tm/alashow/datmusic/ui/downloads/Downloads.kt @@ -69,6 +69,7 @@ import tm.alashow.domain.models.Loading import tm.alashow.domain.models.Success import tm.alashow.domain.models.Uninitialized import tm.alashow.ui.Delayed +import tm.alashow.ui.LazyColumnScrollbar import tm.alashow.ui.LifecycleRespectingBackHandler import tm.alashow.ui.components.AppBarNavigationIcon import tm.alashow.ui.components.AppTopBar @@ -96,11 +97,13 @@ private fun Downloads(viewModel: DownloadsViewModel) { when (val asyncDownloads = viewState.downloads) { is Uninitialized, is Loading -> FullScreenLoading() is Fail -> DownloadsError(asyncDownloads) - is Success -> LazyColumn( - state = listState, - contentPadding = padding, - ) { - downloadsList(asyncDownloads(), viewModel::playAudioDownload) + is Success -> LazyColumnScrollbar(listState) { + LazyColumn( + state = listState, + contentPadding = padding, + ) { + downloadsList(asyncDownloads(), viewModel::playAudioDownload) + } } } } diff --git a/modules/ui-library/src/main/java/tm/alashow/datmusic/ui/library/playlists/detail/PlaylistDetail.kt b/modules/ui-library/src/main/java/tm/alashow/datmusic/ui/library/playlists/detail/PlaylistDetail.kt index b1e9320e..82f32539 100644 --- a/modules/ui-library/src/main/java/tm/alashow/datmusic/ui/library/playlists/detail/PlaylistDetail.kt +++ b/modules/ui-library/src/main/java/tm/alashow/datmusic/ui/library/playlists/detail/PlaylistDetail.kt @@ -40,6 +40,7 @@ private fun PlaylistDetail( val playlistId = viewState.playlist?.id MediaDetail( viewState = viewState, + scrollbarsEnabled = viewState.isLoaded && !viewState.isEmpty, titleRes = R.string.playlist_title, onFailRetry = viewModel::refresh, onEmptyRetry = viewModel::addSongs,