🤖

JetpackComposeでスポットライトを実装する

2023/12/10に公開

この記事は Android Advent Calendar 2023 10日目の記事です。9日目は@musicline_developerさんのOpenGLのVBOを活用した高速化でした。

スポットライト

スポットライトはアプリのチュートリアルやウォークスルーで見かけるアレです。iOSでの実装はそれなりに参考資料があるのに対してAndroid(JetpackCompose)での例はあまり見かけない印象だったので自分の実装方法を紹介します。

例として↓のような画面を作ります。

紹介するスポットライトの例

内容としては以下のステップで紹介します。

  1. 目立たせたい要素だけ明るくしてそれ以外を暗くするスポットライトの基本的な実装
  2. 目立たせた要素の機能・手順を説明するふきだしの実装
  3. (option)目立たせたい要素をいい感じにくり抜く

投稿後追記

「coachmark」で検索するといくつかライブラリがあるようでしたのでこの記事はサードパーティのライブラリに依存しない実装例としておきます。

スポットライトの基本的な実装

基本的なスポットライトの実装としてはいくつか先行例があり[1][2]、Canvasで透過色の矩形を画面全体に描画しDrawScope.clipPathで任意の範囲をくり抜くアプローチが一般的なようです。自分もこれらを参考に同様の手法でスポットライトのコンポーネントを実装しました。詳細な実装の説明については参考にしたスタディサプリさんの記事で詳しく解説されているのでそちらをご参照ください🙏

Spotlight.kt
@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を取得できるので、これをspotlightPathaddRectします。

SampleScreen.kt
@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で描いた三角形を連結することで表現しました。

DownwardBubble.kt
@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 のalignmentoffsetで、これらの値次第でふきだしの表示位置を細かく制御できます。今回は下方向専用の設計としてoffsetのみ引数で渡すような実装にしていますが、 alignment も外から渡すようにするとより汎用なふきだしが作れるかと思います。

使用例としてはこんな感じに表示位置のoffsetを渡すとよしなに表示されます。今回はAlignment.BottomCenterを基準としているので画面下部からふきだしの下部までのoffsetを計算しています。

SampleScreen.kt
@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のくり抜きができそうに思います。

CircleShapeの高さと端のradiusの対応

しかし愚直にRectの高さを使ってaddRoundRectしても残念ながらうまくCircleShapeに沿ってくり抜くことはできません。

SampleScreen.kt
@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("これは一番大きなボタンです。とりあえず押してみましょう。")
        }
    }
}

うまくCircleShapeにくり抜けていない例

これはマテリアルデザインのアクセシビリティの方針でタップ可能な要素に最小サイズの制約がある影響で、実際のボタンの大きさよりも大きくRectが設定されているためです。(addRectの例の時点でボタンの周囲に余白があるのもそのせいです)
この制限はCompositionLocalProviderでLocalMinimumInteractiveComponentEnforcementをfalseにすることで無視できます。

SampleScreen.kt
@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を使って実装できる
  • いい感じにスポットライトをくり抜くのは少し手間がいる
脚注
  1. https://blog.studysapuri.jp/entry/2023/6/26/compose-spotlight ↩︎

  2. https://qiita.com/turtlekazu/items/b115da8bd7a5f47ca057 ↩︎

Discussion