ConstraintLayout に新しく追加された Carousel を使ってみた

- 2020/12/18  
[ConstraintLayout 2.1.0-alpha2](https://androidstudio.googleblog.com/2020/12/constraintlayout-210-alpha-2.html) で追加された無限ループについて更新

[ConstraintLayout 2.1.0-alpha1](https://androidstudio.googleblog.com/2020/11/constraintlayout-210-alpha-1.html) に Carousel というものが導入されたので、何ができるのかとどのように使うのかを書いていきます。
(Carousel は alpha1 で入ったばかりの仕組みなので、大きく変更されることがあり得ます)

## Carousel の仕組み
過去に似たような実装をしたときに書いていますが、ベースのなる状態と左右に動かした状態の 3 パターンを用意しておき、アニメーションでスワイプの動きをして中身を動かしているように見せるという方法になっています。

https://qiita.com/Nabe1216/items/0ca12aa377fc7687c9c0

## 基本的な実装
仕組みでもあるとおり、ベースとなる状態と左右に動かした時の 3 パターンの状態を定義し、それらをアニメーションさせる感じになります。

まずは MotionLayout で使う View の定義をします。

<details><summary>コード</summary><div>

```xml
<androidx.constraintlayout.motion.widget.MotionLayout
        android:id="@+id/motionLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutDescription="@xml/carousel_scene">

        <TextView
            android:id="@+id/textView0"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginEnd="16dp"
            android:text="textView0"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/textView1"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/textView1"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginEnd="16dp"
            android:text="textView1"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/textView2"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/textView2"
            android:layout_width="150dp"
            android:layout_height="150dp"
            android:text="textView2"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/textView3"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginStart="16dp"
            android:text="textView3"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toEndOf="@+id/textView2"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/textView4"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginStart="16dp"
            android:text="textView4"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toEndOf="@+id/textView3"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_begin="100dp" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_end="100dp" />

    </androidx.constraintlayout.motion.widget.MotionLayout>
```

</div></details>

今回は 4 つの TextView を並べています。
4 つの理由は今回のレイアウトの場合は 4 つまで Carousel のアイテムが一度に表示されるためです。


次に MotionScene の定義をします。

<details><summary>コード</summary><div>

```xml
<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        android:id="@+id/forward"
        motion:constraintSetEnd="@+id/next"
        motion:constraintSetStart="@id/base_state">
        <OnSwipe
            motion:dragDirection="dragLeft"
            motion:touchAnchorSide="left" />
    </Transition>

    <Transition
        android:id="@+id/backward"
        motion:constraintSetEnd="@+id/previous"
        motion:constraintSetStart="@+id/base_state">
        <OnSwipe
            motion:dragDirection="dragRight"
            motion:touchAnchorSide="right" />
    </Transition>

    <!-- ベースとなるViewの状態を定義する -->
    <ConstraintSet android:id="@+id/base_state">
        <Constraint
            android:id="@+id/textView1"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginEnd="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toStartOf="@id/guideline"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView2"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:layout_marginRight="16dp"
            android:layout_marginBottom="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintDimensionRatio="w,1:1"
            motion:layout_constraintEnd_toEndOf="@id/guideline2"
            motion:layout_constraintHorizontal_bias="0.5"
            motion:layout_constraintStart_toStartOf="@id/guideline"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView3"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginStart="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toEndOf="@id/guideline2"
            motion:layout_constraintTop_toTopOf="parent" />
    </ConstraintSet>

    <!-- 左へスワイプした時のViewの状態を定義する -->
    <ConstraintSet android:id="@+id/next">
        <Constraint
            android:id="@+id/textView2"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginEnd="16dp"
            android:layout_marginRight="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toStartOf="@id/guideline"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView3"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintDimensionRatio="1:1"
            motion:layout_constraintEnd_toStartOf="@id/guideline2"
            motion:layout_constraintStart_toEndOf="@id/guideline"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView4"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toEndOf="@id/guideline2"
            motion:layout_constraintTop_toTopOf="parent" />
    </ConstraintSet>

    <!-- 右へスワイプした時のViewの状態を定義する -->
    <ConstraintSet android:id="@+id/previous">
        <Constraint
            android:id="@+id/textView2"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toStartOf="@id/guideline2"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView3"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginStart="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toEndOf="@+id/textView2"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView1"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintDimensionRatio="1:1"
            motion:layout_constraintEnd_toStartOf="@id/guideline2"
            motion:layout_constraintStart_toEndOf="@id/guideline"
            motion:layout_constraintTop_toTopOf="parent" />
        <Constraint
            android:id="@+id/textView0"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_marginEnd="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toStartOf="@id/guideline"
            motion:layout_constraintTop_toTopOf="parent" />
    </ConstraintSet>
</MotionScene>
```
</div></details>

少し長いですが、3 パターンの View の状態を定義して、それらをアニメーションできるように `Transition` を設定しています。

ここまでが準備で、ようやく本題の `Carousel` の実装になります。
`MotionLayout` のなかに以下を追加します。

```xml
<androidx.constraintlayout.helper.widget.Carousel
    android:id="@+id/carousel"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:carousel_backwardTransition="@+id/backward"
    app:carousel_forwardTransition="@+id/forward"
    app:carousel_nextState="@+id/next"
    app:carousel_previousState="@+id/previous"
    app:carousel_firstView="@+id/textView2"
    app:constraint_referenced_ids="textView0,textView1,textView2,textView3,textView4" />
```

ここでは `Carousel` で使用するパラメータの設定をしています。

- `carousel_backwardTransition` / `carousel_forwardTransition`  
`MotionScene` で設定した `Transition` を設定し、それを使ってアニメーションさせます。
- `carousel_nextState` / `carousel_previousState`  
`MotionScene` で設定した `ConstraintSet` を設定します。
- `carousel_firstView`　  
`Carousel` で真ん中に表示される View を指定します。

  
`Carousel` の定義が終わりましたが、これだけではなくコードでの設定も必要になります。

```kotlin
val carousel = findViewById<Carousel>(R.id.carousel)
carousel.setAdapter(object : Carousel.Adapter {
    override fun count(): Int = list.size

    override fun populate(view: View, index: Int) {
        if (view !is TextView) return
        val item = list[index]
        view.text = item.text
    }

    override fun onNewItem(index: Int) {
    }
})
```

`Carousel.Adapter` というものがあり、これを使って Carousel の表示処理を行います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126298/f4f944c0-6d92-5916-d3bb-db0add5e0778.gif" width="300px" />



## Carousel のアイテムを追加・削除する

```kotlin
val carousel = findViewById<Carousel>(R.id.carousel)

mutableList.add(Hoge())
carousel.refresh()

mutableList.clear()
carousel.refresh()
```

`Carousel.Adapter` で使用しているリストを操作した後に `Carousel#refresh` を呼ぶと Carousel が更新されます。
また、
`app:carousel_emptyViewsBehavior="invisible/gone"`
を `Carousel` に定義することでアイテムが空の時の `Carousel` の表示状態を指定することができます。

## 無限 Carousel の実装(12/18 追記)
[ConstraintLayout 2.1.0-alpha2](https://androidstudio.googleblog.com/2020/12/constraintlayout-210-alpha-2.html) で Carousel に無限ループのオプションが追加されました。

Carousel の helper に `app:carousel_infinite="true"` を追加するだけで無限ループが実現できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126298/4acfa836-756d-334f-2687-4daeb588cbe1.gif" width="300px" />

<br /><br /> 
  
  
Carousel の仕組み全体を提供するという感じではなく、 シンプルに `MotionLayout` を使っただけではできなかった Carousel とモデルのマッピングや、細かな表示制御のロジックを提供してくれている感じでした。

## リンク
- [公式のサンプルプロジェクト](https://github.com/androidx/constraintlayout/tree/main/projects/CarouselExperiments)
- [ConstraintLayout 2.1+ : what's next](https://www.droidcon.com/media-detail?video=470593168)