Created
December 15, 2025 20:31
-
-
Save sdetilly/cbe122248bcc3226eac845a3d842b501 to your computer and use it in GitHub Desktop.
Animated Content with Crossfade and expand/collapse
This file contains hidden or 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
| 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