$30 off During Our Annual Pro Sale. View Details »

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

    View Slide

  2. 안성용 SOUP
    [email protected]

    View Slide

  3. ?
    👀

    View Slide

  4. Jetpack
    Compose?

    View Slide

  5. View Slide

  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)


    )


    }

    View Slide

  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)


    )


    }

    View Slide

  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)


    )


    }

    View Slide

  9. Scaffold(bottomBar = { ... }) { innerPadding ->


    BottomTabsContents(selectedTab, ...)


    }

    View Slide

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


    Crossfade(


    targetState = selectedTab,


    modifier = Modifier.padding(innerPadding)


    ) { currentTab ->


    BottomTabsContents(currentTab)


    }


    }

    View Slide

  11. Scaffold(bottomBar = { ... }) { innerPadding ->


    Crossfade(


    targetState = selectedTab,


    modifier = Modifier.padding(innerPadding)


    ) { currentTab ->


    BottomTabsContents(currentTab)


    }


    }

    View Slide

  12. Box Box (alpha)
    Box (alpha)

    View Slide

  13. private data class CrossfadeAnimationItem(


    val key: T,


    val content: @Composable () -> Unit


    )


    @Composable


    fun Crossfade(


    targetState: T,


    modifier: Modifier = Modifier,


    animationSpec: FiniteAnimationSpec = tween(),


    content: @Composable (T) -> Unit


    ) {


    val items = remember { mutableStateListOf>() }


    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) }




    View Slide

  14. private data class CrossfadeAnimationItem(


    val key: T,


    val content: @Composable () -> Unit


    )


    @Composable


    fun Crossfade(


    targetState: T,


    modifier: Modifier = Modifier,


    animationSpec: FiniteAnimationSpec = tween(),


    content: @Composable (T) -> Unit


    ) {


    val items = remember { mutableStateListOf>() }


    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) }




    View Slide

  15. private data class CrossfadeAnimationItem(


    val key: T,


    val content: @Composable () -> Unit


    )


    @Composable


    fun Crossfade(


    targetState: T,


    modifier: Modifier = Modifier,


    animationSpec: FiniteAnimationSpec = tween(),


    content: @Composable (T) -> Unit


    ) {


    val items = remember { mutableStateListOf>() }


    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) }




    View Slide

  16. private data class CrossfadeAnimationItem(


    val key: T,


    val content: @Composable () -> Unit


    )


    @Composable


    fun Crossfade(


    targetState: T,


    modifier: Modifier = Modifier,


    animationSpec: FiniteAnimationSpec = tween(),


    content: @Composable (T) -> Unit


    ) {


    val items = remember { mutableStateListOf>() }


    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) }




    View Slide



  17. ) {


    val items = remember { mutableStateListOf>() }


    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)


    }




    View Slide



  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()


    }


    }


    }


    View Slide



  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()


    }


    }


    }


    View Slide



  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)


    }


    }
    복잡해보이지만,간단합니다.

    View Slide

  21. 🤔
    전환효과가생겼지만,
    뭔가아쉽지않나요?
    역시디폴트는...

    View Slide

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

    View Slide

  23. 1. Container Transform

    View Slide

  24. 2. Shared Axis

    View Slide

  25. 3. Fade Through

    View Slide

  26. 4. Fade

    View Slide

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

    View Slide

  28. View Slide

  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)


    }


    }

    View Slide

  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)


    }


    }

    View Slide

  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)


    }


    }

    View Slide

  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)


    }


    }

    View Slide

  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)


    }


    }

    View Slide

  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)


    }


    }

    View Slide

  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)


    }


    }

    View Slide

  36. material-motion-compose 🎉

    View Slide

  37. Shared Axis

    View Slide

  38. Fade Through Fade

    View Slide

  39. Elevation Scale Hold

    View Slide

  40. val enterMotionSpec = ...


    val exitMotionSpec = ...


    MaterialMotion(


    targetState = state,


    enterMotionSpec = enterMotionSpec,


    exitMotionSpec = exitMotionSpec,


    pop = false


    ) { newState ->


    // composable according to screen


    }
    material-motion-compose 🎉

    View Slide

  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

    View Slide

  42. Demo

    View Slide

  43. DemoScreen (w/o transition)
    @Composable


    fun DemoScreen() {


    val (state, onStateChanged) = remember { … }


    if (state != null) {


    AlbumScreen(state)


    } else {


    LibraryScreen(onItemClick = { onStateChanged(it.id) })


    }


    }

    View Slide

  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) })


    }


    }


    }

    View Slide

  45. LibraryScreen (w/o transition)
    @Composable


    fun LibraryScreen(...) {


    val (state, onStateChanged) = remember {


    mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid))


    }


    Scaffold(...) {


    LibraryContents(state, ...)


    }


    }

    View Slide

  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, ...)


    }


    }


    }

    View Slide

  47. 🤔
    하지만,
    AlbumScreen에서
    LibraryScreen으로
    되돌아오면
    상태가초기화됩니다...

    View Slide

  48. @Composable


    fun LibraryScreen(...) {


    val (state, onStateChanged) = remember {


    mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid))


    }


    Scaffold(...) {


    MaterialMotion(...) {


    LibraryContents(...)


    }


    }


    }
    LibraryScreen (with transition)

    View Slide

  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,


    )}


    )


    }

    View Slide

  50. DemoScreen (with transition)
    @Composable


    fun DemoScreen() {


    val (state, onStateChanged) = remember { ... }


    MaterialMotion(...) { currentId ->


    if (currentId != null) {


    AlbumScreen(currentId)


    } else {


    LibraryScreen(onItemClick = { onStateChanged(it.id) })


    }


    }


    }

    View Slide

  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) })


    }


    }


    }


    }

    View Slide


  52. Saveable를이용하여
    적절히상태를복구해줍니다.

    View Slide

  53. Summary
    - androidx.compose.animation.core.Transition
    - Transition을 이용하면 상태 변경에 따른 Animation 효과를 구현할 수 있습니다.
    - 화면 전환 효과를 구현할 때는 각각의 화면을 Box로 한번 감싼 후,

    Box의 modifier 속성에 변화를 주면 됩니다.

    - Saveable, SaveableStateHolder
    - 화면을 전환할 때는 마지막 상태를 저장해두고, 다시 되돌아왔을 때 상태를 복구해줘야 합니다.
    - ViewModel에 상태를 저장해두는 방법도 가능합니다.
    - 다만 스크롤 같은 UI 상태를 저장/복구하려면, SaveableStateHolder를 이용하는 것이 간단합니다.
    - 참고로 navigation-compose 라이브러리가 공식적으로 제공되고 있는데요.

    내부적으로 SaveableStateHolder를 사용하고 있습니다.

    View Slide

  54. 하나만더…

    View Slide

  55. Container Transform

    View Slide

  56. Container Transform 어떻게 구현할 수 있을까?
    - 기존의 Activity, Fragment, View 기반의 Material Motion에서는

    Container Transform가 Shared Elements를 기반으로 구현되어 있습니다.
    - Compose는 Shared Elements가 없기 때문에 Container 간의 Transition을 구현하려면,

    (@Composable을 따로 받는다던지) 약간의 trick이 필요할 것 같습니다.
    - 🤔🤔🤔

    View Slide

  57. 감사합니다!
    Github: https://github.com/fornewid/material-motion-compose
    재미있어보인다면사용해보고,
    아이디어가있으면공유해주세요:)

    View Slide