Slide 1

Slide 1 text

できる!ComposeでCollapsingToolbar 2022/11/08 後夜祭 DroidKaigi 2022 tomoya0x00 1

Slide 2

Slide 2 text

About me tomoya0x00 Twitter, GitHub Android U-NEXT Co., Ltd. 2

Slide 3

Slide 3 text

お話しすること ComposeでCollapsingToolbarを実現する方法の概略 紹介するパターンの数を2から3に増やしました 上記を実現するときに学んだパフォーマンスTips 3

Slide 4

Slide 4 text

お話しの前提 画面内のコンテンツ表示にはLazyColumnを使用 話を単純化するため、他のLazyListやnotLazyなColumnについては触れません 4

Slide 5

Slide 5 text

サンプルコード https://github.com/tomoya0x00/ComposeCollapsingToolbarSample 短縮URL: https://bit.ly/3E7Bibv 5

Slide 6

Slide 6 text

目次 実はMaterial3に・・・ CollapsingToolbarの実装パターンとそれぞれの比較 U-NEXTで採用しているパターンの紹介 パフォーマンスTips まとめと感想 6

Slide 7

Slide 7 text

実はMaterial3に・・・ 7

Slide 8

Slide 8 text

https://developer.android.com/reference/kotlin/androidx/compose/material3/package- summary 8

Slide 9

Slide 9 text

https://developer.android.com/reference/kotlin/androidx/compose/material3/package- summary#LargeTopAppBar(kotlin.Function0,androidx.compose.ui.Modifier,kotlin.Function 0,kotlin.Function1,androidx.compose.foundation.layout.WindowInsets,androidx.compose. material3.TopAppBarColors,androidx.compose.material3.TopAppBarScrollBehavior) 9

Slide 10

Slide 10 text

10

Slide 11

Slide 11 text

TopAppBarScrollBehavior・・・? 11

Slide 12

Slide 12 text

https://developer.android.com/reference/kotlin/androidx/compose/material3/TopAppBarSc rollBehavior 12

Slide 13

Slide 13 text

動かしてみる 13

Slide 14

Slide 14 text

14

Slide 15

Slide 15 text

自作する必要無いのでは?? 15

Slide 16

Slide 16 text

できる!ComposeでCollapsingToolbar CollapsingToolbarを自作してComposeを学ぼう! 16

Slide 17

Slide 17 text

CollapsingToolbarの実装パターンとそれぞれの比較 17

Slide 18

Slide 18 text

パターン1: LazyColumnにContentPadding(top)指定 18

Slide 19

Slide 19 text

パターン1: LazyColumnにContentPadding(top)指定 公式のModifier.nestedScrollドキュメントに使用例として掲載されている LazyColumnは全画面表示で、ツールバーをその上に被せる ツールバーの高さを固定で定義しておきcontentPaddingとしてLazyColumnに渡す LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) ツールバーの開閉は、LazyColumnのスクロールに応じてツールバーのy方向オフセット を変更して実現 19

Slide 20

Slide 20 text

ツールバー サイズは固定 開閉に応じて上下に移動 LazyColumn 全画面表示 20

Slide 21

Slide 21 text

パターン2: Material3のTopAppBar方式 21

Slide 22

Slide 22 text

パターン2: Material3のTopAppBar方式 先に紹介したMaterial3のTopAppBar方式 LazyColumnは全画面表示で、ツールバーをその上に被せる ツールバー開閉は、TopAppBar自体の高さとLazyColumnのcontentPaddingを変更して 実現 Scaffold( ... content = { innerPadding -> LazyColumn( contentPadding = innerPadding, ... 22

Slide 23

Slide 23 text

ツールバー 開閉に応じてサイズ変更 LazyColumn 全画面表示 23

Slide 24

Slide 24 text

パターン3: LazyColumnごとスクロール Collapsing Toolbar in Jetpack Compose | Problem, solutions and alternatives で紹介さ れているアイデア LazyColumnは全画面表示で、ツールバーをその上に被せる ツールバーを開いている状態では、LazyColumnにy方向オフセットを指定して下に移動 しておく ツールバー開閉は、LazyColumnへのスクロール操作に対して、ツールバー内部と LazyColumnのy方向オフセットなどを変更して実現 24

Slide 25

Slide 25 text

ツールバー サイズは固定(開いた状態の 高さ) 開閉に応じて内部コンテンツ を移動 LazyColumn 全画面表示 ツールバー開閉に応じて上下 に移動 25

Slide 26

Slide 26 text

3つのパターンの比較 26

Slide 27

Slide 27 text

3つのパターンの比較 LazyColumnに ContentPadding(top) Material3の TopAppBar LazyColumnごとス クロール 理解しやすさ 普通 簡単 少し難しい 実装難易度 普通 簡単 少し難しい recomposition 少ない 多い 少ない scrollToItem() 問題あり 問題なし 問題なし 27

Slide 28

Slide 28 text

recompositionの頻度について Material3のTopAppBar方式は開閉に応じてツールバーの高さを変えている ツールバーの高さに応じて、LazyColumに指定するContentPaddingも変わる つまり、開閉操作中は常にrecompositionが走る 他のパターンでは各Composableのサイズは開閉しても(高頻度には)変化しない 開閉操作中のrecompositionを抑制できる 28

Slide 29

Slide 29 text

scrollToItem()の問題について LazyColumnにContentPadding(top)指定は、scrollToItemの挙動に問題あり TopのContentPaddingを指定している影響で、ツールバーを閉じている状態では scrollToItemで意図した位置にスクロールできない 29

Slide 30

Slide 30 text

「I'm item 10」へscrollToItemして いる ツールバーを閉じている状態で は、ContentPadding(top)分スクロ ール位置がズレる 30

Slide 31

Slide 31 text

U-NEXTで採用しているパターンの紹介 31

Slide 32

Slide 32 text

U-NEXTで採用しているパターンの紹介 最終的には「パターン3: LazyColumnごとスクロール」を採用 最初は「パターン1: LazyColumnにContentPadding(top)指定」を採用していた scrollToItem()の問題に気付いて「パターン3: LazyColumnごとスクロール」 に変更 ただし、元記事の実装だとrecompositionの発生回数が多い アイデアは参考にさせていただいて、オリジナル実装を作った 32

Slide 33

Slide 33 text

使用例 // TopAppBar + LazyColumn の(ほぼ同じ)引数に対応 LazyColumnWithToolbar( title = { }, navigationIcon = { }, actions = { }, ) { items(100) { index -> Text( text = "I'm item $index", style = MaterialTheme.typography.h3, modifier = Modifier .fillMaxWidth() .padding(16.dp), ) } } 33

Slide 34

Slide 34 text

@Composable fun LazyColumnWithToolbar( ... ) { // これの中身が重要 ToolbarWithScrollableContent( modifier = modifier, toolbarState = toolbarState, title = title, navigationIcon = navigationIcon, actions = actions, stickyContent = stickyContent, isReachedTop = { listState.isReachedTop() }, ) { LazyColumn(...) } } 34

Slide 35

Slide 35 text

@Composable fun ToolbarWithScrollableContent(...) { ... Box(modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection)) { CollapsingToolbar(...) // ツールバー部分 Column(...) { // ツールバーの開閉に応じて上下に移動 // ツールバー下部に常に表示するコンテンツ stickyContent?.invoke() // LazyColumn content() } } } 35

Slide 36

Slide 36 text

@Composable fun CollapsingToolbar( ... ) { ... CompositionLocalProvider( LocalContentColor provides contentColor, ) { Box(modifier = modifier.height(toolbarMaxHeight)) { // 背景を表示、開閉状態に応じて高さを変更 Background(...) // navigationIcon とactions ToolbarWithoutTitle(...) // タイトルを表示、開閉状態に応じて位置を移動 ToolbarTitle(...) } } } 36

Slide 37

Slide 37 text

LazyColumnのスクロール操作に応じてツールバーの開閉が必要 37

Slide 38

Slide 38 text

どうやってLazyColumnに対するスクロール操作に割り込むのか? 38

Slide 39

Slide 39 text

NestedScrollConnection 39

Slide 40

Slide 40 text

NestedScrollConnection ユーザーのスクロール操作に対してコンテンツを実際にスクロールさせる前後に、任意 の処理を追加できる その任意の処理で「どの程度のスクロール量を消費したか」を返却する 例えば、NestedScrollConnection内に記述した処理で全てのスクロール量を消費した場 合、ユーザーが操作したコンテンツはスクロールされなくなる 単純なスクロール量だけでは無く、フリンジも処理できる 40

Slide 41

Slide 41 text

class ToolbarNestedScrollConnection(...) : NestedScrollConnection { // ユーザーがースクロール操作した場合、 // LazyColumn がコンテンツを実際にスクロールする前に呼ばれる override fun onPreScroll(...): Offset { // LazyColumn のスクロール位置が0 なら、 // ツールバーの開閉にスクロール量を消費する } ... // ユーザーがフリンジ操作した場合、 // LazyColumn がフリンジ操作をハンドリングした後に呼ばれる override suspend fun onPostFling(...): Velocity { // LazyColumn のスクロール位置が0 かつツールバーがまだ開けるなら、 // ツールバーを開く } } 41

Slide 42

Slide 42 text

パフォーマンスTips 42

Slide 43

Slide 43 text

パフォーマンスTips CollapsingToolbar用の@StableなStateクラスを導入 値を読み出すのを後回しにする recompositon無しにツールバー背景の高さを変える ラムダ版のModifierを使う derivedStateOfを使う 43

Slide 44

Slide 44 text

CollapsingToolbar用の@StableなStateクラスを導入 LazyColumnに対するLazyListStateのようなもの UI element stateとUI logicを持つ これ自体が直接パフォーマンスに影響するわけではない ただし、「値を読み出すのを後回しにする」のためにも導入がオススメ @Stableなclassとするため、公開するプロパティの実体はMutableStateなどを使う 44

Slide 45

Slide 45 text

@Stable class CollapsingToolbarState(...) { private val maxOffset: Float = (maxHeight - minHeight).toFloat() private var _offset: Float by mutableStateOf(offset.coerceIn(0f, maxOffset)) private var _collapseFraction: Float by mutableStateOf(calcCollapseFraction()) // how much the toolbar extends from its collapsed state val offset: Float get() = _offset // fraction of collapse progress, 1 -> fully collapsed, 0 -> fully expanded val collapseFraction: Float get() = _collapseFraction var navigationIconWidth by mutableStateOf(0) var actionsWidth by mutableStateOf(0) fun consumeScrollOffset(available: Offset): Offset { ... } } 45

Slide 46

Slide 46 text

値を読み出すのを後回しにする Defer reads as long as possible としてGoogle公式ドキュメントにも掲載されている 値を読み出すのを後回しにすることで、recompositionの範囲を狭められる (理解が合っているか不安ですが)値を読み出すと、読み出した箇所と同じ階層の Composableラムダはrecompositionが起きてしまう 46

Slide 47

Slide 47 text

改善前 @Composable ToolbarWithScrollableContent( ... isReachedTop = listState.isReachedTop(), ) { ... } 改善後:ラムダで包む @Composable ToolbarWithScrollableContent( ... isReachedTop = { listState.isReachedTop() }, ) { ... } 47

Slide 48

Slide 48 text

改善前 @Composable Background( ... toolbarStateHeight = toolbarState.minHeight + toolbarState.offset, ) 改善後:@StableなStateクラスを渡す @Composable Background( ... toolbarState = toolbarState, ) 48

Slide 49

Slide 49 text

recompositon無しにツールバー背景の高さを変える 49

Slide 50

Slide 50 text

改善前 @Composable private fun Background( modifier: Modifier = Modifier, backgroundColor: Color, toolbarState: CollapsingToolbarState, ) { val toolbarHeight = with(LocalDensity.current) { (toolbarState.minHeight + toolbarState.offset).toDp() } Box( modifier = modifier .background(backgroundColor) .fillMaxWidth() .height(toolbarHeight) ) } 50

Slide 51

Slide 51 text

改善版 @Composable private fun Background( modifier: Modifier = Modifier, backgroundColor: Color, toolbarState: CollapsingToolbarState, ) { Box( modifier = modifier .fillMaxSize() .drawWithCache { onDrawBehind { drawRect( color = backgroundColor, size = Size( width = size.width, height = toolbarState.minHeight + toolbarState.offset, ), ) } }, ) } 51

Slide 52

Slide 52 text

ラムダ版のModifierを使う Defer reads as long as possible の中で触れられている Modifier が変わる = recomposition が発生する なので、可能な限りラムダバージョン を使用する 52

Slide 53

Slide 53 text

改善前 Row( modifier = modifier .height(toolbarMinHeight) .graphicsLayer( translationX = toolbarState.navigationIconWidth * toolbarState.collapseFraction, translationY = toolbarState.offset, ), verticalAlignment = Alignment.CenterVertically, ) {...} 53

Slide 54

Slide 54 text

改善後 Row( modifier = modifier .height(toolbarMinHeight) .graphicsLayer { translationX = toolbarState.navigationIconWidth * toolbarState.collapseFraction translationY = toolbarState.offset }, verticalAlignment = Alignment.CenterVertically, ) {...} 54

Slide 55

Slide 55 text

derivedStateOfを使う Use derivedStateOf to limit recompositions としてGoogle公式ドキュメントにも掲載さ れている ある状態(例:Int)から派生した状態(例:Boolean)をComposable内の条件分岐な どに使用する場合、 derivedStateOf で無駄なrecompositionを防げる 55

Slide 56

Slide 56 text

改善前 Row( ... ) { Layout(content = title) { measurables, constraints -> // cause a lot of recomposition!! val maxWidth = if (toolbarState.offset < toolbarState.minHeight) { constraints.maxWidth - toolbarState.navigationIconWidth - toolbarState.actionsWidth } else { constraints.maxWidth } ... } } 56

Slide 57

Slide 57 text

改善後 val almostCollapsed: Boolean by remember { derivedStateOf { toolbarState.offset < toolbarState.minHeight } } Row( ... ) { Layout(content = title) { measurables, constraints -> val maxWidth = if (almostCollapsed) { constraints.maxWidth - toolbarState.navigationIconWidth - toolbarState.actionsWidth } else { constraints.maxWidth } ... } } 57

Slide 58

Slide 58 text

まとめと感想 58

Slide 59

Slide 59 text

まとめと感想 CollapsingToolbarを作るのは大変 作った後にMaterial3のTopAppBarの存在に気付いてちょっとショック ただし、自作したことでかなりComposeの勉強にはなった 自作版のメリットもあるので、今後Material3のTopAppBarを使うかは要検討項目 まだ ExperimentalMaterial3Api なので、まずは様子を見守るのが良い? 59

Slide 60

Slide 60 text

ご清聴ありがとうございました! 60