- バックエンド
- PdM
- 急成長中の福利厚生SaaS
- Other occupations (24)
- Development
- Business
- Other
試行錯誤で見つけた最適解、Jetpack Composeで創るグラデーション付き吹き出しComponent
Photo by Daniel Romero on Unsplash
こんにちは!Mobile Growth Squadに所属している長尾(@hikaru_nagao_i)です。4月からウォンテッドリーへ入社し、AndroidをメインにWantedlyアプリの開発を担当しています。今回は、現在対応している施策の中で作成した、以下のようなグラデーション付き吹き出しComponentについてご紹介します。
目次
はじめに
1.検討したグラデーション付きのComponent案
1-1.没案:Canvasを使用して下三角の吹き出し部分を付け足す
1-2. 没案:テキスト部分と下三角の部分のグラデーション位置を調整する
1-3.採択案:独自Shapeで吹き出しの形を作成してグラデーションをかける
2.独自Shapeを使用した吹き出しComponentの実装詳細
2-1.Shapeを実装するクラスを作る
2-2.pathで吹き出しを描く
まとめ
はじめに
Wantedlyのプロダクトでは、特に重要なメッセージを視覚的に強調したい場合に、目を引くグラデーション付きの吹き出しが求められるユースケースがあります。この種のUIはこれまでAndroid Viewで実装していましたが、Jetpack Compose環境での対応が必要となったため、本記事でその過程を共有することにしました。
グラデーション付きの吹き出しをComposeで実装するにあたっては、いくつかの異なるアプローチを検討しました。その中で、コンポーネントの汎用性と再利用性を考慮した結果、最も最適だと判断した実装方法を見つけることができました。本記事を読むことで、グラデーション付きの吹き出しComponentをJetpack ComposeのカスタムShapeでいかに効率的かつシンプルに実現できるか、具体的なコード例を通して理解できます。これにより、同様のUI課題に直面した際に、効果的なアプローチを見つけられるでしょう。さらに、この作成工程を通じて、Shapeの使い方をより深く理解する一歩となれば幸いです。
1.検討したグラデーション付きのComponent案
このセクションでは、グラデーション付きの吹き出しコンポーネントを実装するにあたって実装した3つのアプローチについて説明します。なぜ最終的に特定の構成を選んだのかを理解していただくために、採択に至らなかったプロセスも共有します。また、検討したアプローチは以下の通りです。
- 案1:Canvasを使用して下三角の吹き出し部分を付け足す
- 案2:テキスト部分と下三角の部分のグラデーション位置を調整する
- 案3:独自Shapeで吹き出しの形を作成してグラデーションをかける
1-1.没案:Canvasを使用して下三角の吹き出し部分を付け足す
最初に、ボックス部分の直下にCanvasで下三角形のComponentを追加するアプローチを検討しました。このアプローチでは、Column内にメッセージ表示用のBoxと、その下に下三角形用のCanvasを配置し、それぞれにグラデーションを適用しています。ソースコードは以下になります。
@Composable
internal fun CoachMark(
text: String,
modifier: Modifier = Modifier,
) {
val visitGradientBrush = Brush.linearGradient(
colors = listOf(Color(0xFF0D93E0), Color(0xFF00C4C4)),
start = Offset.Zero,
end = Offset.Infinite,
)
Column(modifier = modifier, horizontalAlignment = Alignment.Start) {
Box(
modifier = Modifier
.wrapContentSize()
.background(
brush = visitGradientBrush,
shape = CircleShape,
),
) {
Text(
text = text,
color = Color(0xFFFFFFFF),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// 下向きの三角形(ポインター)
Canvas(
modifier = Modifier.width(20.dp).height(10.dp).offset(x = 24.dp),
) {
val trianglePath = Path().apply {
moveTo(size.width / 2f, size.height)
lineTo(0f, 0f)
lineTo(size.width, 0f)
close()
}
drawPath(path = trianglePath, brush = visitGradientBrush)
}
}
}
しかし、このアプローチでは、以下の様に下三角形の部分とボックスの部分それぞれにグラデーションを適用したため、全体としてデザインに違和感が生じてしまいました。特にグラデーションの境界部分で不自然さが顕著だったため、この案は採択を見送りました。
1-2. 没案:テキスト部分と下三角の部分のグラデーション位置を調整する
上記のアプローチでは、グラデーションに違和感が出たことが課題でした。その解決として、ボックス部分と下三角のコンポーネントに対して、グラデーションの開始位置から終了位置を合わせることにしました。また、Canvasではボックス部分とグラデーションの開始・終了位置を細かく制御するのが難しいため、下三角形のShapeを作成し、Boxにclipする方法を採択しました。ソースコードは以下です。
@Composable
internal fun CoachMark(
text: String,
modifier: Modifier = Modifier,
textBoxHeight: Dp = 40.dp,
triangleHeight: Dp = 8.dp,
triangleWidth: Dp = 16.dp,
triangleOffset: Dp = 24.dp,
) {
val totalHeight = textBoxHeight + triangleHeight
val gradationColors = listOf(Color(0xFF0D93E0), Color(0xFF00C4C4))
val triangleShape = TriangleShape(
triangleHeight = triangleHeight,
triangleWidth = triangleWidth,
triangleOffset = triangleOffset,
totalHeight = totalHeight,
)
val triangleBrush = Brush.linearGradient(
colors = gradationColors,
start = Offset.Zero,
end = Offset.Infinite,
)
val density = LocalDensity.current
val textBackgroundBrush = Brush.linearGradient(
colors = gradationColors,
start = Offset.Zero,
end = Offset(
x = Float.POSITIVE_INFINITY,
y = with(density) { totalHeight.toPx() },
),
)
Box(modifier = modifier.wrapContentWidth().height(totalHeight)) {
Box(
modifier = Modifier
.wrapContentWidth()
.height(textBoxHeight)
.background(
brush = textBackgroundBrush,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = Color(0xFFFFFFFF),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
Box(
modifier = Modifier
.matchParentSize()
.clip(triangleShape)
.background(brush = triangleBrush),
)
}
}
@Immutable
private class TriangleShape(
private val triangleHeight: Dp,
private val triangleWidth: Dp,
private val triangleOffset: Dp,
private val totalHeight: Dp,
) : Shape {
override fun createOutline(
size: androidx.compose.ui.geometry.Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val path = Path()
with(density) {
val textAreaHeight = (totalHeight - triangleHeight).toPx()
val triangleHeightPx = triangleHeight.toPx()
val triangleWidthPx = triangleWidth.toPx()
val triangleOffsetPx = triangleOffset.toPx()
// 三角形の形状のみ
path.moveTo(triangleOffsetPx, textAreaHeight)
path.lineTo(triangleOffsetPx + triangleWidthPx, textAreaHeight)
path.lineTo(triangleWidthPx / 2 + triangleOffsetPx, textAreaHeight + triangleHeightPx)
path.close()
}
return Outline.Generic(path)
}
}
グラデーションについては、下三角形のBoxにmatchParentSizeを指定することで、親Boxとサイズを揃えました。また、テキスト部分のBoxに適用するグラデーション(textBackgroundBrush)の終了位置は、下三角形の高さ分だけ下方向に拡張し、全体でグラデーションの開始・終了位置が揃うように調整しました。このようにして、見た目上は違和感のない自然なグラデーションを持つ吹き出しコンポーネントを実現できました。
ただし、下三角形のグラデーションにはend = Offset.Infiniteを指定しており、正確にはグラデーションの方向や広がり方が一致しているわけではありません。また、構造もやや複雑になっていることから、最終的にはこの案の採択も見送りました。
1-3.採択案:独自Shapeで吹き出しの形を作成してグラデーションをかける
これまでの2つのアプローチを踏まえ、グラデーションの不連続性という課題を確実に解決しつつ、コードの実装もシンプルに保つためには、吹き出し全体をひとつのShapeとして定義するのが最適だと判断しました。この方法であれば、吹き出しの形そのものに単一のグラデーションを適用できるため、望み通りの自然な見た目を実現できると考えました。このアプローチのソースコードは以下の通りです。
@Composable
internal fun WdsCoachMark(
text: String,
triangleStartX: Dp,
modifier: Modifier = Modifier,
) {
CoachMark(
triangleStartX = triangleStartX,
modifier = modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.body2,
color = Color(0xCCFFFFFF),
)
}
}
@Composable
private fun CoachMark(
modifier: Modifier = Modifier,
triangleHeight: Dp = 8.dp,
triangleStartX: Dp = 24.dp,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.clip(CoachMarkShape(triangleHeight, triangleStartX))
.background(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF0D93E0), Color(0xFF00C4C4)),
start = Offset.Zero,
end = Offset.Infinite,
),
)
.padding(bottom = triangleHeight)
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}
@Immutable
private class CoachMarkShape(
private val triangleHeight: Dp,
private val triangleStartX: Dp,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
with(density) {
Path().apply {
val triangleHeightPx = triangleHeight.toPx()
val triangleStartXPx = triangleStartX.toPx()
val textAreaHeight = size.height - triangleHeightPx
arcTo(
rect = Rect(offset = Offset.Zero, size = Size(size.height, textAreaHeight)),
startAngleDegrees = 270f,
sweepAngleDegrees = -180f,
forceMoveTo = true,
)
lineTo(triangleStartXPx, textAreaHeight)
lineTo(triangleStartXPx + triangleHeightPx, size.height)
lineTo(triangleStartXPx + triangleHeightPx * 2, textAreaHeight)
arcTo(
rect = Rect(
offset = Offset(size.width - textAreaHeight, 0f),
size = Size(textAreaHeight, textAreaHeight),
),
startAngleDegrees = 90f,
sweepAngleDegrees = -180f,
forceMoveTo = false,
)
close()
return Outline.Generic(this)
}
}
}
}
CoachMarkShapeクラスでは、テキストボックスと下三角の部分を合わせて一つの形として描画しています。これにより、グラデーションの不連続性を回避しつつ、デザイン仕様も満たすことができました。この点が、最終的に本アプローチを採択した決め手です。
2.独自Shapeを使用した吹き出しComponentの実装詳細
このセクションでは、前章で採択したアプローチの具体的な実装詳細を解説します。特に、吹き出し全体をカスタムShapeとして描画する際のcreateOutline関数内で、Path APIを用いて複雑な図形をどのように構成していくか、そのポイントを掘り下げていきます。
2-1.Shapeを実装するクラスを作る
Jetpack Composeで独自の形状を作るには、Shapeインターフェースを実装したクラスを作成します。今回のケースでは、テキスト部分(角丸の矩形)+下三角の吹き出しを一体化した形を表現したいので、CoachMarkShapeというクラス名でShapeを継承します。
@Immutable
private class CoachMarkShape(
private val triangleHeight: Dp,
private val triangleStartX: Dp,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
// 吹き出し全体のパスをここで定義します
}
}
2-2.pathで吹き出しを描く
前ステップ(2-1)で独自Shapeクラスの骨組みを用意しました。ここからはそのShapeの「中身」、つまり実際にどのように吹き出しの形をPath(パス)で描画するか、という最も重要なポイントを解説します。ShapeクラスのcreateOutline関数内で、Jetpack ComposeのPath APIを使い、吹き出しの主要部分である「角丸長方形」と、特徴的な「下向き三角形」をひとつながりのパスとして描いていきます。主な実装の流れは以下の通りです。
1. 左側の半円をarcToで描く
2. 下辺を三角形の開始位置まで進む
3. 三角形を描く
4. 右側の半円をarcToで描く
5. パスを閉じる
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
with(density) {
Path().apply {
val triangleHeightPx = triangleHeight.toPx()
val triangleStartXPx = triangleStartX.toPx()
val textAreaHeight = size.height - triangleHeightPx
// 1. 左側の半円
arcTo(
rect = Rect(
offset = Offset.Zero,
size = Size(size.height, textAreaHeight)
),
startAngleDegrees = 270f,
sweepAngleDegrees = -180f,
forceMoveTo = true,
)
// 2. 下辺を三角形の開始位置まで
lineTo(triangleStartXPx, textAreaHeight)
// 3. 三角形
lineTo(triangleStartXPx + triangleHeightPx, size.height)
lineTo(triangleStartXPx + triangleHeightPx * 2, textAreaHeight)
// 4. 右側の半円
arcTo(
rect = Rect(
offset = Offset(size.width - textAreaHeight, 0f),
size = Size(textAreaHeight, textAreaHeight),
),
startAngleDegrees = 90f,
sweepAngleDegrees = -180f,
forceMoveTo = false,
)
// 5. パスを閉じる
close()
return Outline.Generic(this)
}
}
}
このPathの描画では、吹き出しの本体(長丸部分)と下三角形を別々に定義するのではなく、全体をひとつの連続したパスとして描きます。 この方式なら、全体の形状が自然につながり、グラデーションや単色の塗りつぶしを適用した際に、境目がなくきれいに表現できます。
まとめ
本記事では、グラデーション付きの吹き出しコンポーネントを実装するまでの試行錯誤と、その具体的な方法について紹介しました。私自身、グラデーションの不連続性や実装の複雑さに直面する中で、どのようにすればUI要件を満たせるかという課題に取り組みました。その結果、吹き出し全体をひとつのカスタムShapeとして定義するアプローチが最適解であることを見出し、シンプルなコードでグラデーションを持つコンポーネントを実現できました。将来的には、吹き出しの三角形部分が上下どちらの方向にも表示できるようにし、さらなる汎用性向上を目指します。
この記事では、Path APIを用いた複雑な図形描画のポイントについても解説しました。これらの知見が、皆さんのJetpack Compose開発におけるUI実装の一助となれば幸いです。