Skip to content

Instantly share code, notes, and snippets.

@Sal7one
Last active July 24, 2024 06:21
Show Gist options
  • Save Sal7one/d3432f95c2667e3ffb34ed385b751b83 to your computer and use it in GitHub Desktop.
Save Sal7one/d3432f95c2667e3ffb34ed385b751b83 to your computer and use it in GitHub Desktop.
TabbedRow
package com.example.salscomposecomponents
inline fun <R : Any> R.applyWhen(
condition: Boolean,
block: R.() -> R,
): R = applyChoice(condition = condition, trueBlock = block, falseBlock = { this })
inline fun <R : Any> R.applyChoice(
condition: Boolean,
trueBlock: R.() -> R,
falseBlock: R.() -> R,
): R {
return if (condition) {
trueBlock()
} else {
falseBlock()
}
}
package com.example.salscomposecomponents.composable
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.salscomposecomponents.applyWhen
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun TabbedRow(
modifier: Modifier = Modifier,
selectedIndex: Int,
sensitivity: Float = 0.0005f,
items: List<String>,
isDraggable: Boolean = true,
slideOnClick: Boolean = true,
onSelectionChange: (Int) -> Unit
) {
var isDragging by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
BoxWithConstraints(
modifier
.padding(8.dp)
.height(56.dp)
.clip(RoundedCornerShape(8.dp))
.padding(8.dp)
) {
if (items.isNotEmpty()) {
val tabWidth = remember {
maxWidth / items.size
}
val maxIndicatorOffset = remember { (items.size - 1) * tabWidth.value }
val animatedIndicatorOffset = remember {
val offset = calculateOffset(
selectedIndex = selectedIndex,
tabWidth = tabWidth.value,
maxIndicatorOffset = maxIndicatorOffset
)
Animatable(initialValue = offset)
}
LaunchedEffect(selectedIndex) {
val offset = calculateOffset(
selectedIndex = selectedIndex,
tabWidth = tabWidth.value,
maxIndicatorOffset = maxIndicatorOffset
)
animatedIndicatorOffset.animateTo(
targetValue = offset,
animationSpec = tween(durationMillis = 300)
)
}
Box(
modifier = Modifier
.graphicsLayer {
translationX = animatedIndicatorOffset.value.dp.toPx()
}
.background(MaterialTheme.colorScheme.onBackground, RoundedCornerShape(8.dp))
.width(tabWidth)
.fillMaxHeight()
)
Row(modifier = Modifier
.fillMaxWidth()
.applyWhen(isDraggable)
{
pointerInput(sensitivity, selectedIndex) {
detectDragGestures(onDragStart = {
isDragging = true
}, onDrag = { change, dragAmount ->
val newOffset =
animatedIndicatorOffset.value + (dragAmount.x * sensitivity * maxWidth.toPx())
scope.launch {
animatedIndicatorOffset.animateTo(
targetValue = newOffset.coerceIn(0f, maxIndicatorOffset),
animationSpec = tween(durationMillis = 20)
)
}
}, onDragEnd = {
isDragging = false
val targetIndex =
(animatedIndicatorOffset.value / tabWidth.value).roundToInt()
// Compose doesn't trigger for same values in onSelection change :)
// We don't want to introduce other values like id or timestamp for something simple!
if (targetIndex == selectedIndex) {
val indicatorOffset = calculateOffset(
selectedIndex = selectedIndex,
tabWidth = tabWidth.value,
maxIndicatorOffset = maxIndicatorOffset
)
scope.launch {
animatedIndicatorOffset.animateTo(
targetValue = indicatorOffset,
animationSpec = tween(durationMillis = 20)
)
}
} else {
val coercedTargetIndex = targetIndex.coerceIn(0, items.size - 1)
onSelectionChange(coercedTargetIndex)
}
})
}
}
) {
items.forEachIndexed { index, text ->
val selectedColor by remember(selectedIndex) {
derivedStateOf {
index == selectedIndex && !isDragging
}
}
val animatedColor by animateColorAsState(
if (selectedColor) MaterialTheme.colorScheme.background else Color.Gray,
animationSpec = tween(
durationMillis = 350
),
label = "color"
)
Box(
modifier = Modifier
.width(tabWidth)
.fillMaxHeight()
.clickable(interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {
isDragging = false
val animationDuration = if (slideOnClick) 300 else 0
val targetOffset = index * tabWidth.value
scope.launch {
animatedIndicatorOffset.animateTo(
targetValue = targetOffset,
animationSpec = tween(durationMillis = animationDuration)
)
}
onSelectionChange(index)
}), contentAlignment = Alignment.Center
) {
// animated color sucks sometimes... https://stackoverflow.com/a/77297119/6151564
Text(
text = text,
fontSize = 20.sp,
modifier = Modifier
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawRect(
color = animatedColor,
blendMode = BlendMode.SrcIn
)
}
)
}
}
}
}
}
}
private fun calculateOffset(selectedIndex: Int, tabWidth: Float, maxIndicatorOffset: Float): Float {
val offset = selectedIndex * tabWidth
return offset.coerceIn(
0f, maxIndicatorOffset
)
}
@Preview(showBackground = true)
@Composable
fun TextSwitchPreview() {
MaterialTheme {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
var selectedIndex by remember { mutableIntStateOf(0) }
val items = listOf(
"Tab 1", "Tab 2", "Tab 3", "Tab 4", "Tab 5", "Tab 6"
)
TabbedRow(selectedIndex = selectedIndex, items = items, onSelectionChange = { index ->
selectedIndex = index
})
}
}
}
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
var selectedIndex by remember { mutableIntStateOf(0) }
var draggableOnClick by remember { mutableStateOf(false) }
TextButton(onClick = {
draggableOnClick = !draggableOnClick
}) {
Text(text = "Is DraggbleOnclick ${draggableOnClick}")
}
Text(text = "Tabbed row", style = MaterialTheme.typography.titleMedium)
val items = listOf(
"Tab 1", "Tab 2", "Tab 3", "Tab 4", "Tab 5", "Tab 6"
)
TabbedRow(selectedIndex = selectedIndex, items = items, slideOnClick = draggableOnClick, onSelectionChange = { index ->
selectedIndex = index
})
}
@sal7two
Copy link

sal7two commented Jul 24, 2024

Improvement - Disable text color animation if sliding on click is disabled

  val animatedColor by animateColorAsState(
                        if (selectedColor) MaterialTheme.colorScheme.background else Color.Gray,
                        animationSpec = tween(
                            durationMillis = if(slideOnClick) 350 else 0
                        ),
                        label = "color"
                    )

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