動きのあるUIをComposeで実現：美しいAndroid Carouselの作り方

## はじめに
こんにちはcoccoです。
この記事はCarouselレイアウトをComposeで実装してみたよの会です。
何かの参考になれば幸いです。

## What's Carousel?

Carouselとは何かについては、[Carousel – Material Design 3](https://m3.material.io/components/carousel/overview) を参照するのが手っ取り早いです。

今回実装するレイアウトは下記のような基本的なCarouselレイアウトとなります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624643/09622479-a174-2374-8140-a1db67447940.gif" width="360px">

全体的なコードは最後に載せているので、説明不要な方はそちらをご覧ください。

## ディレクトリ構成

```
carouselapp
├─ ui
|  └─ CardContent
|  └─ CarouselScreen
├─ MainActivity
```

## Carouselの実装

今回Carouselの実装に使用したのは、HorizontalPagerとElevatedCardになります。
また現在どのpegeにいるかを管理する必要があるため、rememberPagerStateを使用してState管理します。

今回はInt型でpagesを受け取り、その数だけCardを表示するようにしてみます。
まずは、rememberPagerStateを使用してpagerStateを定義します。

```CardContent.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardContent(
    pages: Int
) {
    val pagerState = rememberPagerState { pages }
}
```

そして、HorizontalPagerのstateに先ほど定義したpagerStateをセットします。
そうすることでページの状態を制御してくれます。
また、コンテンツの下にドットインジケータを表示したいので、全体をColumnで囲み、HorizontalPagerの下に配置する形で実装しています。
```CardContent.kt
Box(
    modifier = Modifier
        .fillMaxSize()
) {
    Column(modifier = Modifier.fillMaxSize()) {
        Box(
            modifier = Modifier
                .weight(1f)
                .fillMaxSize()
        ) {
            HorizontalPager(
                state = pagerState,
                contentPadding = PaddingValues(32.dp),
                pageSpacing = 16.dp,
                modifier = Modifier.fillMaxSize()
            ) { page ->
                /*TODO: コンテンツが入ります*/
            }
        }
        Row(
            Modifier
                .fillMaxWidth()
                .weight(0.1f),
            horizontalArrangement = Arrangement.Center
        ) {
            repeat(pages) { iteration ->
                val color =
                    if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray
                Box(
                    modifier = Modifier
                        .padding(6.dp)
                        .clip(CircleShape)
                        .background(color)
                        .size(8.dp)
                )
            }
        }
    }
}
```

最後に/*TODO*/の部分である、HorizontalPagerの要素となるCardContentsを作成したら終了です。
ElevatedCardを表示するだけで、特に説明することはないです。
中に配置するContentsはお好きなようにカスタマイズしてください。

```CardContents.kt
@Composable
fun CardContents(pageId: Int) {
    val userId = pageId + 1

    Box(
        modifier = Modifier
            .fillMaxSize()
    ) {
        ElevatedCard(
            colors = CardDefaults.cardColors(
                containerColor = Color.LightGray,
            ),
            elevation = CardDefaults.cardElevation(
                defaultElevation = 16.dp
            ),
            modifier = Modifier.fillMaxSize()
        ) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.fillMaxSize()
            ) {
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier
                        .weight(1f)
                        .fillMaxSize()
                ) {
                    Text(fontSize = 24.sp, text = userId.toString() + "人目")
                }
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier
                        .fillMaxSize()
                        .weight(2f)
                ) {
                    Text(text = "Contents")
                }
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier
                        .weight(1f)
                        .fillMaxSize()
                ) {
                    Text(fontSize = 24.sp, text = pageId.toString())
                }
            }
        }
    }
}
```

ここまでが基本的なCarouselレイアウトの作成方法となります。

## アニメーションの追加

ここからは各カードをタップした際に、カードを回転させるアニメーションを作成してみます。
手順は下記の通りです。

### FlippableCard コンポーネントの作成
• 各ページのカードをFlippableCardとして分離
• isFlipped状態を管理し、タップ時に反転
• isAnimating フラグを追加し、アニメーションが実行中かどうかを追跡 (連続タップを防ぐため)
• Animatableを使用して、rotation値をアニメーション
• graphicsLayerを使用して、Y軸周りにカードを回転
• scaleXを使用して、カードが180度を超えるときに反転表示
• 回転角度に基づいて、表 (FrontCardContent) または裏 (BackCardContent) のコンテンツを表示

 ```FlippableCard.kt
 @Composable
fun FlippableCard(pageId: Int) {
    var isFlipped by remember { mutableStateOf(false) }
    var isAnimating by remember { mutableStateOf(false) }

    val rotation = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    // 表面か裏面かを判定
    val isFront = rotation.value <= 90f

    ElevatedCard(
        colors = CardDefaults.cardColors(
            containerColor = Color.LightGray,
        ),
        elevation = CardDefaults.cardElevation(
            defaultElevation = 16.dp
        ),
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = !isAnimating) {
                if (!isAnimating) {
                    isFlipped = !isFlipped
                    isAnimating = true
                    scope.launch {
                        rotation.animateTo(
                            targetValue = if (isFlipped) 180f else 0f,
                            animationSpec = tween(
                                durationMillis = 650,
                            )
                        )
                        isAnimating = false
                    }
                }
            }
            .graphicsLayer {
                rotationY = rotation.value
                cameraDistance = 12f * density
                scaleX = if (rotation.value in 90f..270f) -1f else 1f
            }
    ) {
        if (isFront) {
            /*TODO: FrontCardContent*/
        } else {
            /*TODO: BackCardContent*/
        }
    }
}
 ```

### 表と裏のコンテンツ
• FrontCardContentは元のカードの内容を保持
• BackCardContentは裏面の内容を定義

 ```FrontCardContent.kt
 @Composable
fun FrontCardContent(pageId: Int) {
    val userId = pageId + 1

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxSize()
            .background(Color.LightGray)
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .weight(1f)
                .fillMaxSize()
        ) {
            Text(fontSize = 24.sp, text = "$userId 人目")
        }
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .fillMaxSize()
                .weight(2f)
        ) {
            Text(text = "Contents")
        }
        Box(
            contentAlignment = Alignment.BottomEnd,
            modifier = Modifier
                .weight(1f)
                .padding(16.dp)
                .fillMaxSize()
        ) {
            Icon(
                imageVector = Icons.Default.TouchApp,
                contentDescription = "タップ",
                modifier = Modifier.size(36.dp)
            )
        }
    }
}
 ```

 
 ```BackCardContent.kt
 @Composable
fun BackCardContent() {
    // カードの裏面のコンテンツをここに定義します
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxSize()
            .background(Color.DarkGray)
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .weight(1f)
                .fillMaxSize()
        ) {
            Text(fontSize = 24.sp, text = "裏面", color = Color.White)
        }
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .fillMaxSize()
                .weight(2f)
        ) {
            Text(text = "More Details", color = Color.White)
        }
        Box(
            contentAlignment = Alignment.BottomEnd,
            modifier = Modifier
                .weight(1f)
                .padding(16.dp)
                .fillMaxSize()
        ) {
            Icon(
                imageVector = Icons.Default.TouchApp,
                contentDescription = "タップ",
                modifier = Modifier.size(36.dp),
                tint = Color.White
            )
        }
    }
}
 ```

 以上でY軸周りに180度回転するアニメーションが作成できました！
 
 
## 最終的なコード

```MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CarouselScreen()
        }
    }
}
```

```CarouselScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CarouselScreen() {

    Scaffold(
        topBar = {
            TopAppBar(
                colors = TopAppBarDefaults.smallTopAppBarColors(
                    containerColor = Color.LightGray
                ),
                title = {
                    Text("Carousel App")
                },
                actions = {
                    IconButton(onClick = { /*TODO*/ }) {
                        Icon(
                            imageVector = Icons.Default.MoreVert,
                            contentDescription = null
                        )
                    }
                }
            )
        }){innerPadding ->
        Column(
            Modifier.padding(innerPadding)
        ) {
            Box(
                contentAlignment = Alignment.BottomCenter,
                modifier = Modifier
                    .weight(0.5f)
                    .padding(
                        start = 32.dp,
                        end = 32.dp,
                        top = 16.dp,
                        bottom = 16.dp
                    )
            ) {
                Text(fontSize = 24.sp, text = "Contents")
            }
            Box(
                modifier = Modifier
                    .weight(3f)
            ) {
                CardContent(5)
            }
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier
                    .weight(0.5f)
                    .padding(
                        start = 32.dp,
                        end = 32.dp,
                        top = 16.dp,
                        bottom = 16.dp
                    )
            ) {
                Text(fontSize = 16.sp, text = "Contents")
            }
        }
    }
}
```

```CardContent.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardContent(
    pages: Int
) {
    val pagerState = rememberPagerState { pages }

    Box(
        modifier = Modifier
            .fillMaxSize()
    ) {
        Column(modifier = Modifier.fillMaxSize()) {
            Box(
                modifier = Modifier
                    .weight(1f)
                    .fillMaxSize()
            ) {
                HorizontalPager(
                    state = pagerState,
                    contentPadding = PaddingValues(32.dp),
                    pageSpacing = 16.dp,
                    modifier = Modifier.fillMaxSize()
                ) { page ->
                    FlippableCard(page)
                }
            }
            Row(
                Modifier
                    .fillMaxWidth()
                    .weight(0.1f),
                horizontalArrangement = Arrangement.Center
            ) {
                repeat(pages) { iteration ->
                    val color =
                        if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray
                    Box(
                        modifier = Modifier
                            .padding(6.dp)
                            .clip(CircleShape)
                            .background(color)
                            .size(8.dp)
                    )
                }
            }
        }
    }
}

@Composable
fun FlippableCard(pageId: Int) {
    var isFlipped by remember { mutableStateOf(false) }
    var isAnimating by remember { mutableStateOf(false) }

    val rotation = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    // 表面か裏面かを判定
    val isFront = rotation.value <= 90f

    ElevatedCard(
        colors = CardDefaults.cardColors(
            containerColor = Color.LightGray,
        ),
        elevation = CardDefaults.cardElevation(
            defaultElevation = 16.dp
        ),
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = !isAnimating) {
                if (!isAnimating) {
                    isFlipped = !isFlipped
                    isAnimating = true
                    scope.launch {
                        rotation.animateTo(
                            targetValue = if (isFlipped) 180f else 0f,
                            animationSpec = tween(
                                durationMillis = 650,
                            )
                        )
                        isAnimating = false
                    }
                }
            }
            .graphicsLayer {
                rotationY = rotation.value
                cameraDistance = 12f * density
                // Flip the card when rotation exceeds 90 degrees
                scaleX = if (rotation.value in 90f..270f) -1f else 1f
            }
    ) {
        if (isFront) {
            FrontCardContent(pageId)
        } else {
            BackCardContent()
        }
    }
}

@Composable
fun FrontCardContent(pageId: Int) {
    val userId = pageId + 1

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxSize()
            .background(Color.LightGray)
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .weight(1f)
                .fillMaxSize()
        ) {
            Text(fontSize = 24.sp, text = "$userId 人目")
        }
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .fillMaxSize()
                .weight(2f)
        ) {
            Text(text = "Contents")
        }
        Box(
            contentAlignment = Alignment.BottomEnd,
            modifier = Modifier
                .weight(1f)
                .padding(16.dp)
                .fillMaxSize()
        ) {
            Icon(
                imageVector = Icons.Default.TouchApp,
                contentDescription = "タップ",
                modifier = Modifier.size(36.dp)
            )
        }
    }
}

@Composable
fun BackCardContent() {
    // カードの裏面のコンテンツをここに定義します
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxSize()
            .background(Color.DarkGray)
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .weight(1f)
                .fillMaxSize()
        ) {
            Text(fontSize = 24.sp, text = "裏面", color = Color.White)
        }
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .fillMaxSize()
                .weight(2f)
        ) {
            Text(text = "More Details", color = Color.White)
        }
        Box(
            contentAlignment = Alignment.BottomEnd,
            modifier = Modifier
                .weight(1f)
                .padding(16.dp)
                .fillMaxSize()
        ) {
            Icon(
                imageVector = Icons.Default.TouchApp,
                contentDescription = "タップ",
                modifier = Modifier.size(36.dp),
                tint = Color.White
            )
        }
    }
}
```

## 最後に

今回はComposeで動きのあるCarouselレイアウトを実装してみました。
Carouselはスペースの有効活用や視覚的な魅力とインタラクティブ性に富んだUIコンポーネントだと思います。
世の中のモバイルアプリケーションでも目にする機会が多いのではないかと思います。
今回紹介したレイアウトは基本的なものですので、まだまだカスタマイズできると思います！
ぜひカスタマイズしてより美しいCarouselを作ってみてください。