1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-20

はじめに

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

What's Carousel?

Carouselとは何かについては、Carousel – Material Design 3 を参照するのが手っ取り早いです。

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

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

ディレクトリ構成

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を作ってみてください。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?