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

Material Motion for Jetpack Compose

Material Motion for Jetpack Compose

Avatar for Sungyong An

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이 필요할 것 같습니다. - 🤔🤔🤔