Slide 1

Slide 1 text

Composableの枠を超えて アニメーションする shibuya.apk #46 2024/01/19 (Fri.) Taichi Sato / @syarihu Giftmall, Inc. / mikan Co., Ltd. Android Engineer 動きのあるスライドが多いため、動きを見たい場合は DescriptionにあるURLまた は右のQRコードからGoogle Slidesを見てください

Slide 2

Slide 2 text

Taichi Sato (@syarihu) ● 本業 ○ Giftmall, Inc. (LUCHE GROUP) ● 副業 ○ mikan Co., Ltd.

Slide 3

Slide 3 text

Taichi Sato (@syarihu) ● 本業 ○ Giftmall, Inc. (LUCHE GROUP) ● 副業 ○ mikan Co., Ltd.

Slide 4

Slide 4 text

実現したい機能 語句を並べかえて正しい英文を作る問題 を回答する(語句整序)ための機能

Slide 5

Slide 5 text

語句整序の初期実装

Slide 6

Slide 6 text

語句整序の初期実装 ● 6単語以上は非対応 ● 単語の改行に非対応 ○ 改行が必要になる問題の場合は レイアウトが崩れる ● 単語を一単語ずつ戻せない ○ すべての単語が一括で戻る

Slide 7

Slide 7 text

語句整序の初期実装 ● Layout Composableを使う ○ Jetpack Composeにおけるカスタムレイアウト ● 単語が選択されたら選択された単語の枠はグレーアウトして表示 しておくため、背景色用のBoxの中に単語のBoxを入れ子にする ● Boxのoffsetを変えることで単語の移動と並べ替えを表現 ○ 単語をタップしたときにoffsetを計算して単語を移動

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

移動用のBoxのoffsetを変えたとき offset=0の単語 offsetをずらした単語

Slide 10

Slide 10 text

語句整序の初期実装の課題 ● 選択された単語のoffsetを計算する必要があるので、複数行に なったときに計算が大変 ○ これが理由で初期実装時に改行を諦めたのだと思い確認し たところ、その通りだった

Slide 11

Slide 11 text

語句整序の初期実装の課題 ● 複数行になったとき、選択済の単語を表示する領域にDividerを 表示するのが難しい

Slide 12

Slide 12 text

語句整序の初期実装の課題 ● 複数行になったとき、選択済の単語を表示する領域にDividerを 表示するのが難しい これ これ

Slide 13

Slide 13 text

語句整序の初期実装の課題 ● 複数行になったとき、選択済の単語を表示する領域にDividerを 表示するのが難しい ○ 単語を描画する領域と行数を計算し、必要なDividerを事前に Layoutのcontent内に入れておく必要がある ○ 初期実装では1行の表示を前提としてLayoutの外にDivider を置いてoffsetで選択済と選択肢の間に配置していた

Slide 14

Slide 14 text

語句整序の初期実装の課題 ● 初期実装の追加改修では6単語以上(改行が必要となる語句整 序)の対応は難しいと判断して、別のアプローチで新規実装する ことにした

Slide 15

Slide 15 text

語句整序の6単語以上に対応する方法を考える ● Layout Composableによる実装 ○ 単語が右端までいったら単語単位で折り返さないといけない ○ 単語が改行されるとき、行間にDividerを入れるのが大変 ■ Layoutはあくまで配置を決めるものなので、事前に DividerをContentに含める必要がある

Slide 16

Slide 16 text

語句整序の6単語以上に対応する方法を考える ● Canvasによる実装 ○ ComposeにもCanvas Composableがあり、すべてをCanvas で描画すれば頑張ればできる ○ パワープレイすぎるし、誰もメンテできなくなる可能性がある のでできればやりたくない ○ 他のアプローチが無かった場合の最終手段にした

Slide 17

Slide 17 text

語句整序の6単語以上に対応する方法を考える ● レイアウトはComposableで、アニメーションはCanvasで実装す る ○ 単語単位で折返すレイアウトはFlowRowを使う ○ ModifierにはdrawWithContentがあり、これを使うと Composableの描画の後にCanvasを描画できる

Slide 18

Slide 18 text

改行可能な単語一覧のレイアウトを構築する

Slide 19

Slide 19 text

単語を並べて、単語が右端までいったら単語単位で折り返す val sampleWords = listOf( "store", "to", "buy", "want", "food", "to", "cook", "dinner", "to", "go", "to", "the", "some", "to", "eat", "with", "my", "family", "I" ).mapIndexed { index, word -> WordEntity(wordId = "$index", word = word) } @Composable private fun Words( words: List = sampleWords, ) { FlowRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 16.dp), mainAxisSpacing = 8.dp, crossAxisSpacing = 16.dp, ) { words.forEach { entity -> Word( word = entity.word, ) } } }

Slide 20

Slide 20 text

単語を並べて、単語が右端までいったら単語単位で折り返す val sampleWords = listOf( "store", "to", "buy", "want", "food", "to", "cook", "dinner", "to", "go", "to", "the", "some", "to", "eat", "with", "my", "family", "I" ).mapIndexed { index, word -> WordEntity(wordId = "$index", word = word) } @Composable private fun Words( words: List = sampleWords, ) { FlowRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 16.dp), mainAxisSpacing = 8.dp, crossAxisSpacing = 16.dp, ) { words.forEach { entity -> Word( word = entity.word, ) } } }

Slide 21

Slide 21 text

単語を並べて、単語が右端までいったら単語単位で折り返す

Slide 22

Slide 22 text

選択した単語の描画領域を確保する

Slide 23

Slide 23 text

単語を並べて、単語が右端までいったら単語単位で折り返す @Composable private fun Word( word: String, showWord: Boolean = true, onGloballyPositioned: (LayoutCoordinates) -> Unit = {}, ) { Box( modifier = Modifier .drawWithContent { if (showWord) { drawContent() } } .border( BorderStroke(2.dp, Color.LightGray), RoundedCornerShape(8.dp) ) .onGloballyPositioned { onGloballyPositioned(it) } ) {

Slide 24

Slide 24 text

単語を並べて、単語が右端までいったら単語単位で折り返す @Composable private fun Word( word: String, showWord: Boolean = true, onGloballyPositioned: (LayoutCoordinates) -> Unit = {}, ) { Box( modifier = Modifier .drawWithContent { if (showWord) { drawContent() } } .border( BorderStroke(2.dp, Color.LightGray), RoundedCornerShape(8.dp) ) .onGloballyPositioned { onGloballyPositioned(it) } ) {

Slide 25

Slide 25 text

単語を並べて、単語が右端までいったら単語単位で折り返す FlowRow( // ... ) { words.forEach { entity -> Word( word = entity.word, showWord = showWord, ) } }

Slide 26

Slide 26 text

単語を並べて、単語が右端までいったら単語単位で折り返す @Preview @Composable private fun Preview() { Column { Words(showWord = false) Words(showWord = true) } }

Slide 27

Slide 27 text

選択した単語の描画領域を確保する

Slide 28

Slide 28 text

選択済の単語の領域にDividerを描画する

Slide 29

Slide 29 text

選択済の単語の領域にDividerを描画する // FlowRowのサイズを保持する var flowRowSize by remember { mutableStateOf(IntSize.Zero) } FlowRow( modifier = Modifier // ... .onGloballyPositioned { flowRowSize = it.size }, // ... ) { // ... }

Slide 30

Slide 30 text

選択済の単語の領域にDividerを描画する // 各単語の位置を保持するための Map val wordPositions = remember { mutableMapOf() } FlowRow( // ... ) { words.forEach { entity -> Word( word = entity.word, visible = showWord, onGloballyPositioned = { wordPositions[entity.wordId] = it } ) } }

Slide 31

Slide 31 text

選択済の単語の領域にDividerを描画する FlowRow( modifier = Modifier // ... .drawWithContent { // FlowRowのcontentを先に描画する drawContent()

Slide 32

Slide 32 text

選択済の単語の領域にDividerを描画する FlowRow( modifier = Modifier // ... .drawWithContent { // FlowRowのcontentを先に描画する drawContent() // 単語が非表示のとき if (showWord.not()) { // 描画されている単語の中から親の Composable上でx=0の位置に // 描画されている単語(各行の一番最初の単語)のみを取得する val firstWordInLines = wordPositions .filter { (_, layoutCoordinates) -> layoutCoordinates.positionInParent().x == 0f } if (firstWordInLines.isEmpty()) return@drawWithContent

Slide 33

Slide 33 text

選択済の単語の領域にDividerを描画する FlowRow( modifier = Modifier // ... .drawWithContent { // ... if (showWord.not()) { // ... // 最後行の下に dividerを表示しないために最後行の単語の keyを取得する val lastKey = firstWordInLines.keys.last() // 最後行の単語以外の単語の下に dividerを表示する firstWordInLines .filterNot { it.key == lastKey } .forEach { (_, layoutCoordinates) -> // ここでDividerを描画する } } }

Slide 34

Slide 34 text

選択済の単語の領域にDividerを描画する firstWordInLines .filterNot { it.key == lastKey } .forEach { (_, layoutCoordinates) -> // 単語の左下の y座標 + 単語とDividerの間のスペース val dividerOffsetY = Rect( layoutCoordinates.positionInParent(), layoutCoordinates.size.toSize() ).bottomLeft.y + dividerSpacing drawLine( Color.LightGray, start = Offset( // FlowRowの一番左から dividerを表示 x = 0f, y = dividerOffsetY ), end = Offset( // FlowRowの一番右まで dividerを表示 x = flowRowSize.width.toFloat(), y = dividerOffsetY ), strokeWidth = 1.dp.toPx() ) }

Slide 35

Slide 35 text

選択済の単語の領域にDividerを描画する firstWordInLines .filterNot { it.key == lastKey } .forEach { (_, layoutCoordinates) -> // 単語の左下の y座標 + 単語とDividerの間のスペース val dividerOffsetY = Rect( layoutCoordinates.positionInParent(), layoutCoordinates.size.toSize() ).bottomLeft.y + dividerSpacing drawLine( Color.LightGray, start = Offset( // FlowRowの一番左から dividerを表示 x = 0f, y = dividerOffsetY ), end = Offset( // FlowRowの一番右まで dividerを表示 x = flowRowSize.width.toFloat(), y = dividerOffsetY ), strokeWidth = 1.dp.toPx() ) }

Slide 36

Slide 36 text

選択済の単語の領域にDividerを描画する firstWordInLines .filterNot { it.key == lastKey } .forEach { (_, layoutCoordinates) -> // 単語の左下の y座標 + 単語とDividerの間のスペース val dividerOffsetY = Rect( layoutCoordinates.positionInParent(), layoutCoordinates.size.toSize() ).bottomLeft.y + dividerSpacing drawLine( Color.LightGray, start = Offset( // FlowRowの一番左から dividerを表示 x = 0f, y = dividerOffsetY ), end = Offset( // FlowRowの一番右まで dividerを表示 x = flowRowSize.width.toFloat(), y = dividerOffsetY ), strokeWidth = 1.dp.toPx() ) }

Slide 37

Slide 37 text

選択済の単語の領域にDividerを描画する

Slide 38

Slide 38 text

選択済の単語の領域にDividerを描画する @Preview @Composable private fun WordsPreview() { Column { Box { Words(words = sampleWords, showWord = false) Words(words = sampleWords.take(5), showWord = true) } Words(words = sampleWords, showWord = true) } }

Slide 39

Slide 39 text

選択済の単語の領域にDividerを描画する

Slide 40

Slide 40 text

Composableの枠を超えてアニメーションする

Slide 41

Slide 41 text

Composableの枠を超えてアニメーションする @Preview @Composable private fun WordsPreview() { Column { Box { Words(words = sampleWords, showWord = false) Words(words = selectedWords, showWord = true) } Words(words = sampleWords, showWord = true) } }

Slide 42

Slide 42 text

Composableの枠を超えてアニメーションする

Slide 43

Slide 43 text

Column Composableの枠を超えてアニメーションする

Slide 44

Slide 44 text

Column { Box { Words {} Words {} } Words {} } Composableの枠を超えてアニメーションする

Slide 45

Slide 45 text

Column { Box { Words {} Words { Word() } } Words { Word() } } Composableの枠を超えてアニメーションする

Slide 46

Slide 46 text

Column { Box { Words {} Words { Word() } } Words { Word() } } 入れ子になったComposableを外のComposableに アニメーションで移動したい Composableの枠を超えてアニメーションする

Slide 47

Slide 47 text

ColumnのCanvas上に単語を描画し、 その単語をアニメーションすることで 単語が移動しているように見せよう💡

Slide 48

Slide 48 text

@Composable fun WordOrderChoice( words: List = sampleWords, selectedWords: List = emptyList() ) { Column( modifier = Modifier.drawWithContent { drawContent() // ここに単語のアニメーションを実装することで、 // Composableの入れ子を気にせずアニメーションできる } ) { Box { Words(words = words, showWord = false) Words(words = selectedWords) } Words(words = words, showWord = true) } } Composableの枠を超えてアニメーションする

Slide 49

Slide 49 text

@Composable fun WordOrderChoice( words: List = sampleWords, selectedWords: List = emptyList() ) { Column( modifier = Modifier.drawWithContent { drawContent() // ここに単語のアニメーションを実装することで、 // Composableの入れ子を気にせずアニメーションできる } ) { Box { Words(words = words, showWord = false) Words(words = selectedWords) } Words(words = words, showWord = true) } } Composableの枠を超えてアニメーションする

Slide 50

Slide 50 text

Composableの枠を超えてアニメーションする 1. Composableの単語をタップ 2. Composableの単語を非表示にする 3. Composableの単語があった位置にCanvas上で単語を表示する 4. 移動先の単語一覧のComposableに非表示状態で単語を追加 ○ 移動先の単語の位置の確認のため 5. Canvas上でアニメーションで単語を移動 6. 移動が終わったらComposableの単語を表示し、Canvas上の単語を 非表示にする

Slide 51

Slide 51 text

アニメーションなしで 単語を選択できるようにする

Slide 52

Slide 52 text

/** 単語の表示状態 */ enum class WordVisibility { /** 単語を表示する */ Visible, /** 単語全体を塗りつぶす(文字は表示されない) */ InvisibleFilled, /** 単語を透明にする(表示領域のみの確保) */ InvisibleTransparent, } data class WordEntity( val wordId: String, val word: String, val visibility: WordVisibility = WordVisibility.Visible, ) 単語を選択できるようにする

Slide 53

Slide 53 text

@Composable private fun Word( word: WordEntity, showWord: Boolean = true, onGloballyPositioned: (LayoutCoordinates) -> Unit = {}, onClick: (WordEntity) -> Unit = {}, ) { Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .drawWithContent { if (showWord) { when (word.visibility) { WordVisibility.Visible -> drawContent() WordVisibility.InvisibleFilled -> drawRect(Color.LightGray) else -> Unit } } } // ... .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), onClick = { onClick(word) }, ) 単語を選択できるようにする

Slide 54

Slide 54 text

@Composable private fun Word( word: WordEntity, showWord: Boolean = true, onGloballyPositioned: (LayoutCoordinates) -> Unit = {}, onClick: (WordEntity) -> Unit = {}, ) { Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .drawWithContent { if (showWord) { when (word.visibility) { WordVisibility.Visible -> drawContent() WordVisibility.InvisibleFilled -> drawRect(Color.LightGray) else -> Unit } } } // ... .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), onClick = { onClick(word) }, ) 単語を選択できるようにする

Slide 55

Slide 55 text

@Composable private fun Word( word: WordEntity, showWord: Boolean = true, onGloballyPositioned: (LayoutCoordinates) -> Unit = {}, onClick: (WordEntity) -> Unit = {}, ) { Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .drawWithContent { if (showWord) { when (word.visibility) { WordVisibility.Visible -> drawContent() WordVisibility.InvisibleFilled -> drawRect(Color.LightGray) else -> Unit } } } // ... .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), onClick = { onClick(word) }, ) 単語を選択できるようにする

Slide 56

Slide 56 text

@Composable private fun Words( words: List, showWords: Boolean = true, onClickWord: (WordEntity) -> Unit = {}, ) { // ... FlowRow( // ... ) { words.forEach { entity -> Word( // ... word = entity, onClick = onClickWord, ) } } 単語を選択できるようにする

Slide 57

Slide 57 text

@Composable fun WordOrderChoice( words: List, selectedWords: List, onClickChoicesWord: (WordEntity) -> Unit = {}, ) { Column( modifier = Modifier.background(Color.White) ) { Box { Words(words = words, showWords = false) Words(words = selectedWords) } Words( words = words, showWords = true, onClickWord = onClickChoicesWord ) } } 単語を選択できるようにする

Slide 58

Slide 58 text

@Preview @Composable private fun WordOrderChoicePreview() { var words by remember { mutableStateOf(sampleWords) } var selectedWords by remember { mutableStateOf(listOf()) } WordOrderChoice( words = words, selectedWords = selectedWords, onClickChoicesWord = { clickWord -> words = words.map { if (it.wordId == clickWord.wordId) { it.copy(visibility = WordVisibility.InvisibleFilled) } else { it } } selectedWords = selectedWords.toMutableList().apply { add(clickWord) } } ) } 単語を選択できるようにする

Slide 59

Slide 59 text

単語を選択できるよ うにする

Slide 60

Slide 60 text

単語選択時にアニメーションする

Slide 61

Slide 61 text

private fun ContentDrawScope.drawWord( textLayoutResult: TextLayoutResult, wordSize: Size, wordTopLeft: Offset, ) { if (wordTopLeft == Offset.Unspecified || wordSize == Size.Zero) return // テキストがチップの中央に表示されるように計算して配置する val textTopLeft = Offset( wordTopLeft.x + wordSize.width / 2f - textLayoutResult.size.width / 2f, wordTopLeft.y + wordSize.height / 2f - textLayoutResult.size.height / 2f, ) // チップの背景色 drawRoundRect( color = Color.White, topLeft = wordTopLeft, size = wordSize, cornerRadius = CornerRadius(8.dp.toPx()) ) // ... Canvasで単語を描画する

Slide 62

Slide 62 text

private fun ContentDrawScope.drawWord( textLayoutResult: TextLayoutResult, wordSize: Size, wordTopLeft: Offset, ) { if (wordTopLeft == Offset.Unspecified || wordSize == Size.Zero) return // テキストがチップの中央に表示されるように計算して配置する val textTopLeft = Offset( wordTopLeft.x + wordSize.width / 2f - textLayoutResult.size.width / 2f, wordTopLeft.y + wordSize.height / 2f - textLayoutResult.size.height / 2f, ) // チップの背景色 drawRoundRect( color = Color.White, topLeft = wordTopLeft, size = wordSize, cornerRadius = CornerRadius(8.dp.toPx()) ) // ... Canvasで単語を描画する

Slide 63

Slide 63 text

private fun ContentDrawScope.drawWord( textLayoutResult: TextLayoutResult, wordSize: Size, wordTopLeft: Offset, ) { if (wordTopLeft == Offset.Unspecified || wordSize == Size.Zero) return // テキストがチップの中央に表示されるように計算して配置する val textTopLeft = Offset( wordTopLeft.x + wordSize.width / 2f - textLayoutResult.size.width / 2f, wordTopLeft.y + wordSize.height / 2f - textLayoutResult.size.height / 2f, ) // チップの背景色 drawRoundRect( color = Color.White, topLeft = wordTopLeft, size = wordSize, cornerRadius = CornerRadius(8.dp.toPx()) ) // ... Canvasで単語を描画する

Slide 64

Slide 64 text

private fun ContentDrawScope.drawWord( textLayoutResult: TextLayoutResult, wordSize: Size, wordTopLeft: Offset, ) { if (wordTopLeft == Offset.Unspecified || wordSize == Size.Zero) return // テキストがチップの中央に表示されるように計算して配置する val textTopLeft = Offset( wordTopLeft.x + wordSize.width / 2f - textLayoutResult.size.width / 2f, wordTopLeft.y + wordSize.height / 2f - textLayoutResult.size.height / 2f, ) // 単語の背景色 drawRoundRect( color = Color.White, topLeft = wordTopLeft, size = wordSize, cornerRadius = CornerRadius(8.dp.toPx()) ) // ... Canvasで単語を描画する

Slide 65

Slide 65 text

private fun ContentDrawScope.drawWord( textLayoutResult: TextLayoutResult, wordSize: Size, wordTopLeft: Offset, ) { // ... // 単語の枠線 drawRoundRect( color = Color.LightGray, topLeft = wordTopLeft, size = wordSize, cornerRadius = CornerRadius(8.dp.toPx()), style = Stroke(2.dp.toPx()) ) // 単語のテキスト drawText( textLayoutResult, topLeft = textTopLeft, ) } Canvasで単語を描画する

Slide 66

Slide 66 text

data class DrawWord( /* 描画する単語の情報 */ val word: WordEntity, /* 単語のテキストの測定結果 */ val textLayoutResult: TextLayoutResult? = null, /* 単語のサイズ */ val size: Size = Size.Zero, /* 単語の初期位置 */ val originalTopLeft: Offset = Offset.Zero, /* 単語の現在の位置(アニメーション中はこれを更新する) */ val topLeft: Offset = Offset.Zero, /** 単語がアニメーション中かどうか */ val isInAnimation: Boolean = false, ) Canvas上で単語をアニメーションするための準備

Slide 67

Slide 67 text

@Composable fun WordOrderChoice( // ... ) { val textMeasurer = rememberTextMeasurer() val coroutineScope = rememberCoroutineScope() val drawWords = remember { mutableStateMapOf() } var parentLayoutCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } Column( modifier = Modifier .background(Color.White) .drawWithContent { drawContent() drawWords.forEach { (_, drawWord) -> drawWord.textLayoutResult?.let { textLayoutResult -> drawWord(textLayoutResult, drawWord.size, drawWord.topLeft) } } } .onGloballyPositioned { parentLayoutCoordinates = it } ) { Canvas上で単語をアニメーションするための準備

Slide 68

Slide 68 text

@Composable fun WordOrderChoice( // ... ) { val textMeasurer = rememberTextMeasurer() val coroutineScope = rememberCoroutineScope() val drawWords = remember { mutableStateMapOf() } var parentLayoutCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } Column( modifier = Modifier .background(Color.White) .drawWithContent { drawContent() drawWords.forEach { (_, drawWord) -> drawWord.textLayoutResult?.let { textLayoutResult -> drawWord(textLayoutResult, drawWord.size, drawWord.topLeft) } } } .onGloballyPositioned { parentLayoutCoordinates = it } ) { Canvas上で単語をアニメーションするための準備

Slide 69

Slide 69 text

@Composable fun WordOrderChoice( // ... ) { val textMeasurer = rememberTextMeasurer() val coroutineScope = rememberCoroutineScope() val drawWords = remember { mutableStateMapOf() } var parentLayoutCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } Column( modifier = Modifier .background(Color.White) .drawWithContent { drawContent() drawWords.forEach { (_, drawWord) -> drawWord.textLayoutResult?.let { textLayoutResult -> drawWord(textLayoutResult, drawWord.size, drawWord.topLeft) } } } .onGloballyPositioned { parentLayoutCoordinates = it } ) { Canvas上で単語をアニメーションするための準備

Slide 70

Slide 70 text

@Composable fun WordOrderChoice( // ... ) { val textMeasurer = rememberTextMeasurer() val coroutineScope = rememberCoroutineScope() val drawWords = remember { mutableStateMapOf() } var parentLayoutCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } Column( modifier = Modifier .background(Color.White) .drawWithContent { drawContent() drawWords.forEach { (_, drawWord) -> drawWord.textLayoutResult?.let { textLayoutResult -> drawWord(textLayoutResult, drawWord.size, drawWord.topLeft) } } } .onGloballyPositioned { parentLayoutCoordinates = it } ) { Canvas上で単語をアニメーションするための準備

Slide 71

Slide 71 text

@Composable private fun Words( // ... onClickWord: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, onWordPositionChanged: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, ) { val wordPositions = remember { mutableMapOf() } FlowRow(...) { words.forEach { entity -> Word( // ... onGloballyPositioned = { wordPositions[entity.wordId] = it onWordPositionChanged(entity, it) }, onClick = { wordPositions[entity.wordId]?.let { layoutCoordinates -> onClickWord(it, layoutCoordinates) } }, ) } 単語をタップしたときに単語の位置を取得する

Slide 72

Slide 72 text

@Composable private fun Words( // ... onClickWord: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, onWordPositionChanged: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, ) { val wordPositions = remember { mutableMapOf() } FlowRow(...) { words.forEach { entity -> Word( // ... onGloballyPositioned = { wordPositions[entity.wordId] = it onWordPositionChanged(entity, it) }, onClick = { wordPositions[entity.wordId]?.let { layoutCoordinates -> onClickWord(it, layoutCoordinates) } }, ) } 単語をタップしたときに単語の位置を取得する

Slide 73

Slide 73 text

@Composable private fun Words( // ... onClickWord: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, onWordPositionChanged: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, ) { val wordPositions = remember { mutableMapOf() } FlowRow(...) { words.forEach { entity -> Word( // ... onGloballyPositioned = { wordPositions[entity.wordId] = it onWordPositionChanged(entity, it) }, onClick = { wordPositions[entity.wordId]?.let { layoutCoordinates -> onClickWord(it, layoutCoordinates) } }, ) } 単語をタップしたときに単語の位置を取得する

Slide 74

Slide 74 text

@Composable fun WordOrderChoice( words: List = sampleWords, selectedWords: List = emptyList() ) { Column(...) { Box { Words(words = words, showWord = false) Words(words = selectedWords) } Words(words = words, showWord = true) } } 選択肢の単語一覧にタップイベントを実装する

Slide 75

Slide 75 text

Words( // ... onClickWord = { entity, layoutCoordinates -> if ( drawWords.containsKey(entity.wordId) || selectedWords.any { it.wordId == entity.wordId } ) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 76

Slide 76 text

Words( // ... onClickWord = { entity, layoutCoordinates -> if ( drawWords.containsKey(entity.wordId) || selectedWords.any { it.wordId == entity.wordId } ) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 77

Slide 77 text

Words( // ... onClickWord = { entity, layoutCoordinates -> if ( drawWords.containsKey(entity.wordId) || selectedWords.any { it.wordId == entity.wordId } ) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 78

Slide 78 text

Words( // ... onClickWord = { entity, layoutCoordinates -> if ( drawWords.containsKey(entity.wordId) || selectedWords.any { it.wordId == entity.wordId } ) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 79

Slide 79 text

Words( words = words, showWords = true, onClickWord = { entity, layoutCoordinates -> // ... onClickChoicesWord( // タップされた単語 entity.copy(visibility = WordVisibility.InvisibleFilled), // 移動先の単語 entity.copy(visibility = WordVisibility.InvisibleTransparent), ) drawWords[entity.wordId] = drawWord } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 80

Slide 80 text

Words( words = words, showWords = true, onClickWord = { entity, layoutCoordinates -> // ... onClickChoicesWord( // タップされた単語 entity.copy(visibility = WordVisibility.InvisibleFilled), // 移動先の単語 entity.copy(visibility = WordVisibility.InvisibleTransparent), ) drawWords[entity.wordId] = drawWord } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 81

Slide 81 text

Words( words = words, showWords = true, onClickWord = { entity, layoutCoordinates -> // ... onClickChoicesWord( // タップされた単語 entity.copy(visibility = WordVisibility.InvisibleFilled), // 移動先の単語 entity.copy(visibility = WordVisibility.InvisibleTransparent), ) drawWords[entity.wordId] = drawWord } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 82

Slide 82 text

Words( words = words, showWords = true, onClickWord = { entity, layoutCoordinates -> // ... onClickChoicesWord( // タップされた単語 entity.copy(visibility = WordVisibility.InvisibleFilled), // 移動先の単語 entity.copy(visibility = WordVisibility.InvisibleTransparent), ) drawWords[entity.wordId] = drawWord } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 83

Slide 83 text

@Composable fun WordOrderChoice( words: List = sampleWords, selectedWords: List = emptyList() ) { Column(...) { Box { Words(words = words, showWord = false) Words(words = selectedWords) } Words(words = words, showWord = true) } } 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 84

Slide 84 text

Words( words = selectedWords, onWordPositionChanged = { entity, layoutCoordinates -> val targetWords = drawWords.filter { (_, drawWord) -> drawWord.isInAnimation.not() && drawWord.word.wordId == entity.wordId } if (targetWords.isEmpty()) return@Words // ... } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 85

Slide 85 text

Words( words = selectedWords, onWordPositionChanged = { entity, layoutCoordinates -> val targetWords = drawWords.filter { (_, drawWord) -> drawWord.isInAnimation.not() && drawWord.word.wordId == entity.wordId } if (targetWords.isEmpty()) return@Words // ... } ) 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 86

Slide 86 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 87

Slide 87 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 88

Slide 88 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 89

Slide 89 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 90

Slide 90 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 91

Slide 91 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 92

Slide 92 text

onWordPositionChanged = { entity, layoutCoordinates -> // ... coroutineScope.launch { val animationWords = targetWords.map { (_, drawWord) -> async { drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, initialValue = drawWord.originalTopLeft, targetValue = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } animationWords.awaitAll() 選択肢の単語を選択済に移動するアニメーションを実装する

Slide 93

Slide 93 text

単語選択時に アニメーションする

Slide 94

Slide 94 text

選択された単語を選択肢の単語一覧に アニメーションする

Slide 95

Slide 95 text

@Composable private fun Words( // ... onWordPositionChanged: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, onWordPositionsChanged: (Map) -> Unit = {}, ) { val wordPositions = remember { mutableMapOf() } FlowRow(...) { words.forEach { entity -> Word( word = entity, showWord = showWords, onGloballyPositioned = { wordPositions[entity.wordId] = it onWordPositionChanged(entity, it) onWordPositionsChanged(wordPositions) }, // ... 選択肢の単語の位置を取得できるようにする

Slide 96

Slide 96 text

@Composable private fun Words( // ... onWordPositionChanged: (WordEntity, LayoutCoordinates) -> Unit = { _, _ -> }, onWordPositionsChanged: (Map) -> Unit = {}, ) { val wordPositions = remember { mutableMapOf() } FlowRow(...) { words.forEach { entity -> Word( word = entity, showWord = showWords, onGloballyPositioned = { wordPositions[entity.wordId] = it onWordPositionChanged(entity, it) onWordPositionsChanged(wordPositions) }, // ... 選択肢の単語の位置を取得できるようにする

Slide 97

Slide 97 text

val choicesWordPositions = remember { mutableStateMapOf() } Words( // ... onWordPositionsChanged = { choicesWordPositions.clear() choicesWordPositions.putAll(it) } ) 選択肢の単語の位置を取得できるようにする

Slide 98

Slide 98 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> if (drawWords.containsKey(entity.wordId)) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) onClickSelectedWord(entity.copy(visibility = WordVisibility.InvisibleTransparent)) drawWords[entity.wordId] = drawWord // ... } ) 選択肢の単語の位置を取得できるようにする

Slide 99

Slide 99 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> if (drawWords.containsKey(entity.wordId)) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) onClickSelectedWord(entity.copy(visibility = WordVisibility.InvisibleTransparent)) drawWords[entity.wordId] = drawWord // ... } ) 選択肢の単語の位置を取得できるようにする

Slide 100

Slide 100 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> if (drawWords.containsKey(entity.wordId)) return@Words val drawWordOffset = parentLayoutCoordinates ?.localPositionOf(layoutCoordinates, Offset.Zero) ?: return@Words val drawWord = DrawWord( word = entity, topLeft = drawWordOffset, originalTopLeft = drawWordOffset, size = layoutCoordinates.size.toSize(), textLayoutResult = if (entity.word.isEmpty()) { null } else { // 単語が押されたとき、押された単語のテキストを測定し、その情報を保持する textMeasurer.measure(AnnotatedString(entity.word), drawWordTextStyle) } ) onClickSelectedWord(entity.copy(visibility = WordVisibility.InvisibleTransparent)) drawWords[entity.wordId] = drawWord // ... } ) 選択肢の単語の位置を取得できるようにする

Slide 101

Slide 101 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> // ... coroutineScope.launch { val targetLayoutCoordinates = choicesWordPositions[entity.wordId] ?: return@launch drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, drawWord.originalTopLeft, parentLayoutCoordinates?.localPositionOf(targetLayoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedWordAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } ) 選択肢の単語の位置を取得できるようにする

Slide 102

Slide 102 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> // ... coroutineScope.launch { val targetLayoutCoordinates = choicesWordPositions[entity.wordId] ?: return@launch drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, drawWord.originalTopLeft, parentLayoutCoordinates?.localPositionOf(targetLayoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedWordAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } ) 選択肢の単語の位置を取得できるようにする

Slide 103

Slide 103 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> // ... coroutineScope.launch { val targetLayoutCoordinates = choicesWordPositions[entity.wordId] ?: return@launch drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, drawWord.originalTopLeft, parentLayoutCoordinates?.localPositionOf(targetLayoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedWordAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } ) 選択肢の単語の位置を取得できるようにする

Slide 104

Slide 104 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> // ... coroutineScope.launch { val targetLayoutCoordinates = choicesWordPositions[entity.wordId] ?: return@launch drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, drawWord.originalTopLeft, parentLayoutCoordinates?.localPositionOf(targetLayoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedWordAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } ) 選択肢の単語の位置を取得できるようにする

Slide 105

Slide 105 text

Words( words = selectedWords, onClickWord = { entity, layoutCoordinates -> // ... coroutineScope.launch { val targetLayoutCoordinates = choicesWordPositions[entity.wordId] ?: return@launch drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(isInAnimation = true) } animate( Offset.VectorConverter, drawWord.originalTopLeft, parentLayoutCoordinates?.localPositionOf(targetLayoutCoordinates, Offset.Zero) ?: Offset.Zero, ) { value, _ -> drawWords[entity.wordId]?.let { drawWords[entity.wordId] = it.copy(topLeft = value) } } onSelectedWordAnimationFinished(entity.copy(visibility = WordVisibility.Visible)) drawWords.remove(entity.wordId) } } ) 選択肢の単語の位置を取得できるようにする

Slide 106

Slide 106 text

選択された単語を 選択肢の単語一覧に アニメーションする

Slide 107

Slide 107 text

実際の機能に 組み込む 以下全てに対応できた 🎉 ● 単語の改行 ● 6単語以上の表示 ● 一単語ずつ戻す

Slide 108

Slide 108 text

単語のドラッグ&ド ロップ対応 今後リリース予定です 機会があればどこかで話をするか もしれません

Slide 109

Slide 109 text

We Are Hiring ! いつでもお気軽にご連絡ください

Slide 110

Slide 110 text

Composableの枠を超えて アニメーションする shibuya.apk #46 2024/01/19 (Fri.) Taichi Sato / @syarihu Giftmall, Inc. / mikan Co., Ltd. Android Engineer