Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Material Motion for Jetpack Compose

Material Motion for Jetpack Compose

Sungyong An

April 21, 2021
Tweet

More Decks by Sungyong An

Other Decks in Programming

Transcript

  1. enum class BottomTabs { Albums, Photos, Search } val (selectedTab,

    setSelectedTab) = remember { mutableStateOf(BottomTabs.Albums) } Scaffold(bottomBar = { BottomNavigation { BottomTabs.values().forEach { tab -> BottomNavigationItem( ... selected = tab == selectedTab, onClick = { setSelectedTab(tab) } ) } } }) { innerPadding -> BottomTabsContents( selectedTab, modifier = Modifier.padding(innerPadding) ) }
  2. enum class BottomTabs { Albums, Photos, Search } val (selectedTab,

    setSelectedTab) = remember { mutableStateOf(BottomTabs.Albums) } Scaffold(bottomBar = { BottomNavigation { BottomTabs.values().forEach { tab -> BottomNavigationItem( ... selected = tab == selectedTab, onClick = { setSelectedTab(tab) } ) } } }) { innerPadding -> BottomTabsContents( selectedTab, modifier = Modifier.padding(innerPadding) ) }
  3. enum class BottomTabs { Albums, Photos, Search } val (selectedTab,

    setSelectedTab) = remember { mutableStateOf(BottomTabs.Albums) } Scaffold(bottomBar = { BottomNavigation { BottomTabs.values().forEach { tab -> BottomNavigationItem( ... selected = tab == selectedTab, onClick = { setSelectedTab(tab) } ) } } }) { innerPadding -> BottomTabsContents( selectedTab, modifier = Modifier.padding(innerPadding) ) }
  4. Scaffold(bottomBar = { ... }) { innerPadding -> Crossfade( targetState

    = selectedTab, modifier = Modifier.padding(innerPadding) ) { currentTab -> BottomTabsContents(currentTab) } }
  5. Scaffold(bottomBar = { ... }) { innerPadding -> Crossfade( targetState

    = selectedTab, modifier = Modifier.padding(innerPadding) ) { currentTab -> BottomTabsContents(currentTab) } }
  6. private data class CrossfadeAnimationItem<T>( val key: T, val content: @Composable

    () -> Unit ) @Composable fun <T> Crossfade( targetState: T, modifier: Modifier = Modifier, animationSpec: FiniteAnimationSpec<Float> = tween(), content: @Composable (T) -> Unit ) { val items = remember { mutableStateListOf<CrossfadeAnimationItem<T>>() } val transitionState = remember { MutableTransitionState(targetState) } val targetChanged = (targetState != transitionState.targetState) transitionState.targetState = targetState val transition = updateTransition(transitionState) if (targetChanged || items.isEmpty()) { // Only manipulate the list when the state is changed, or in the first run. val keys = items.map { it.key }.run { if (!contains(targetState)) { toMutableList().also { it.add(targetState) }
  7. private data class CrossfadeAnimationItem<T>( val key: T, val content: @Composable

    () -> Unit ) @Composable fun <T> Crossfade( targetState: T, modifier: Modifier = Modifier, animationSpec: FiniteAnimationSpec<Float> = tween(), content: @Composable (T) -> Unit ) { val items = remember { mutableStateListOf<CrossfadeAnimationItem<T>>() } val transitionState = remember { MutableTransitionState(targetState) } val targetChanged = (targetState != transitionState.targetState) transitionState.targetState = targetState val transition = updateTransition(transitionState) if (targetChanged || items.isEmpty()) { // Only manipulate the list when the state is changed, or in the first run. val keys = items.map { it.key }.run { if (!contains(targetState)) { toMutableList().also { it.add(targetState) }
  8. private data class CrossfadeAnimationItem<T>( val key: T, val content: @Composable

    () -> Unit ) @Composable fun <T> Crossfade( targetState: T, modifier: Modifier = Modifier, animationSpec: FiniteAnimationSpec<Float> = tween(), content: @Composable (T) -> Unit ) { val items = remember { mutableStateListOf<CrossfadeAnimationItem<T>>() } val transitionState = remember { MutableTransitionState(targetState) } val targetChanged = (targetState != transitionState.targetState) transitionState.targetState = targetState val transition = updateTransition(transitionState) if (targetChanged || items.isEmpty()) { // Only manipulate the list when the state is changed, or in the first run. val keys = items.map { it.key }.run { if (!contains(targetState)) { toMutableList().also { it.add(targetState) }
  9. private data class CrossfadeAnimationItem<T>( val key: T, val content: @Composable

    () -> Unit ) @Composable fun <T> Crossfade( targetState: T, modifier: Modifier = Modifier, animationSpec: FiniteAnimationSpec<Float> = tween(), content: @Composable (T) -> Unit ) { val items = remember { mutableStateListOf<CrossfadeAnimationItem<T>>() } val transitionState = remember { MutableTransitionState(targetState) } val targetChanged = (targetState != transitionState.targetState) transitionState.targetState = targetState val transition = updateTransition(transitionState) if (targetChanged || items.isEmpty()) { // Only manipulate the list when the state is changed, or in the first run. val keys = items.map { it.key }.run { if (!contains(targetState)) { toMutableList().also { it.add(targetState) }
  10. ) { val items = remember { mutableStateListOf<CrossfadeAnimationItem<T>>() } val

    transitionState = remember { MutableTransitionState(targetState) } val targetChanged = (targetState != transitionState.targetState) transitionState.targetState = targetState val transition = updateTransition(transitionState) if (targetChanged || items.isEmpty()) { // Only manipulate the list when the state is changed, or in the first run. val keys = items.map { it.key }.run { if (!contains(targetState)) { toMutableList().also { it.add(targetState) } } else { this } } items.clear() keys.mapTo(items) { key -> CrossfadeAnimationItem(key) { val alpha by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) }
  11. } items.clear() keys.mapTo(items) { key -> CrossfadeAnimationItem(key) { val alpha

    by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) } } } } else if (transitionState.currentState == transitionState.targetState) { // Remove all the intermediate items from the list once the animation is finished. items.removeAll { it.key != transitionState.targetState } } Box(modifier) { items.fastForEach { key(it.key) { it.content() } } }
  12. } items.clear() keys.mapTo(items) { key -> CrossfadeAnimationItem(key) { val alpha

    by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) } } } } else if (transitionState.currentState == transitionState.targetState) { // Remove all the intermediate items from the list once the animation is finished. items.removeAll { it.key != transitionState.targetState } } Box(modifier) { items.fastForEach { key(it.key) { it.content() } } }
  13. } } } } else if (transitionState.currentState == transitionState.targetState) {

    // Remove all the intermediate items from the list once the animation is finished. items.removeAll { it.key != transitionState.targetState } } Box(modifier) { items.fastForEach { key(it.key) { it.content() } } } } Box { Box(Modifier.alpha(1f -> 0f)) { BottomTabsContents(previousTab) } Box(Modifier.alpha(0f -> 1f)) { BottomTabsContents(currentTab) } } 복잡해보이지만,간단합니다.
  14. // Crossfade CrossfadeAnimationItem(key) { val alpha by transition.animateFloat( transitionSpec =

    { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) } }
  15. // Crossfade CrossfadeAnimationItem(key) { val alpha by transition.animateFloat( transitionSpec =

    { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) } }
  16. // Crossfade CrossfadeAnimationItem(key) { val alpha by transition.animateFloat( transitionSpec =

    { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) } }
  17. // MaterialFadeThrough MaterialAnimationItem(key) { val animationSpec = ... val alpha

    by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0f } val scale by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0.92f } Box(Modifier.alpha(alpha = alpha) .scale(scale = scale) ) { content(key) } }
  18. // MaterialFadeThrough MaterialAnimationItem(key) { val animationSpec = ... val alpha

    by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0f } val scale by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0.92f } Box(Modifier.alpha(alpha = alpha) .scale(scale = scale) ) { content(key) } }
  19. // MaterialFadeThrough MaterialAnimationItem(key) { val animationSpec = ... val alpha

    by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0f } val scale by transition.animateFloat( transitionSpec = { animationSpec } ) { if (it == key) 1f else 0.92f } Box(Modifier.alpha(alpha = alpha) .scale(scale = scale) ) { content(key) } }
  20. Scaffold(bottomBar = { ... }) { innerPadding -> MaterialFadeThrough( targetState

    = selectedTab, modifier = Modifier.padding(innerPadding) ) { currentTab -> BottomTabsContents(currentTab) } } Box { Box(Modifier.alpha(1f -> 0f).scale(1f)) { BottomTabsContents(previousTab) } Box(Modifier.alpha(0f -> 1f).scale(0.92f -> 1f)) { BottomTabsContents(currentTab) } }
  21. val enterMotionSpec = ... val exitMotionSpec = ... MaterialMotion( targetState

    = state, enterMotionSpec = enterMotionSpec, exitMotionSpec = exitMotionSpec, pop = false ) { newState -> // composable according to screen } material-motion-compose 🎉
  22. val enterMotionSpec = ... val exitMotionSpec = ... MaterialMotion( targetState

    = state, enterMotionSpec = enterMotionSpec, exitMotionSpec = exitMotionSpec, pop = false ) { newState -> // composable according to screen } materialSharedAxis(Axis.X, forward = true) materialFadeThrough() materialFade() materialElevationScale(growing = false) hold() ... material-motion-compose 🎉 Axis.Y Axis.Z
  23. DemoScreen (w/o transition) @Composable fun DemoScreen() { val (state, onStateChanged)

    = remember { … } if (state != null) { AlbumScreen(state) } else { LibraryScreen(onItemClick = { onStateChanged(it.id) }) } }
  24. DemoScreen (with transition) @Composable fun DemoScreen() { val (state, onStateChanged)

    = remember { … } MaterialMotion( targetState = state, enterMotionSpec = ..., exitMotionSpec = ..., pop = state == null ) { currentId -> if (currentId != null) { AlbumScreen(currentId) } else { LibraryScreen(onItemClick = { onStateChanged(it.id) }) } } }
  25. LibraryScreen (w/o transition) @Composable fun LibraryScreen(...) { val (state, onStateChanged)

    = remember { mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid)) } Scaffold(...) { LibraryContents(state, ...) } }
  26. LibraryScreen (with transition) @Composable fun LibraryScreen(...) { val (state, onStateChanged)

    = remember { mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid)) } Scaffold(...) { MaterialMotion( targetState = state, motionSpec = ..., modifier = Modifier.padding(innerPadding) ) { currentDestination -> LibraryContents(currentDestination, ...) } } }
  27. @Composable fun LibraryScreen(...) { val (state, onStateChanged) = remember {

    mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid)) } Scaffold(...) { MaterialMotion(...) { LibraryContents(...) } } } LibraryScreen (with transition)
  28. @Composable fun LibraryScreen(...) { val (state, onStateChanged) = rememberSaveable(stateSaver =

    Saver) { mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid)) } Scaffold(...) { MaterialMotion(...) { LibraryContents(...) } } } LibraryScreen (with transition + saveable) val Saver = run { val sortTypeKey = "SortType" val listTypeKey = "ListType" mapSaver( save = { mapOf( sortTypeKey to it.sortType, listTypeKey to it.listType, )}, restore = { LibraryState( it[sortTypeKey] as SortType, it[listTypeKey] as ListType, )} ) }
  29. DemoScreen (with transition) @Composable fun DemoScreen() { val (state, onStateChanged)

    = remember { ... } MaterialMotion(...) { currentId -> if (currentId != null) { AlbumScreen(currentId) } else { LibraryScreen(onItemClick = { onStateChanged(it.id) }) } } }
  30. DemoScreen (with transition + SaveableStateHolder) @Composable fun DemoScreen() { val

    saveableStateHolder = rememberSaveableStateHolder() val (state, onStateChanged) = remember { ... } MaterialMotion(...) { currentId -> saveableStateHolder.SaveableStateProvider(currentId.toString()) { if (currentId != null) { AlbumScreen(currentId) } else { LibraryScreen(onItemClick = { onStateChanged(it.id) }) } } } }
  31. Summary - androidx.compose.animation.core.Transition - Transition을 이용하면 상태 변경에 따른 Animation

    효과를 구현할 수 있습니다. - 화면 전환 효과를 구현할 때는 각각의 화면을 Box로 한번 감싼 후, 
 Box의 modifier 속성에 변화를 주면 됩니다. 
 - Saveable, SaveableStateHolder - 화면을 전환할 때는 마지막 상태를 저장해두고, 다시 되돌아왔을 때 상태를 복구해줘야 합니다. - ViewModel에 상태를 저장해두는 방법도 가능합니다. - 다만 스크롤 같은 UI 상태를 저장/복구하려면, SaveableStateHolder를 이용하는 것이 간단합니다. - 참고로 navigation-compose 라이브러리가 공식적으로 제공되고 있는데요. 
 내부적으로 SaveableStateHolder를 사용하고 있습니다.
  32. Container Transform 어떻게 구현할 수 있을까? - 기존의 Activity, Fragment,

    View 기반의 Material Motion에서는 
 Container Transform가 Shared Elements를 기반으로 구현되어 있습니다. - Compose는 Shared Elements가 없기 때문에 Container 간의 Transition을 구현하려면, 
 (@Composable을 따로 받는다던지) 약간의 trick이 필요할 것 같습니다. - 🤔🤔🤔