Last active
July 24, 2024 06:21
-
-
Save Sal7one/d3432f95c2667e3ffb34ed385b751b83 to your computer and use it in GitHub Desktop.
TabbedRow
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
}) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Improvement - Disable text color animation if sliding on click is disabled