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. Material Motion for Jetpack Compose

  2. 안성용 SOUP [email protected]

  3. ? 👀

  4. Jetpack Compose?

  5. None
  6. 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) ) }
  7. 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) ) }
  8. 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) ) }
  9. Scaffold(bottomBar = { ... }) { innerPadding -> BottomTabsContents(selectedTab, ...)

    }
  10. Scaffold(bottomBar = { ... }) { innerPadding -> Crossfade( targetState

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

    = selectedTab, modifier = Modifier.padding(innerPadding) ) { currentTab -> BottomTabsContents(currentTab) } }
  12. Box Box (alpha) Box (alpha)

  13. 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) }
  14. 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) }
  15. 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) }
  16. 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) }
  17. ) { 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) }
  18. } 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() } } }
  19. } 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() } } }
  20. } } } } 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) } } 복잡해보이지만,간단합니다.
  21. 🤔 전환효과가생겼지만, 뭔가아쉽지않나요? 역시디폴트는...

  22. Material Motion? MaterialDesign의MotionSystem에서는 구성요소또는화면간전환에 4가지패턴을제공합니다.

  23. 1. Container Transform

  24. 2. Shared Axis

  25. 3. Fade Through

  26. 4. Fade

  27. 스펙문서에Transition의 세부정보가적혀있습니다.

  28. None
  29. // Crossfade CrossfadeAnimationItem(key) { val alpha by transition.animateFloat( transitionSpec =

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

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

    { animationSpec } ) { if (it == key) 1f else 0f } Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(key) } }
  32. // 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) } }
  33. // 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) } }
  34. // 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) } }
  35. 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) } }
  36. material-motion-compose 🎉

  37. Shared Axis

  38. Fade Through Fade

  39. Elevation Scale Hold

  40. val enterMotionSpec = ... val exitMotionSpec = ... MaterialMotion( targetState

    = state, enterMotionSpec = enterMotionSpec, exitMotionSpec = exitMotionSpec, pop = false ) { newState -> // composable according to screen } material-motion-compose 🎉
  41. 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
  42. Demo

  43. DemoScreen (w/o transition) @Composable fun DemoScreen() { val (state, onStateChanged)

    = remember { … } if (state != null) { AlbumScreen(state) } else { LibraryScreen(onItemClick = { onStateChanged(it.id) }) } }
  44. 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) }) } } }
  45. LibraryScreen (w/o transition) @Composable fun LibraryScreen(...) { val (state, onStateChanged)

    = remember { mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid)) } Scaffold(...) { LibraryContents(state, ...) } }
  46. 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, ...) } } }
  47. 🤔 하지만, AlbumScreen에서 LibraryScreen으로 되돌아오면 상태가초기화됩니다...

  48. @Composable fun LibraryScreen(...) { val (state, onStateChanged) = remember {

    mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid)) } Scaffold(...) { MaterialMotion(...) { LibraryContents(...) } } } LibraryScreen (with transition)
  49. @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, )} ) }
  50. DemoScreen (with transition) @Composable fun DemoScreen() { val (state, onStateChanged)

    = remember { ... } MaterialMotion(...) { currentId -> if (currentId != null) { AlbumScreen(currentId) } else { LibraryScreen(onItemClick = { onStateChanged(it.id) }) } } }
  51. 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) }) } } } }
  52. ✅ Saveable를이용하여 적절히상태를복구해줍니다.

  53. Summary - androidx.compose.animation.core.Transition - Transition을 이용하면 상태 변경에 따른 Animation

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

  55. Container Transform

  56. Container Transform 어떻게 구현할 수 있을까? - 기존의 Activity, Fragment,

    View 기반의 Material Motion에서는 
 Container Transform가 Shared Elements를 기반으로 구현되어 있습니다. - Compose는 Shared Elements가 없기 때문에 Container 간의 Transition을 구현하려면, 
 (@Composable을 따로 받는다던지) 약간의 trick이 필요할 것 같습니다. - 🤔🤔🤔
  57. 감사합니다! Github: https://github.com/fornewid/material-motion-compose 재미있어보인다면사용해보고, 아이디어가있으면공유해주세요:)