JetpackComposeでスポットライトを実装する
この記事は Android Advent Calendar 2023 10日目の記事です。9日目は@musicline_developerさんのOpenGLのVBOを活用した高速化でした。
スポットライト
スポットライトはアプリのチュートリアルやウォークスルーで見かけるアレです。iOSでの実装はそれなりに参考資料があるのに対してAndroid(JetpackCompose)での例はあまり見かけない印象だったので自分の実装方法を紹介します。
例として↓のような画面を作ります。
内容としては以下のステップで紹介します。
- 目立たせたい要素だけ明るくしてそれ以外を暗くするスポットライトの基本的な実装
- 目立たせた要素の機能・手順を説明するふきだしの実装
- (option)目立たせたい要素をいい感じにくり抜く
投稿後追記
「coachmark」で検索するといくつかライブラリがあるようでしたのでこの記事はサードパーティのライブラリに依存しない実装例としておきます。
- https://github.com/pseudoankit/coachmark
- https://github.com/svenjacobs/reveal
- https://github.com/TouchType/CornedBeef
スポットライトの基本的な実装
基本的なスポットライトの実装としてはいくつか先行例があり[1][2]、Canvasで透過色の矩形を画面全体に描画しDrawScope.clipPathで任意の範囲をくり抜くアプローチが一般的なようです。自分もこれらを参考に同様の手法でスポットライトのコンポーネントを実装しました。詳細な実装の説明については参考にしたスタディサプリさんの記事で詳しく解説されているのでそちらをご参照ください🙏
@Composable
fun Spotlight(
spotlightPath: Path.() -> Unit,
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
clipPath(
path = Path().apply {
spotlightPath()
},
clipOp = ClipOp.Difference,
) {
drawRect(
color = Color.Black.copy(alpha = 0.5f),
)
}
}
}
Pathをレシーバとする関数を引数に取ることで汎用なコンポーネントにしています。使用する際はこんな感じに目立たせたいUI(Composable)に対してonGloballyPositionedModifierを適用することで対象のRectを取得できるので、これをspotlightPath
にaddRectします。
@Composable
fun SampleScreen() {
var rect by remember { mutableStateOf<Rect?>(null) }
Scaffold(
topBar = { TopAppBar(...) },
floatingActionButton = {
Button(
shape = CircleShape,
modifier = Modifier
.width(280.dp)
.onGloballyPositioned { layoutCoordinates ->
rect = layoutCoordinates.boundsInRoot()
},
) {
Text(text = "サンプルボタン")
}
},
backgroundColor = Color.LightGray,
floatingActionButtonPosition = FabPosition.Center
) { paddingValues -> ... }
rect?.let { rect ->
Spotlight(
spotlightPath = {
addRect(rect)
},
)
}
}
機能を説明するふきだしの実装
ふきだしは Popup を使うことで実装できます。 Popup は画面上の任意の位置にフローティングな UI を表示するための Composable 関数で、主に DropdownMenu の内部実装などで利用されています。
今回は下方向へのふきだしを例にします。簡単にですがコンポーネントはこのような感じに、シンプルにBoxとCanvasで描いた三角形を連結することで表現しました。
@Composable
fun DownwardBubble(
offset: IntOffset,
content: @Composable BoxScope.() -> Unit,
) {
Popup(
alignment = Alignment.BottomCenter, // ふきだしの方向に合わせて変更する
properties = PopupProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
),
offset = offset,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(
horizontal = 32.dp,
vertical = 16.dp,
)
) {
Box(
modifier = Modifier
.background(
color = Color.White,
shape = RoundedCornerShape(16.dp)
)
.padding(16.dp),
content = content,
)
DownwardArrow()
}
}
}
@Composable
private fun DownwardArrow() {
Canvas(
modifier = Modifier.size(
height = 16.dp,
width = 24.dp,
)
) {
val path = Path()
path.moveTo(0f, 0f)
path.lineTo(size.width, 0f)
path.lineTo(size.width / 2, size.height)
drawPath(
path = path,
brush = SolidColor(Color.White),
)
}
}
重要なのは Popup のalignment
とoffset
で、これらの値次第でふきだしの表示位置を細かく制御できます。今回は下方向専用の設計としてoffset
のみ引数で渡すような実装にしていますが、 alignment
も外から渡すようにするとより汎用なふきだしが作れるかと思います。
使用例としてはこんな感じに表示位置のoffsetを渡すとよしなに表示されます。今回はAlignment.BottomCenter
を基準としているので画面下部からふきだしの下部までのoffsetを計算しています。
@Composable
fun SampleScreen() {
var rect by remember { mutableStateOf<Rect?>(null) }
Scaffold(
topBar = { TopAppBar(...) },
floatingActionButton = { ... },
) { paddingValues -> ... }
rect?.let { rect ->
val screenHeight = with(LocalDensity.current) {
LocalConfiguration.current.screenHeightDp.dp.roundToPx()
}
Spotlight(
spotlightPath = {
addRect(rect)
},
)
DownwardBubble(
offset = IntOffset(0, rect.top.toInt() - screenHeight),
) {
Text("これは一番大きなボタンです。とりあえず押してみましょう。")
}
}
}
ボタンに沿っていい感じにスポットライトをくり抜く
正直ここまでの実装でスポットライトの役割としては十分だと思いますが、デザイン上もっと凝ったスポットライトにしたいケースもあるかと思います。今回はCircleShapeのボタンのスポットライトの実装例を紹介します。
先述の実装ではスポットライトのPathにaddRect
を使ってくり抜きを行いましたが、代わりにaddRoundRectを使うことでRectに角丸をつけてくり抜くことができます。またRoundRectはShapeを指定できませんがradiusを指定することはできます。そしてCircleShapeのradiusは実質高さの半分と見なせます。これらのことからaddRoundRect
を使うことでCircleShapeのくり抜きができそうに思います。
しかし愚直にRectの高さを使ってaddRoundRect
しても残念ながらうまくCircleShapeに沿ってくり抜くことはできません。
@Composable
fun SampleScreen() {
var rect by remember { mutableStateOf<Rect?>(null) }
Scaffold(
topBar = { TopAppBar(...) },
floatingActionButton = { ... },
) { paddingValues -> ... }
rect?.let { rect ->
val screenHeight = with(LocalDensity.current) {
LocalConfiguration.current.screenHeightDp.dp.roundToPx()
}
Spotlight(
spotlightPath = {
addRoundRect(
RoundRect(
rect = rect,
cornerRadius = CornerRadius(rect.height / 2),
)
)
},
)
DownwardBubble(
offset = IntOffset(0, rect.top.toInt() - screenHeight),
) {
Text("これは一番大きなボタンです。とりあえず押してみましょう。")
}
}
}
これはマテリアルデザインのアクセシビリティの方針でタップ可能な要素に最小サイズの制約がある影響で、実際のボタンの大きさよりも大きくRectが設定されているためです。(addRect
の例の時点でボタンの周囲に余白があるのもそのせいです)
この制限はCompositionLocalProviderでLocalMinimumInteractiveComponentEnforcement
をfalseにすることで無視できます。
@ExperimentalMaterialApi
@Composable
fun SampleScreen() {
var rect by remember { mutableStateOf<Rect?>(null) }
Scaffold(
topBar = { TopAppBar(...) },
floatingActionButton = {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Button(
shape = CircleShape,
modifier = Modifier
.width(280.dp)
.onGloballyPositioned { layoutCoordinates ->
rect = layoutCoordinates.boundsInRoot()
},
) {
Text(text = "サンプルボタン")
}
}
},
) { paddingValues -> ... }
rect?.let { rect ->
val screenHeight = with(LocalDensity.current) {
LocalConfiguration.current.screenHeightDp.dp.roundToPx()
}
Spotlight(
spotlightPath = {
addRoundRect(
RoundRect(
rect = rect,
cornerRadius = CornerRadius(rect.height / 2),
)
)
},
)
DownwardBubble(
offset = IntOffset(0, rect.top.toInt() - screenHeight),
) {
Text("これは一番大きなボタンです。とりあえず押してみましょう。")
}
}
}
実際に利用する際はスポットライトを表示する時だけLocalMinimumInteractiveComponentEnforcement
をfalseにするような制御を入れるのが良いと思います。ただ現時点(2023.12)ではまだExperimentalなAPIを使う必要がありますし、くり抜き方が変わったところでユーザーの体験には特に寄与しないので無理に対応する必要はないかと思います。
まとめ
JetpackComposeでスポットライトを実装した例を紹介しました。
- 基本的なスポットライトはCanvasと
DrawScope.clipPath
で実装できる - ふきだしは
Popup
を使って実装できる - いい感じにスポットライトをくり抜くのは少し手間がいる
Discussion