diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/HorizontalTabPager.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/HorizontalTabPager.kt new file mode 100644 index 0000000..d3071b6 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/HorizontalTabPager.kt @@ -0,0 +1,171 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.core.tween +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +@Stable +class TabPagerState( + val pageCount: Int, + val initialPageIndex: Int, +) { + var pageIndex by mutableStateOf(initialPageIndex) + + var tempPageIndex: Int? = null + + val animatable = Animatable(0f) + + val offset by animatable.asState() + + fun updateBounds(lowerBound: Float, upperBound: Float) { + animatable.updateBounds(lowerBound, upperBound) + } + + suspend fun animateScrollTo(newPageIndex: Int) { + tempPageIndex = newPageIndex + if (newPageIndex > pageIndex) { + animatable.animateTo( + animatable.upperBound!!, tween( + durationMillis = 500, + easing = FastOutSlowInEasing + ) + ) + } else if (newPageIndex < pageIndex) { + animatable.animateTo( + animatable.lowerBound!!, tween( + durationMillis = 500, + easing = FastOutSlowInEasing + ) + ) + } + + pageIndex = newPageIndex + animatable.snapTo(0f) + tempPageIndex = null + } +} + +@Composable +fun rememberTabPagerState(initialPageIndex: Int, pageCount: Int): TabPagerState { + return remember { + TabPagerState( + pageCount = pageCount, + initialPageIndex = initialPageIndex, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HorizontalTabPager( + state: TabPagerState, + modifier: Modifier = Modifier, + content: @Composable (index: Int) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + val itemProvider = remember(state) { + object : LazyLayoutItemProvider { + override val itemCount = state.pageCount + + @Composable + override fun Item(index: Int) = content(index) + } + } + + LazyLayout( + itemProvider = itemProvider, + modifier = modifier + .clipToBounds() + .pointerInput(state) { + val velocityTracker = VelocityTracker() + val decay = splineBasedDecay(this) + + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + coroutineScope.launch { + state.animatable.snapTo(state.offset - dragAmount) + } + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().x + val initialTargetValue = + decay.calculateTargetValue(state.offset, velocity) + + velocityTracker.resetTracking() + + coroutineScope.launch { + val isEnough = initialTargetValue.absoluteValue > size.width / 2 + if (initialTargetValue > 0) { + state.animatable.animateTo( + targetValue = if (isEnough) size.width.toFloat() else 0f, + initialVelocity = if (isEnough) velocity else 0f + ) + if (isEnough) { + state.pageIndex = state.pageIndex + .plus(1) + .coerceAtMost(state.pageCount - 1) + state.animatable.snapTo(0f) + } + } else { + state.animatable.animateTo( + targetValue = if (isEnough) -size.width.toFloat() else 0f, + initialVelocity = if (isEnough) velocity else 0f + ) + if (isEnough) { + state.pageIndex = state.pageIndex + .minus(1) + .coerceAtLeast(0) + state.animatable.snapTo(0f) + } + } + } + } + ) + } + ) { constraints -> + val previousPlaceable = state.offset.takeIf { it < 0 }?.let { + (state.tempPageIndex ?: (state.pageIndex - 1)).takeIf { it >= 0 }?.let { index -> + measure(index, constraints).first() + } + } + val placeable = measure(state.pageIndex, constraints).first() + + val nextPlaceable = state.offset.takeIf { it > 0 }?.let { + (state.tempPageIndex ?: (state.pageIndex + 1)).takeIf { it < state.pageCount } + ?.let { index -> + measure(index, constraints).first() + } + } + + state.updateBounds( + lowerBound = if (state.pageIndex == 0) 0f else -constraints.maxWidth.toFloat(), + upperBound = if (state.pageIndex == state.pageCount - 1) 0f else constraints.maxWidth.toFloat() + ) + + layout(width = constraints.maxWidth, height = constraints.maxHeight) { + previousPlaceable?.let { + previousPlaceable.place(x = -state.offset.toInt() - constraints.maxWidth, y = 0) + placeable.place(x = -state.offset.toInt(), y = 0) + } ?: nextPlaceable?.let { + placeable.place(x = -state.offset.toInt(), y = 0) + nextPlaceable.place(x = -state.offset.toInt() + constraints.maxWidth, y = 0) + } ?: placeable.place(x = -state.offset.toInt(), y = 0) + } + } +} \ No newline at end of file