blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2024.09.20 イベントレポート

Jetpack Compose Crossword Quizの問題紹介&解答解説

by tsutomu hayakawa

#droidkaigi #mobile #android #jetpackcompose #crossword

こんにちは、エンジニアリング室の tsutomu hayakawa です!

2024年9月11日(水)から13日(金)までオフラインで開催されたDroidKaigi 2024にて、DeNA はゴールドスポンサーとして協賛しました。
DeNA はDroidKaigi 2024 の「スポンサーブース」に出展し、ブース企画として「SWETチームの取り組み紹介」「Jetpack Compose Crossword Quiz」を実施しました!

Booth

DeNA Boothの様子

当日はDeNAのブースにたくさんの方々にご参加いただき、昨年に引き続き大盛況となりました。「Jetpack Compose Crossword Quiz」で集めた解答用紙は、なんと307枚にものぼりました!
このブログでは、「Jetpack Compose Crossword Quiz」の問題と解説を行います。

※ 問題文においてimport部分の記載は省略する形で記載しております。
※ 問題文、プレビューの下に回答があります。回答を考えたい方は、スクロールを止めてチャレンジしてみてください。

Question1の問題と解説

Question1の問題、Preview、回答は以下のとおりです。 問題作成者は @shirataki707 です。
イベント期間中の回答数は53、正解率は83%でした。

@Composable
fun Question1() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        BusinessCard()
    }
}

@Composable
fun BusinessCard() {
    Row(
        modifier = Modifier
            .size(width = 320.dp, height = 180.dp)
            .□□□□□□(width = 1.dp, color = Color.Black)  // A-2
    ) {
        Box(
            modifier = Modifier
                .background(color = colorResource(id = R.color.orange))
                .size(width = 160.dp, height = 180.dp),
            contentAlignment = Alignment.Center
        ) {
            Image(
                painter = painterResource(id = R.drawable.dena_logo),
                contentDescription = "DeNA Logo",
                modifier = Modifier.size(256.dp)
            )
        }

        Box(
            modifier = Modifier
                .background(Color.White)
                .size(width = 160.dp, height = 180.dp)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxSize(),
                □□□□□□□□□□Alignment = Alignment.Start   // A-1
            ) {
                Text(
                    text = "ドロイド太郎",
                    modifier = Modifier.padding(start = 16.dp, □□□ = 16.dp) // A-4
                )
                Text(text = "Taro Droid", modifier = Modifier.padding(start = 20.dp))
                □□□□□□(modifier = Modifier.weight(1f))  // A-3
                Text(
                    text = "Android事業本部\nKotlin部",
                    style = □□□□□□□□□(fontSize = 12.sp),    // A-5
                    modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
                )
            }
        }
    }
}

Question1

Question1のPreview

クロスワードと回答

Question1-Crossword

Question1-Crossword

解説

難易度EasyなのでJetpackComposeの基礎的な部分を用いつつ、DeNAらしさがでるように実際の名刺をイメージして作成しました。

キーポイント

Spacerを使った余白やpaddingの方向 (start, top, end, bottom) など、Composableの配置についての問題にしています。

反省点

難易度的にはちょうどよかったと思います。BusinessCardなので、Card Composableを用いればよかったです。

Question2の問題と解説

Question2の問題、Preview、回答は以下のとおりです。 問題作成者は @shirataki707 です。
イベント期間中の回答数は38、正解率は13%でした。

@Composable
fun Question2Page() {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        val imageBrush =
            □□□□□□□□□□□(ImageShader(ImageBitmap.imageResource(id = R.drawable.golden_gate_bridge)))   // A-1

        Box(
            modifier = Modifier
                .clip(□□□□□□□□□□□□□Shape(0.dp))     // A-6
                .size(300.dp)
                .□□□□□□□□□□□□□ {                    // A-2
                    clip = true
                    shape = CircleShape
                    □□□□□□□□□□□□ = 30.dp.toPx()     // A-3
                }
                .background(Color(0xFFF06292))
        ) {
            Text(
                text = "Hello\nDroidKaigi 2024",
                style = TextStyle(
                    □□□□□ = imageBrush,             // A-4
                    fontWeight = FontWeight.ExtraBold,
                    fontSize = 36.sp,
                ),
                modifier = Modifier
                    .align(Alignment.Center)
                    .padding(16.dp)
            )
        }

        Box {
            BalloonCord(offset = Offset(-65f, 0f), degrees = -20f)
            BalloonCord(offset = Offset(-20f, 0f), degrees = -10f)
            BalloonCord(offset = Offset(65f, 0f), degrees = 20f)
            BalloonCord(offset = Offset(20f, 0f), degrees = 10f)
        }

        Box(
            modifier = Modifier
                .width(100.dp)
                .height(70.dp)
                .background(Color(0xFF6A4E42))
        )
    }
}

@Composable
fun BalloonCord(offset: Offset, degrees: Float) {
    Box(
        modifier = Modifier
            .offset(x = offset.x.dp)
            .size(width = 6.dp, height = 120.dp)
            .□□□□□□(degrees = degrees)                  // A-5
            .background(color = Color(0xFFD2B48C))
    )
}

Question2

Question2のPreview

クロスワードと回答

Question2-Crossword

Question2-Crossword

キーポイント キーポイントは2つあります。

  • 図形のクリップ

    • Composeでクリップするには、graphicsLayerを用いる方法と、そのラッパーであるModifier.clipがあります。今回はその両方を使っています。
    • A-6では四角形にクリップしていますが、あえてRectangleShapeではなく、RoundedCornerShapeを使っています(これのせいで問題が複雑になってしまった気がします)。
  • バルーンの風船部分の形状

    • Android公式のグラフィックスに関する ガイド を参考に作成しています。
    • graphicsLayertranslationYを用いることで、Box Composableの境界を変えずに下方向へ円を移動できます。そのため、最初に四角形でクリップすることにより、四角からはみ出た部分が除去されるようになっています。

反省点

画像のBrushclipなど様々な観点を問題に詰め込んだことで予想以上に問題が難しくなりました。また、バルーンの風船部分のレイアウトについては画像とコードを良く見て考える必要があり、normalの問題としては不適切だったと反省しています。

Question3の問題と解説

Question3の問題、Preview、回答は以下のとおりです。 問題作成者は @emusute1212 です。
イベント期間中の回答数は31、正解率は35%でした。

@Composable
fun Question3Page() {
    val progressWidth = 300.dp
    val progressHeight = 20.dp
    val thumbsWidth = 4.dp
    val percentage = 0.6f

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.White),
        contentAlignment = Alignment.Center,
    ) {
        Box(
            modifier = Modifier
                .width(progressWidth)
                .height(progressHeight)
                .background(color = Color.LightGray)
        ) {
            Box(
                modifier = Modifier
                    .□□□□□□□□□□□□(□□□□□□□□ = percentage) // A-6, A-3
                    .fillMaxHeight()
                    .background(color = Color.Cyan)
            )
            Thumbs(
                modifier = Modifier
                    .offset(x = (□□□□□□□□□□□□□ * □□□□□□□□□□) - (□□□□□□□□□□□ / 2)) // A-2, A-4, A-5
                    .□□□□□□□□□□□□□□□(□□□□□□□□□ = true), // A-7, A-1
                width = thumbsWidth,
                height = progressHeight + 4.dp,
            )
        }
    }
}

@Composable
fun Thumbs(
    modifier: Modifier = Modifier,
    width: Dp,
    height: Dp,
) {
    Box(
        modifier = modifier
            .width(width)
            .height(height)
            .blur(2.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
            .background(color = Color.Blue)
    )
}

Question3

Question3のPreview

クロスワードと回答

Question3-Crossword

Question3-Crossword

キーポイント

リッチなプログレスバーを自前で実装した際に今までなかなか使わないようなModifierを使うことが多く、勉強になったので今回問題にしてみました。 fillMaxWidthfractionに割合を指定することで、その割合分のサイズに引き伸ばすことを利用して、プログレスを表現しています。 また、今回のプログレスでは端の青色のバーが親要素のサイズを超えており、それを表現するために、wrapContentSizeunboundedtrueにして、親要素を超えて描画できるようにしました。

よくあった間違い

fillMaxWidthweightと考えるケースが多かった気がしています。 また、wrapContentSizeではなく、requiredHeightで画面外描画を実現するのを考えている方も多かったように見受けられました。

反省点

端の青色のバーがグラデーションしている影響で画面外にはみ出ていることが若干わかりづらいのかなと思いました。このあたりはもう少し大きめにオーバーさせるなど改善の余地はあると考えております。

Question4の問題と解説

Question4の問題、Preview、回答は以下のとおりです。 問題作成者は @emusute1212 です。
イベント期間中の回答数は37、正解率は57%でした。

@Composable
fun Question4() {
    val numbers = listOf(
        listOf(7, 8, 9),
        listOf(4, 5, 6),
        listOf(1, 2, 3),
        listOf(0),
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.White),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            modifier = Modifier
                .width(200.dp),
        ) {
            □□□□□□□.forEach { // A-5
                Row(
                    □□□□□□□□□□Arrangement = Arrangement.□□□□□□□□(20.dp), // A-4, A-2
                ) {
                    it.forEach { number ->
                        Text(
                            text = number.toString(),
                            style = □□□□□□□□□( // A-3
                                fontSize = 80.sp,
                                □□□□□□□□□ = TextAlign.Center, // A-1
                            ),
                            modifier = Modifier.weight(1f),
                        )
                    }
                }
            }
        }
    }
}

Question4

Question4のPreview

クロスワードと回答

Question4-Crossword

Question4-Crossword

キーポイント

数字を格子状に並べるサンプル。二次元配列の使い方やArrangementの使い方がキーとなる問題。numbersとその要素をforEachで回すことで、格子状に配置することを表現していて、要素間のスペースはArrangementspacedByを用いることで実現している。

よくあった間違い

ArrangementspacedByをあまり利用したことがないので間違えてしまった、というケースが良く見受けられました。

反省点

少し簡単に作ってしまったのが反省点として挙げられます。問題として、一つぐらいはもう少しひねりのある問題にしても良かったと思いました。 あとは、文字間のスペースがわかりやすく見えるように、Textの要素にborderを追加した方が良かったのかなと考えています。

Question5の問題と解説

Question5の問題、Preview、回答は以下のとおりです。 問題作成者は @shirataki707 です。
イベント期間中の回答数は35、正解率は88%でした。

@Composable
fun Question5() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.□□□□□□ // A-4
    ) {
        DroidKunFace()
        DroidKunRightEye()
        DroidKunLeftEye()
        DroidKunRightEar()

        // DroidKunLeftEar
        Box(
            modifier = □□□□□□□□ // A-5
                .□□□□□□(x = (-75).dp, y = (-150).dp)    // A-1
                .size(width = 12.dp, □□□□□□ = 60.dp)    // A-3
                .□□□□□□(degrees = -30f)                 // A-6
                .□□□□□□□□□□(color = Color(0xFFA4CA39))  // A-2
        )
    }
}


Question5

Question5のPreview

クロスワードと回答

Question5-Crossword

Question5-Crossword

解説

難易度EasyなのでComposeの基礎的な部分を用いつつ、Androidらしさがでるようにドロイドくんを描きました。問題として見せるコード量を減らすためと、クロスワードの答えが複数箇所に現れるのを防ぐために、DrodiKunFace()のように別で定義したComposableを呼び出しています。

キーポイント

Canvasを使って描画すると難易度が高くなるため、offsetrotateを使って描いています。

反省点

DroidKunLeftEarとしている部分は、画面内の左側にある耳という意味で命名しています。しかし、ドロイドくんにとっては右耳になるので、少し混乱させてしまったかもしれません。

Question6の問題と解説

Question6の問題、Preview、回答は以下のとおりです。 問題作成者は @chnotchy です。
イベント期間中の回答数は46、正解率は56%でした。

@Composable
fun Question6() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp)
    ) {
        Assets() // display `back.png` and `grad.png`
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .□□□□□□□□□□□(1f)  // A-3
                .fillMaxSize()
                .clip(□□□□□□□□□□□)  // A-1
        ) {
            □□□□□(  // A-4
                painter = painterResource(id = R.drawable.□□□□),  // A-5
                contentDescription = "icon",
                modifier = Modifier.fillMaxSize()
            )
            □□□□□(  // A-4
                painter = painterResource(id = R.drawable.□□□□),  // A-2
                contentDescription = "icon",
                modifier = Modifier.fillMaxSize()
            )
        }
    }
}

Question6

Question6のPreview

クロスワードと回答

Question6-Crossword

Question6-Crossword

キーポイント

  • ポイントは以下の3点です。
    • Boxの表示順
    • 円形のclip
    • 縦横比固定

まずは基本的なことですが、Box内のComposableは、下に記述された要素は画面の手前に重なって表示されます。今回は、 back.png は背景に、 grad.png は手前に表示する必要があります。したがって、最初の画像(A-5)が back 、次の画像(A-2)が grad となります。 A-1は、円形に画像を切り取るCircleShapeです。 最後に、あまり使用する機会がないのが、画像の縦横比を固定するためのaspectRatio(A-3)です。引数に 1f が指定されているのがヒントです。これを指定しないと、画像が余白いっぱいに広がり、縦長の画面で表示した場合、画像が縦長になってしまいます。詳細は以下のドキュメントを参照してください。 https://developer.android.com/develop/ui/compose/graphics/images/customize?hl=ja#custom-aspect-ratio

Question7の問題と解説

Question7の問題、Preview、回答は以下のとおりです。 問題作成者は @akkiee76 です。
イベント期間中の回答数は36、正解率は41%でした。

@Composable
fun Question09() {
    var isPresented by remember { mutableStateOf(true) }
    val bottomSheetState = rememberModalBottomSheetState()

    Column(
        modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween
    ) {
        Box(
            modifier = Modifier
                .aspectRatio(1f)
                .fillMaxWidth()
        ) {
            MapView()
        }
        Spacer(modifier = Modifier.weight(1f))
    }

    □□□□□□□□□□□□□□□□( // A-2
        on□□□□□□□Request = { isPresented = false }, // A-6
        modifier = Modifier.fillMaxSize(),
        sheetState = bottomSheetState,
        shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
        □□□□□Color = Color.Transparent // A-4
    ) {
        Column(modifier = Modifier.□□□□□□□(horizontal = 8.dp)) { // A-5
            Text(
                text = "Golden Gate Bridge", fontSize = 24.sp
            )
            Text(
                text = "San Francisco, California",
                fontSize = 16.sp,
                color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
            )
            Divider(modifier = Modifier.padding(vertical = 8.dp))
            Text(
                text = "A suspension bridge spanning the Golden Gate Strait connecting San Francisco Bay on the west coast of the United States with the Pacific Ocean."
            )
            Spacer(modifier = Modifier.height(8.dp))
            □□□( // A-8
                modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center
            ) {
                □□□□□( // A-10
                    painter = painterResource(id = R.□□□□□□□□.golden_gate_bridge), // A-7
                    contentDescription = "Golden Gate Bridge",
                    modifier = Modifier.clip(RoundedCornerShape(8.dp)),
                    contentScale = ContentScale.Fit
                )
            }
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "photo by @mvdheuvel on Unsplash", fontSize = 16.sp
            )
        }
    }
}

@Composable
fun MapView() {
    val goldenGateBridge = LatLng(37.8199, -122.4783)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(goldenGateBridge, 12.5f)
    }
    □□□□□□□□□( // A-3
        modifier = Modifier.fillMaxSize(), cameraPositionState = cameraPositionState
    ) {
        □□□□□□( // A-1
            state = MarkerState(□□□□□□□□ = goldenGateBridge), // A-9
            title = "GoldenGateBridge",
            snippet = "Marker in GoldenGateBridge"
        )
    }
}

Question7

Question7のPreview

クロスワードと回答

Question7-Crossword

Question7-Crossword

解説

GoogleMapを利用したよくある地図アプリ風の問題です。回答部分は基本的なModifierを出題しています。GoogleMapのキーワードはGoogleMapを使ったことがなくても回答しやすい問題となっているので、前後のコードから推測してみてください!

Question8の問題と解説

Question8の問題、Preview、回答は以下のとおりです。 問題作成者は @akkiee76 です。
イベント期間中の回答数は31、正解率は58%でした。

@Composable
fun Question8() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .□□□□□□□□□□(Color(0xFF3DDC84)), // A-3
        □□□□□□□□□□□□□□□□ = Alignment.Center // A-4
    ) {
        Canvas(modifier = Modifier.size(1080f.dp)) {
            drawPath(
                path = shadowPath, brush = Brush.linear□□□□□□□□( // A-1
                    colors = listOf(Color(0x44000000), Color(0x00000000)),
                    start = Offset(429.492f, 495.9793f),
                    end = Offset(858.4757f, 924.963f)
                )
            )
            drawPath(
                path = droidPath, color = Color.White, style = □□□□ // A-5
            )
        }

        Text(
            text = "DroidKaigi 2024",
            modifier = Modifier.□□□□□□(y = 48.dp), // A-2
            color = Color.White,
            style = □□□□□□□□□( // A-6
                fontSize = 36.sp, fontWeight = FontWeight.Bold
            )
        )
    }
}

Question8

Question8のPreview

クロスワードと回答

Question8-Crossword

Question8-Crossword

解説

Canvasを利用したデザインをベースにした問題です。基本的なModifierから出題しています。A-1、 A-5は、drawPathModifierから出題していますが、プレビューを参考にぜひ挑戦してみてください!

予備Questionの問題と解説

こちらは予備として用意していた問題、Preview、回答になります。 問題作成者は@rokuro.ichiharaです。
皆様に解いていただく機会はなかったですが、考えてみていただければありがたいです。

@Composable
fun QuestionSpare() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .□□□□□□□□□□(Color(0xFF3DDC84)), // A-3
        □□□□□□□□□□□□□□□□ = Alignment.Center // A-4
    ) {
        Canvas(modifier = Modifier.size(1080f.dp)) {
            drawPath(
                path = shadowPath, brush = Brush.linear□□□□□□□□( // A-1
                    colors = listOf(Color(0x44000000), Color(0x00000000)),
                    start = Offset(429.492f, 495.9793f),
                    end = Offset(858.4757f, 924.963f)
                )
            )
            drawPath(
                path = droidPath, color = Color.White, style = □□□□ // A-5
            )
        }

        Text(
            text = "DroidKaigi 2024",
            modifier = Modifier.□□□□□□(y = 48.dp), // A-2
            color = Color.White,
            style = □□□□□□□□□( // A-6
                fontSize = 36.sp, fontWeight = FontWeight.Bold
            )
        )
    }
}

Question9

Question9のPreview

クロスワードと回答

Question9-Crossword

Question9-Crossword

解説

この問題は難易度がeasyであるため、基本的なComposeの知識があれば解けるようにComposeの基礎的なレイアウトや、Modifierのクラス関数を使う問題を出題しました。UIとしてはDroidKaigiのテーマカラーを使ったバナーと、 Googleの公式サイト から引用したテキストを置くような形で構成しています。 


「Jetpack Compose Crossword Quiz」の問題解説は以上になります。 DroidKaigi 2024 のブースでたくさんの方々に問題の挑戦していただけ非常に嬉しく思います! 問題が変わるタイミングで挑戦しにきてくれる方もおり、合計300回を超える回答をいただきました。 多くの方々の参加と体験を提供できたブースイベントは、今後も続けていきたいと思っています。 コメントやフィードバックもお待ちしております。最後まで読んでいただき、本当にありがとうございました!!

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。