Slide 1

Slide 1 text

Material Motion for Jetpack Compose

Slide 2

Slide 2 text

안성용 SOUP [email protected]

Slide 3

Slide 3 text

? 👀

Slide 4

Slide 4 text

Jetpack Compose?

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

Scaffold(bottomBar = { ... }) { innerPadding -> BottomTabsContents(selectedTab, ...) }

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Box Box (alpha) Box (alpha)

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

} } } } 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) } } 복잡해보이지만,간단합니다.

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

1. Container Transform

Slide 24

Slide 24 text

2. Shared Axis

Slide 25

Slide 25 text

3. Fade Through

Slide 26

Slide 26 text

4. Fade

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

material-motion-compose 🎉

Slide 37

Slide 37 text

Shared Axis

Slide 38

Slide 38 text

Fade Through Fade

Slide 39

Slide 39 text

Elevation Scale Hold

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

Demo

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

하나만더…

Slide 55

Slide 55 text

Container Transform

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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