Skip to content

Instantly share code, notes, and snippets.

@KlassenKonstantin
Last active October 27, 2023 19:22
Show Gist options
  • Save KlassenKonstantin/81ce135392c8764ad4f64891c743253a to your computer and use it in GitHub Desktop.
Save KlassenKonstantin/81ce135392c8764ad4f64891c743253a to your computer and use it in GitHub Desktop.
@file:OptIn(ExperimentalFoundationApi::class)
package de.apuri.springytabs
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import de.apuri.springytabs.ui.theme.SpringyTabsTheme
import kotlinx.coroutines.launch
import java.lang.Float.max
import kotlin.math.absoluteValue
private val color = Color(0xFF8B5CF6)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SpringyTabsTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val pagerState = rememberPagerState()
val tabs = listOf(
"One",
"Two",
"Three",
)
Column {
Box(
Modifier
.height(250.dp)
.fillMaxWidth()
.background(color)
) {
SpringyTabs(
modifier = Modifier.align(Alignment.BottomCenter),
items = tabs,
pagerState = pagerState,
)
}
HorizontalPager(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
state = pagerState,
pageCount = tabs.size,
) {
Box(
Modifier
.fillMaxSize()
) {
Column {
ListItem(headlineContent = { Text(text = "Lorem Ipsum") }, supportingContent = { Text(text = "Dolor") })
}
}
}
}
}
}
}
}
}
@Composable
fun SpringyTabs(
modifier: Modifier = Modifier,
items: List<String>,
pagerState: PagerState,
) {
val scope = rememberCoroutineScope()
Row(
modifier = modifier
) {
items.forEachIndexed { index, label ->
var progress by remember { mutableStateOf(0f) }
Tab(
modifier = Modifier.snapper(
index = index,
pagerState = pagerState,
onProgressChanged = {
progress = it
}
),
text = label,
provideProgress = { progress }
) {
scope.launch {
pagerState.animateScrollToPage(index)
}
}
}
}
}
private fun Modifier.snapper(index: Int, pagerState: PagerState, onProgressChanged: (Float) -> Unit) = composed {
val progress by remember {
derivedStateOf {
val currentPage = pagerState.currentPage
val currentPageOffset = pagerState.currentPageOffsetFraction
// The neighbor the user swipes to
val isApproachedNeighbor = currentPage - index == 1 && currentPageOffset < 0 || currentPage - index == -1 && currentPageOffset > 0
when {
currentPage == index -> 1f
isApproachedNeighbor -> currentPageOffset.absoluteValue * 0.7f
else -> 0f
}
}
}
val animSpec = when (progress) {
1f -> spring<Float>(stiffness = 6_000f, dampingRatio = 0.3f)
else -> spring<Float>(stiffness = Spring.StiffnessHigh)
}
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = animSpec,
label = "progress"
)
onProgressChanged(animatedProgress)
this
}
@Composable
fun RowScope.Tab(
modifier: Modifier = Modifier,
text: String,
provideProgress: () -> Float,
onClick: () -> Unit
) {
Box(
modifier.weight(1f),
contentAlignment = Alignment.Center
) {
// Background
Box(
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
.clickable { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier
.graphicsLayer {
val progress = provideProgress()
translationY = -size.height / 3f * progress
alpha = 0.5f + 0.5f * (1 - progress)
}
.padding(vertical = 12.dp),
text = text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.surface
)
}
// Foreground
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
val progress = provideProgress()
translationY = size.height - size.height * progress
val overshoot = max(0f, progress - 1f)
scaleY = max(1f, 1f + overshoot * 2f)
}
.background(MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier.padding(vertical = 12.dp),
text = text,
style = MaterialTheme.typography.titleMedium,
color = color
)
}
}
}
@mxalbert1996
Copy link

snapper makes no sense as a modifier and you don't need to sync progress with the animation state.

Something like this is much better:

@Composable
fun SpringyTabs(
    modifier: Modifier = Modifier,
    items: List<String>,
    pagerState: PagerState,
) {
    val scope = rememberCoroutineScope()
    Row(
        modifier = modifier
    ) {
        items.forEachIndexed { index, label ->
            val progress = snapProgress(index, pagerState)
            Tab(
                text = label,
                provideProgress = progress::value
            ) {
                scope.launch {
                    pagerState.animateScrollToPage(index)
                }
            }
        }
    }
}

@Composable
private fun snapProgress(index: Int, pagerState: PagerState): State<Float> {
    val progress by remember {
        derivedStateOf {
            val currentPage = pagerState.currentPage
            val currentPageOffset = pagerState.currentPageOffsetFraction

            // The neighbor the user swipes to
            val isApproachedNeighbor = currentPage - index == 1 && currentPageOffset < 0 || currentPage - index == -1 && currentPageOffset > 0

            when {
                currentPage == index -> 1f
                isApproachedNeighbor -> currentPageOffset.absoluteValue * 0.7f
                else -> 0f
            }
        }
    }

    val animSpec = when (progress) {
        1f -> spring<Float>(stiffness = 6_000f, dampingRatio = 0.3f)
        else -> spring<Float>(stiffness = Spring.StiffnessHigh)
    }

    return animateFloatAsState(
        targetValue = progress,
        animationSpec = animSpec,
        label = "progress"
    )
}

@KlassenKonstantin
Copy link
Author

@mxalbert1996 I tend to forget about Composable functions with return value when they're not prefixed with remember... 😀. Thanks for your suggestion, looks good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment