From 530a584b7d3abf327cce08dc9fc1b5fa52d9affd Mon Sep 17 00:00:00 2001 From: oxy Date: Sun, 7 Jul 2024 18:33:12 +0800 Subject: [PATCH] DraggableScrollbar #172 --- .../components/SmartphoneChannelGallery.kt | 82 ++-- .../components/VerticalDraggableScrollbar.kt | 369 ++++++++++++++++++ 2 files changed, 418 insertions(+), 33 deletions(-) create mode 100644 material/src/main/java/com/m3u/material/components/VerticalDraggableScrollbar.kt diff --git a/feature/playlist/src/main/java/com/m3u/feature/playlist/components/SmartphoneChannelGallery.kt b/feature/playlist/src/main/java/com/m3u/feature/playlist/components/SmartphoneChannelGallery.kt index 5517e0e6d..b476015a9 100644 --- a/feature/playlist/src/main/java/com/m3u/feature/playlist/components/SmartphoneChannelGallery.kt +++ b/feature/playlist/src/main/java/com/m3u/feature/playlist/components/SmartphoneChannelGallery.kt @@ -3,9 +3,11 @@ package com.m3u.feature.playlist.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells @@ -20,10 +22,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems import com.m3u.core.architecture.preferences.hiltPreferences -import com.m3u.data.database.model.Programme import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.Programme import com.m3u.feature.playlist.PlaylistViewModel import com.m3u.material.components.CircularProgressIndicator +import com.m3u.material.components.VerticalDraggableScrollbar import com.m3u.material.ktx.plus import com.m3u.material.model.LocalSpacing @@ -52,41 +55,54 @@ internal fun SmartphoneChannelGallery( val channels = categoryWithChannels?.channels?.collectAsLazyPagingItems() - LazyVerticalStaggeredGrid( - state = state, - columns = StaggeredGridCells.Fixed(actualRowCount), - verticalItemSpacing = spacing.medium, - horizontalArrangement = Arrangement.spacedBy(spacing.medium), - contentPadding = PaddingValues(spacing.medium) + contentPadding, - modifier = modifier.fillMaxSize() + Row( + modifier = modifier + .fillMaxSize() + .padding(start = spacing.medium), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - items(channels?.itemCount ?: 0) { index -> - val channel = channels?.get(index) - if (channel != null) { - var programme: Programme? by remember { mutableStateOf(null) } - LaunchedEffect(channel.originalId) { - programme = getProgrammeCurrently(channel.originalId.orEmpty()) - } - SmartphoneChannelItem( - channel = channel, - programme = programme, - recently = recently, - zapping = zapping == channel, - isVodOrSeriesPlaylist = isVodOrSeriesPlaylist, - onClick = { onClick(channel) }, - onLongClick = { onLongClick(channel) }, - modifier = Modifier.fillMaxWidth() - ) - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - ) { - CircularProgressIndicator() + LazyVerticalStaggeredGrid( + state = state, + columns = StaggeredGridCells.Fixed(actualRowCount), + verticalItemSpacing = spacing.medium, + horizontalArrangement = Arrangement.spacedBy(spacing.medium), + contentPadding = PaddingValues(vertical = spacing.medium) + contentPadding, + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + items(channels?.itemCount ?: 0) { index -> + val channel = channels?.get(index) + if (channel != null) { + var programme: Programme? by remember { mutableStateOf(null) } + LaunchedEffect(channel.originalId) { + programme = getProgrammeCurrently(channel.originalId.orEmpty()) + } + SmartphoneChannelItem( + channel = channel, + programme = programme, + recently = recently, + zapping = zapping == channel, + isVodOrSeriesPlaylist = isVodOrSeriesPlaylist, + onClick = { onClick(channel) }, + onLongClick = { onLongClick(channel) }, + modifier = Modifier.fillMaxWidth() + ) + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + CircularProgressIndicator() + } } } } + VerticalDraggableScrollbar( + lazyStaggeredGridState = state, + modifier = Modifier.padding(5.dp) + ) } } diff --git a/material/src/main/java/com/m3u/material/components/VerticalDraggableScrollbar.kt b/material/src/main/java/com/m3u/material/components/VerticalDraggableScrollbar.kt new file mode 100644 index 000000000..0031581d8 --- /dev/null +++ b/material/src/main/java/com/m3u/material/components/VerticalDraggableScrollbar.kt @@ -0,0 +1,369 @@ +package com.m3u.material.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.dp +import com.m3u.material.model.LocalSpacing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun VerticalDraggableScrollbar( + lazyListState: LazyListState, + modifier: Modifier = Modifier, + color: Color = Color.White +) { + val visibleCountPercent by remember { + derivedStateOf { + val visibleItemsCount = lazyListState.layoutInfo.visibleItemsInfo.size + val totalItemsCount = lazyListState.layoutInfo.totalItemsCount + if (totalItemsCount == 0) 0f + else visibleItemsCount.toFloat() / totalItemsCount + } + } + val coroutineScope = rememberCoroutineScope() + val draggableState = rememberDraggableState { delta -> + coroutineScope.launch { + lazyListState.scrollBy(delta / visibleCountPercent) + } + } + var isDragging: Boolean by remember { mutableStateOf(false) } + var isScrolling: Boolean by remember { mutableStateOf(false) } + val fadedIsDragging: Boolean by produceState(isDragging) { + snapshotFlow { isDragging } + .onEach { + if (!it) delay(800.milliseconds) + value = it + } + .launchIn(this) + } + val fadedIsScrolling: Boolean by produceState(isScrolling) { + snapshotFlow { isScrolling } + .onEach { + if (!it) delay(400.milliseconds) + value = it + } + .launchIn(this) + } + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset }.collectLatest { + isScrolling = true + delay(200.milliseconds) + isScrolling = false + } + } + val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = + { isDragging = true } + val onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = { isDragging = false } + val percent by remember(lazyListState) { + derivedStateOf { + val totalItemsCount = lazyListState.layoutInfo.totalItemsCount + val firstVisibleItemIndex = lazyListState.firstVisibleItemIndex + if (totalItemsCount == 0) 0f + else { + val firstVisiblePercent = with(lazyListState) { + firstVisibleItemScrollOffset.toFloat() / layoutInfo.visibleItemsInfo[0].size + } + ((firstVisibleItemIndex + firstVisiblePercent) / totalItemsCount).coerceIn(0f, 1f) + } + } + } + val currentAlpha by animateFloatAsState( + targetValue = if (fadedIsDragging || fadedIsScrolling) 1f else 0.65f, + label = "current-alpha" + ) + val currentPosition by animateFloatAsState( + targetValue = percent, + label = "current-position" + ) + val currentVisibleCountPercent by animateFloatAsState( + targetValue = visibleCountPercent, + label = "current-visible-count-percent", + animationSpec = tween(200, delayMillis = 200) + ) + val currentWidth by animateDpAsState( + targetValue = if (isDragging) 16.dp else 12.dp, + label = "current-width" + ) + Canvas( + modifier = modifier + .fillMaxHeight() + .requiredWidth(currentWidth) + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped + ) + ) { + drawVerticalDraggableScrollbar( + color = color, + currentAlpha = currentAlpha, + currentPosition = currentPosition, + currentVisibleCountPercent = currentVisibleCountPercent + ) + } +} + +@Composable +fun VerticalDraggableScrollbar( + lazyGridState: LazyGridState, + modifier: Modifier = Modifier, + color: Color = Color.White +) { + val visibleCountPercent by remember { + derivedStateOf { + val visibleItemsCount = lazyGridState.layoutInfo.visibleItemsInfo.size + val totalItemsCount = lazyGridState.layoutInfo.totalItemsCount + if (totalItemsCount == 0) 0f + else visibleItemsCount.toFloat() / totalItemsCount + } + } + val coroutineScope = rememberCoroutineScope() + val draggableState = rememberDraggableState { delta -> + coroutineScope.launch { + lazyGridState.scrollBy(delta / visibleCountPercent) + } + } + var isDragging: Boolean by remember { mutableStateOf(false) } + var isScrolling: Boolean by remember { mutableStateOf(false) } + val fadedIsDragging: Boolean by produceState(isDragging) { + snapshotFlow { isDragging } + .onEach { + if (!it) delay(800.milliseconds) + value = it + } + .launchIn(this) + } + val fadedIsScrolling: Boolean by produceState(isScrolling) { + snapshotFlow { isScrolling } + .onEach { + if (!it) delay(400.milliseconds) + value = it + } + .launchIn(this) + } + LaunchedEffect(Unit) { + snapshotFlow { lazyGridState.firstVisibleItemScrollOffset }.collectLatest { + isScrolling = true + delay(200.milliseconds) + isScrolling = false + } + } + val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = + { isDragging = true } + val onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = { isDragging = false } + val percent by remember(lazyGridState) { + derivedStateOf { + val totalItemsCount = lazyGridState.layoutInfo.totalItemsCount + val firstVisibleItemIndex = lazyGridState.firstVisibleItemIndex + if (totalItemsCount == 0) 0f + else { + val firstVisiblePercent = with(lazyGridState) { + firstVisibleItemScrollOffset.toFloat() / layoutInfo.visibleItemsInfo[0].size.height + } + ((firstVisibleItemIndex + firstVisiblePercent) / totalItemsCount).coerceIn(0f, 1f) + } + } + } + val currentAlpha by animateFloatAsState( + targetValue = if (fadedIsDragging || fadedIsScrolling) 1f else 0.65f, + label = "current-alpha" + ) + val currentPosition by animateFloatAsState( + targetValue = percent, + label = "current-position" + ) + val currentVisibleCountPercent by animateFloatAsState( + targetValue = visibleCountPercent, + label = "current-visible-count-percent", + animationSpec = tween(200, delayMillis = 200) + ) + val currentWidth by animateDpAsState( + targetValue = if (isDragging) 16.dp else 12.dp, + label = "current-width" + ) + Canvas( + modifier = modifier + .fillMaxHeight() + .requiredWidth(currentWidth) + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped + ) + ) { + drawVerticalDraggableScrollbar( + color = color, + currentAlpha = currentAlpha, + currentPosition = currentPosition, + currentVisibleCountPercent = currentVisibleCountPercent + ) + } +} + +@Composable +fun VerticalDraggableScrollbar( + lazyStaggeredGridState: LazyStaggeredGridState, + modifier: Modifier = Modifier, + color: Color = Color.White, + alwaysShow: Boolean = false +) { + val visibleCountPercent by remember { + derivedStateOf { + val visibleItemsCount = lazyStaggeredGridState.layoutInfo.visibleItemsInfo.size + val totalItemsCount = lazyStaggeredGridState.layoutInfo.totalItemsCount + if (totalItemsCount == 0) 0f + else visibleItemsCount.toFloat() / totalItemsCount + } + } + val shouldShow by remember(alwaysShow) { + derivedStateOf { + alwaysShow || (visibleCountPercent > 0f && visibleCountPercent <= 0.85f) + } + } + val coroutineScope = rememberCoroutineScope() + val draggableState = rememberDraggableState { delta -> + coroutineScope.launch { + if (!shouldShow) return@launch + lazyStaggeredGridState.scrollBy(delta / visibleCountPercent) + } + } + var isDragging: Boolean by remember { mutableStateOf(false) } + var isScrolling: Boolean by remember { mutableStateOf(false) } + val fadedIsDragging: Boolean by produceState(isDragging) { + snapshotFlow { isDragging } + .onEach { + if (!it) delay(800.milliseconds) + value = it + } + .launchIn(this) + } + val fadedIsScrolling: Boolean by produceState(isScrolling) { + snapshotFlow { isScrolling } + .onEach { + if (!it) delay(400.milliseconds) + value = it + } + .launchIn(this) + } + LaunchedEffect(Unit) { + snapshotFlow { lazyStaggeredGridState.firstVisibleItemScrollOffset }.collectLatest { + isScrolling = true + delay(200.milliseconds) + isScrolling = false + } + } + val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = + { isDragging = true } + val onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = { isDragging = false } + val percent by remember(lazyStaggeredGridState) { + derivedStateOf { + val totalItemsCount = lazyStaggeredGridState.layoutInfo.totalItemsCount + val firstVisibleItemIndex = lazyStaggeredGridState.firstVisibleItemIndex + if (totalItemsCount == 0) 0f + else { + val firstVisiblePercent = with(lazyStaggeredGridState) { + firstVisibleItemScrollOffset.toFloat() / layoutInfo.visibleItemsInfo[0].size.height + } + ((firstVisibleItemIndex + firstVisiblePercent) / totalItemsCount).coerceIn(0f, 1f) + } + } + } + val currentAlpha by animateFloatAsState( + targetValue = if (fadedIsDragging || fadedIsScrolling) 1f else 0.65f, + label = "current-alpha" + ) + val currentPosition by animateFloatAsState( + targetValue = percent, + label = "current-position" + ) + val currentVisibleCountPercent by animateFloatAsState( + targetValue = visibleCountPercent, + label = "current-visible-count-percent", + animationSpec = tween(200, delayMillis = 200) + ) + val currentWidth by animateDpAsState( + targetValue = if (isDragging) 16.dp else 12.dp, + label = "current-width" + ) + if (shouldShow) { + Canvas( + modifier = modifier + .fillMaxHeight() + .requiredWidth(currentWidth) + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped + ) + ) { + drawVerticalDraggableScrollbar( + color = color, + currentAlpha = currentAlpha, + currentPosition = currentPosition, + currentVisibleCountPercent = currentVisibleCountPercent + ) + } + } else { + Spacer(modifier = Modifier.width(LocalSpacing.current.medium)) + } + +} + +private fun DrawScope.drawVerticalDraggableScrollbar( + color: Color, + currentAlpha: Float, + currentPosition: Float, + currentVisibleCountPercent: Float +) { + val maxHeight = size.height + val minDimension = size.minDimension + drawRoundRect( + color = color, + alpha = currentAlpha, + topLeft = Offset( + x = 0f, + y = currentPosition * maxHeight + ), + size = size.copy( + height = (maxHeight * currentVisibleCountPercent).coerceAtLeast(minDimension) + ), + cornerRadius = CornerRadius(minDimension) + ) +} \ No newline at end of file