はじめに
こんにちは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を定義します。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardContent(
pages: Int
) {
val pagerState = rememberPagerState { pages }
}
そして、HorizontalPagerのstateに先ほど定義したpagerStateをセットします。
そうすることでページの状態を制御してくれます。
また、コンテンツの下にドットインジケータを表示したいので、全体をColumnで囲み、HorizontalPagerの下に配置する形で実装しています。
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はお好きなようにカスタマイズしてください。
@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) のコンテンツを表示
@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は裏面の内容を定義
@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
)
}
}
}
以上でY軸周りに180度回転するアニメーションが作成できました!
最終的なコード
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CarouselScreen()
}
}
}
@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")
}
}
}
}
@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を作ってみてください。