Skip to content

Instantly share code, notes, and snippets.

@sdetilly
Created December 15, 2025 20:31
Show Gist options
  • Select an option

  • Save sdetilly/cbe122248bcc3226eac845a3d842b501 to your computer and use it in GitHub Desktop.

Select an option

Save sdetilly/cbe122248bcc3226eac845a3d842b501 to your computer and use it in GitHub Desktop.
Animated Content with Crossfade and expand/collapse
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.tillylabs.androiddemo.ui.theme.AndroidDemoTheme
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
interface MyViewModel {
val title: String
val selectionType: StateFlow<Selection?>
val sections: StateFlow<List<MyContent>>
}
data class ActiveSelection(
val currentSelection: Selection?,
val breakfastChoices: SelectionChoices<BreakfastChoices>,
val lunchChoices: SelectionChoices<LunchChoices>
)
data class SelectionChoices<T>(
val isExpanded: Boolean = false,
val list: List<T> = emptyList()
)
enum class BreakfastChoices(val displayName: String) {
PANCAKES("Pancakes"),
WAFFLES("Waffles"),
EGGS_BACON("Eggs & Bacon"),
FRENCH_TOAST("French Toast"),
OATMEAL("Oatmeal"),
YOGURT("Yogurt Parfait"),
BURRITO("Breakfast Burrito"),
AVOCADO_TOAST("Avocado Toast"),
}
enum class LunchChoices(val displayName: String) {
CAESAR_SALAD("Caesar Salad"),
CLUB_SANDWICH("Club Sandwich"),
BURGER_FRIES("Burger & Fries"),
PASTA("Pasta Primavera"),
CHICKEN_WRAP("Chicken Wrap"),
FISH_TACOS("Fish Tacos"),
SOUP_SALAD("Soup & Salad"),
GRILLED_CHICKEN("Grilled Chicken"),
VEGGIE_BOWL("Veggie Bowl"),
STEAK_SANDWICH("Steak Sandwich"),
}
class MyViewModelImpl() : MyViewModel, ViewModel() {
override val title = "Order Food"
override val selectionType: MutableStateFlow<Selection?> = MutableStateFlow(null)
private val activeSelectionFlow = MutableStateFlow(
ActiveSelection(
currentSelection = null,
breakfastChoices = SelectionChoices(),
lunchChoices = SelectionChoices()
)
)
override val sections: StateFlow<List<MyContent>> = activeSelectionFlow.map { activeSelection ->
buildList {
add(
MyContent.SelectionSection(
title = "Choose Meal",
selectionTypes = listOf(
VMButton(
content = MutableStateFlow(
SelectableTextImageContent(
text = "Breakfast",
image = null,
isSelected = activeSelection.currentSelection == Selection.ONE
)
),
action = {
val selection =
Selection.ONE.takeIf { activeSelection.currentSelection != it }
selectionType.value = selection
activeSelectionFlow.update {
it.copy(
currentSelection = selection,
breakfastChoices = SelectionChoices(),
lunchChoices = SelectionChoices(),
)
}
}
),
VMButton(
content = MutableStateFlow(
SelectableTextImageContent(
text = "Lunch",
image = null,
isSelected = activeSelection.currentSelection == Selection.TWO
)
),
action = {
val selection =
Selection.TWO.takeIf { activeSelection.currentSelection != it }
selectionType.value = selection
activeSelectionFlow.update {
it.copy(
currentSelection = selection,
breakfastChoices = SelectionChoices(),
lunchChoices = SelectionChoices(),
)
}
}
),
)
)
)
when (activeSelection.currentSelection) {
Selection.ONE -> buildOneChoices()
Selection.TWO -> buildTwoChoices()
else -> Unit
}
}
}.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private fun MutableList<MyContent>.buildOneChoices() {
add(MyContent.SectionDivider)
add(MyContent.SectionTitle("Breakfast Items"))
add(
MyContent.ExpandableCheckboxListSection(
isExpanded = activeSelectionFlow.value.breakfastChoices.isExpanded,
firstFiveButtons = buildChoiceOneButtons(BreakfastChoices.entries.take(5)),
otherButtons = buildChoiceOneButtons(BreakfastChoices.entries.drop(5))
)
)
add(
MyContent.ExpandButtonSection(
VMButton(
content = activeSelectionFlow.map { activeSelection ->
val isExpanded = activeSelection.breakfastChoices.isExpanded
SelectableTextImageContent(
text = if (isExpanded) "See less" else "See more",
image = Icons.Filled.ArrowDropDown,
isSelected = isExpanded
)
}.stateIn(viewModelScope, SharingStarted.Lazily, SelectableTextImageContent("See more", Icons.Filled.ArrowDropDown, false))
) {
activeSelectionFlow.update { it.copy(breakfastChoices = it.breakfastChoices.copy(isExpanded = !it.breakfastChoices.isExpanded)) }
}
)
)
}
private fun MutableList<MyContent>.buildTwoChoices() {
add(MyContent.SectionDivider)
add(MyContent.SectionTitle("Lunch Items"))
add(
MyContent.ExpandableCheckboxListSection(
isExpanded = activeSelectionFlow.value.lunchChoices.isExpanded,
firstFiveButtons = buildChoiceTwoButtons(LunchChoices.entries.take(5)),
otherButtons = buildChoiceTwoButtons(LunchChoices.entries.drop(5))
)
)
add(
MyContent.ExpandButtonSection(
VMButton(
content = activeSelectionFlow.map { activeSelection ->
val isExpanded = activeSelection.lunchChoices.isExpanded
SelectableTextImageContent(
text = if (isExpanded) "See less" else "See more",
image = Icons.Filled.ArrowDropDown,
isSelected = isExpanded
)
}.stateIn(viewModelScope, SharingStarted.Lazily, SelectableTextImageContent("See more", Icons.Filled.ArrowDropDown, false))
) {
activeSelectionFlow.update { it.copy(lunchChoices = it.lunchChoices.copy(isExpanded = !it.lunchChoices.isExpanded)) }
}
)
)
}
fun buildChoiceOneButtons(choices: List<BreakfastChoices>) = choices.map { choice ->
VMButton(
content = activeSelectionFlow.map { activeSelection ->
SelectableTextImageContent(text = choice.displayName, isSelected = activeSelection.breakfastChoices.list.contains(choice), image = Icons.Filled.Done.takeIf { activeSelection.breakfastChoices.list.contains(choice) })
}.stateIn(viewModelScope, SharingStarted.Lazily, SelectableTextImageContent(text = choice.displayName, isSelected = false))
) {
activeSelectionFlow.update { it.copy(breakfastChoices = it.breakfastChoices.copy(list = it.breakfastChoices.list.addOrRemove(choice))) }
}
}
fun buildChoiceTwoButtons(choices: List<LunchChoices>) = choices.map { choice ->
VMButton(
content = activeSelectionFlow.map { activeSelection ->
SelectableTextImageContent(text = choice.displayName, isSelected = activeSelection.lunchChoices.list.contains(choice), image = Icons.Filled.Done.takeIf { activeSelection.lunchChoices.list.contains(choice) })
}.stateIn(viewModelScope, SharingStarted.Lazily, SelectableTextImageContent(text = choice.displayName, isSelected = false))
) {
activeSelectionFlow.update { it.copy(lunchChoices = it.lunchChoices.copy(list = it.lunchChoices.list.addOrRemove(choice))) }
}
}
}
private fun <E> List<E>.addOrRemove(item: E) =
if (contains(item)) {
this - item
} else {
this + item
}
enum class Selection {
ONE,
TWO
}
data class VMButton<T: Any>(
val content: StateFlow<T>,
val isEnabled: StateFlow<Boolean> = MutableStateFlow(true),
val action: () -> Unit,
)
data class SelectableTextImageContent(
val text: String,
val image: ImageVector? = null,
val isSelected: Boolean
)
sealed interface MyContent {
data object SectionDivider : MyContent
data class SectionTitle(
val title: String
) : MyContent
data class SelectionSection(
val title: String,
val selectionTypes: List<VMButton<SelectableTextImageContent>>
) : MyContent
data class ExpandableCheckboxListSection(
val isExpanded: Boolean,
val firstFiveButtons: List<VMButton<SelectableTextImageContent>>,
val otherButtons: List<VMButton<SelectableTextImageContent>>,
) : MyContent
data class ExpandButtonSection(
val button: VMButton<SelectableTextImageContent>
) : MyContent
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(
scrim = Color(0xFF1C1C1E).toArgb(),
darkScrim = Color(0xFF1C1C1E).toArgb()
),
navigationBarStyle = SystemBarStyle.dark(Color(0xFF1C1C1E).toArgb())
)
val viewModel: MyViewModelImpl by viewModels()
setContent {
AndroidDemoTheme {
DemoApp(viewModel)
}
}
}
}
@Composable
private fun DemoApp(viewModel: MyViewModel) {
val selectionType by viewModel.selectionType.collectAsStateWithLifecycle()
val sections by viewModel.sections.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding(),
) {
Text(
viewModel.title,
modifier = Modifier
.padding(start = 16.dp, bottom = 8.dp),
style = MaterialTheme.typography.titleLarge
)
HorizontalDivider()
val scrollState = rememberScrollState()
val sectionAndFacility = Pair(sections, selectionType)
AnimatedContent(
targetState = sectionAndFacility,
transitionSpec = {
fadeIn(animationSpec = tween(500)) togetherWith
fadeOut(animationSpec = tween(500))
},
contentKey = {
it.second
}
) { (currentSections, _) ->
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(16.dp)
.padding(bottom = 120.dp)
) {
currentSections.forEach { content ->
when (content) {
MyContent.SectionDivider -> HorizontalDivider(Modifier.padding(vertical = 16.dp))
is MyContent.SectionTitle -> SectionTitleView(content)
is MyContent.SelectionSection -> SelectionSectionView(content)
is MyContent.ExpandButtonSection -> ExpandButtonSectionView(content)
is MyContent.ExpandableCheckboxListSection -> ExpandableCheckboxListSectionView(content)
}
}
}
}
}
}
@Composable
private fun SectionTitleView(content: MyContent.SectionTitle) {
Text(
modifier = Modifier.padding(bottom = 16.dp),
text = content.title,
)
}
@Composable
private fun SelectionSectionView(content: MyContent.SelectionSection) {
Text(
text = content.title,
)
Spacer(Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
content.selectionTypes.forEach { button ->
SelectableButton(button)
}
}
}
@Composable
private fun SelectableButton(button: VMButton<SelectableTextImageContent>) {
CustomButton(
button = button,
modifier = Modifier
.border(
width = 1.dp,
color = Color.DarkGray,
shape = RoundedCornerShape(15.dp)
)
.clip(RoundedCornerShape(15.dp))
) { buttonContent ->
val boxColor by animateColorAsState(
targetValue = if (buttonContent.isSelected) Color.Blue else Color.White
)
val contentColor by animateColorAsState(
targetValue = if (buttonContent.isSelected) Color.White else Color.Black
)
Row(
modifier = Modifier
.background(boxColor)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
buttonContent.image?.let {
Image(
imageVector = it,
contentDescription = null,
colorFilter = ColorFilter.tint(contentColor)
)
Spacer(Modifier.width(8.dp))
}
Text(
text = buttonContent.text,
color = contentColor
)
}
}
}
@Composable
private fun ExpandButtonSectionView(content: MyContent.ExpandButtonSection) {
CustomButton(
button = content.button,
modifier = Modifier
.padding(top = 8.dp),
) { buttonContent ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val rotate by animateFloatAsState(
targetValue = if (buttonContent.isSelected) 180f else 0f
)
Text(
text = buttonContent.text,
color = Color.Blue
)
buttonContent.image?.let { image ->
Image(
imageVector = image,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
rotationZ = rotate
},
colorFilter = ColorFilter.tint(Color.Blue)
)
}
}
}
}
@Composable
private fun ExpandableCheckboxListSectionView(content: MyContent.ExpandableCheckboxListSection) {
Column {
content.firstFiveButtons.forEach { button ->
CheckboxButton(button)
}
AnimatedVisibility(
visible = content.isExpanded
) {
Column {
content.otherButtons.forEach { button ->
CheckboxButton(button)
}
}
}
}
}
@Composable
private fun CheckboxButton(button: VMButton<SelectableTextImageContent>) {
CustomButton(
button = button,
modifier = Modifier
.padding(vertical = 12.dp),
) { buttonContent ->
val checkboxColor by animateColorAsState(
targetValue = if (buttonContent.isSelected) Color.Blue else Color.White
)
val borderColor by animateColorAsState(
targetValue = if (buttonContent.isSelected) Color.Blue else Color.DarkGray
)
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.weight(1f),
text = buttonContent.text,
color = Color.Black,
)
Box(
modifier = Modifier
.size(30.dp)
.border(
width = 1.dp,
color = borderColor,
shape = RoundedCornerShape(4.dp)
)
.background(checkboxColor, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
) {
buttonContent.image?.let { check ->
Image(
imageVector = check,
contentDescription = null,
modifier = Modifier
.align(Alignment.Center),
colorFilter = ColorFilter.tint(Color.White)
)
}
}
}
}
}
@Composable
private fun <C : Any> CustomButton(
button: VMButton<C>,
modifier: Modifier = Modifier,
content: @Composable BoxScope.(field: C) -> Unit
) {
val isEnabled by button.isEnabled.collectAsStateWithLifecycle()
Box(
modifier = modifier
.clickable(
enabled = isEnabled,
indication = null,
interactionSource = null,
role = Role.Button
) { button.action() }
) {
val buttonContent by button.content.collectAsStateWithLifecycle()
content(buttonContent)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment