Composableのカスタムレイアウト

このページではComposeの1.0.0-beta08バージョンを基としたアニメーション周りの感想をまとめたものです。参考にした動画はこちらですhttps://youtu.be/DDd6IOlH3io

beta08での情報なので、随時変更される可能性はございます。

## `@Composable fun Layout()`でカスタムレイアウトを作成！

**ComposeではLayoutブロックを使要することでカスタムレイアウトを作成することができます。以下動画内の一部を抜粋したサンプルで説明します。**

![constraint.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646120/95146f24-af6e-c543-c815-6221837aa1fa.jpeg)
(※画像でのminは140dpですが、この記事を書いている時点では134dpに変更されています)

サンプルコード。
https://github.com/android/compose-samples/blob/01d35510a3d5d72099ffbb2c6e25ef4782291d3d/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt#L97

Composableレイアウトを作る関数の宣言は以下の通りです。Modifierとcontentを引数にとり、メインブロックにmeasureBlockがあります。

```kotlin
fun Layout(
    modifier: Modifier = Modifier,    
    content:@Composable () -> Unit,
){
    measureBlock: MeasureBlock
}
```
メジャーブロックは3つのステップで作ることができます。
1. それぞれのアイテムのmeasureを呼び出す。
2. layout()を呼ぶ。
3. それぞれのアイテムを置く(placeを呼び出す)。

コードの外観

```kotlin
Layout(
  modifier = ...,
  children = {
      Text(
          text = category.name,
          ...
       )
       SnackImage(
          ...
       )
  }
) { measurables: List<Measurable>, 
    constraints: Constraints->
  // 1. それぞれのアイテムのmeasureを呼び出す。
  // 2. layout()を呼ぶ。
  // 3. それぞれのアイテムを置く(placeを呼び出す)。
}
```

## MeasureBlockの引数について

#### 引数で渡ってくる `constraints: Constraints` とはなにか？
AndroidエンジニアにはおなじみのConstraintLayoutとは何も関係なく、maxやminのWidthやHeightが取得できるだけで、このViewの大きさの上限と下限がわかるだけのオブジェクトです。

#### 引数で渡ってくる `measurables: List<Measurable>` とはなにか？
このリストの一つ一つの要素がUIのコンポーネントのmeasureを呼べるmeasurableになっている。
例えばここでmeasurables[0]は`Text`に対応して、measurables[1]は`SnackImage`に対応する。
このMeasurableに対して `Measurable#measure()`を呼び出すと `Placeable` を返す。

## 一番シンプルな例

一番左上にすべての物を置く例がこちらになります。

以下の流れを行っています。

1. それぞれのアイテムのmeasureを呼び出す。
2. layout()を呼ぶ。
3. それぞれのアイテムを置く(placeを呼び出す)。

```kotlin
Layout(
  modifier = ...,
  content = {
      Text(
          text = category.name,
          ...
       )
       SnackImage(
          ...
       )
  }
) { measurables: List<Measurable>, 
    constraints: Constraints->
    // 1. それぞれのアイテムのmeasureを呼び出す。
    val placeables = measurables.map {
        it.measure(constraints)
    }
    // 2. layout()を呼ぶ。
    layout(
            width = constraints.maxWidth,
            height = constraints.maxHeight
    ) {
        placeables.forEach {
            // 3. それぞれのアイテムを置く(placeを呼び出す)。
            it.place(
                    x = 0,
                    y = 0
            )
        }
    }
}
```

placeable.place()関数は、うまくKotlin DSLの機能を使っておりlayout()のブロックの中でしか呼べなくなっています。

## Jetpack Composeのサンプルではどうやっているか？

以下のように実際に行っているようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/c88915ad-4310-7d22-3a9d-725d51db1fde.png)

https://github.com/android/compose-samples/blob/34a75fb3672622a3fb0e6a78adc88bbc2886c28f/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt#L102 より

コードの中にコメントとして解説を入れてみました。

```kotlin
    Layout(
        modifier = modifier
            // レイアウトのアスペクト比を設定
            .aspectRatio(1.45f)
            .shadow(elevation = 3.dp, shape = CategoryShape)
            // 丸みを帯びた長方形にカット
            .clip(CategoryShape)
            .background(Brush.horizontalGradient(gradient))
            .clickable { /* todo */ },
        content = {
            Text(
                text = category.name,
                style = MaterialTheme.typography.subtitle1,
                color = JetsnackTheme.colors.textSecondary,
                modifier = Modifier
                    .padding(4.dp)
                    .padding(start = 8.dp)
            )
            SnackImage(
                imageUrl = category.imageUrl,
                contentDescription = null,
                modifier = Modifier.fillMaxSize()
            )
        }
    ) { measurables, constraints ->
        // テキストの横幅の比率設定(５５％)
        val textWidth = (constraints.maxWidth * CategoryTextProportion).toInt()
        val textPlaceable = measurables[0].measure(Constraints.fixedWidth(textWidth))

        // 画像が大きい場合は原寸大を試用し、小さい場合はConstraintの最大サイズに合わせて拡大
        val imageSize = max(MinImageSize.roundToPx(), constraints.maxHeight)
        // 縦横の大きさを固定して、Constraintに渡しmeasure()する
        val imagePlaceable = measurables[1].measure(Constraints.fixed(imageSize, imageSize))
        layout(
            width = constraints.maxWidth,
            height = constraints.minHeight
        ) {
            //テキストの左寄せ
            textPlaceable.placeRelative(
                x = 0,
                y = (constraints.maxHeight - textPlaceable.height) / 2 // 縦方向の中央配置
            )
            //はみ出した画像はクリップされる
            imagePlaceable.placeRelative(
                //テキストの最後尾に画像を配置
                x = textWidth,
                y = (constraints.maxHeight - imagePlaceable.height) / 2 // 縦方向の中央配置
            )
        }
    }
```

## スクロールと連動のアニメーション処理

![scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646120/471604e9-e2b2-8b7d-bf79-b7ed0cdc9cc3.gif)

Layout()でスクロールに連動した伸縮のエフェクトも実現できます。
以下のコードは主に、画像の伸縮の処理を行っています。

```kotlin
@Composable
private fun CollapsingImageLayout(
    collapseFraction: Float,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        check(measurables.size == 1)

        val imageMaxSize = min(ExpandedImageSize.roundToPx(), constraints.maxWidth)
        val imageMinSize = max(CollapsedImageSize.roundToPx(), constraints.minWidth)
        val imageWidth = lerp(imageMaxSize, imageMinSize, collapseFraction)
        // measureする
        val imagePlaceable = measurables[0].measure(Constraints.fixed(imageWidth, imageWidth))

        // lerpは２点A,B間の間のベクトルを近似的関数で求めてくれるFunctionです
        val imageY = lerp(MinTitleOffset, MinImageOffset, collapseFraction).roundToPx()
        val imageX = lerp(
            (constraints.maxWidth - imageWidth) / 2, // centered when expanded
            constraints.maxWidth - imageWidth, // right aligned when collapsed
            collapseFraction
        )
        // layoutのセット
        layout(
            width = constraints.maxWidth,
            height = imageY + imageWidth
        ) {
            imagePlaceable.placeRelative(imageX, imageY)
        }
    }
}
```

## スクロールとの連動方法

ScrollStateを作成し、スクロールの変化量を常に記録します。スクロールの情報を、それぞれBody()やImage()などのComposable関数に渡すことで、全体がスクロールに連動するようになります。

```kotlin
@Composable
fun SnackDetail(
...
) {
...
    Stack(Modifier.fillMaxSize()) {
        val scroll = rememberScrollState(0f) // ← ここでScrollStateを記録
　　　　　...
        Body(related, scroll)
　　　　　...
        Image(snack.imageUrl, scroll.value)　
　　　　　...
    }

```

## 他にも様々な実装がサンプルで用意されています

他にもJetsnackサンプル内で様々な動きが実装されていますので、興味のある方は以下のリンクから是非参考にしてみてください！
https://github.com/android/compose-samples/tree/master/Jetsnack#features