5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【SwiftUI/JetpackCompose】スポットライトをつくる ~比較で覚える宣言的モバイルUI~

Last updated at Posted at 2022-10-04

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に

  • きれいに対応関係がまとまっている
  • コピペで動く
  • 最もシンプルな実装

そんな記事があったらいいなと思い、備忘録も兼ねて自分が実装してみた範囲でまとめていきたいと思います。初心者ですので、より良い実装をご存知の方がいらっしゃいましたら、ご教示ください。

今回はチュートリアルなどでよく見かける、スポットライト編です。

対応関係

【SwiftUI】mask.compositingGroup()修飾子と.luminanceToAlpha()修飾子で反転(ビューの図形をそのまま使える)
【JetpackCompose】Canvasで.clipPathを使う(Canvasで行うため、Pathで図形をつくる必要がある)

SwiftUI

こちらのmotoshima1150さんの記事を主に参考にさせていただきました。

ステップ

  1. 全画面を覆う半透明のビューをつくる
  2. 切り抜きたい図形のビューを作成し、マスクする
  3. マスク領域を反転させる

1. 全画面を覆う半透明のビューをつくる

SpotlightView.swift
import SwiftUI

// View型の引数を取りたいので <Content: View> をつける
struct SpotlightView<Content: View>: View {
    let content: Content
    var opacity: CGFloat = 0.5
    
    var body: some View {
        Rectangle()
            .fill(Color.black.opacity(opacity))
            .ignoresSafeArea(.all)
    }
}

プレビュー
swiftui_spotlight_step1

2. 切り抜きたい図形のビューを作成し、マスクする

切り抜きたい図形を、ビューとしてつくります。今回は角丸四角形にしてみます。

CustomShape.swift
import SwiftUI

struct CustomShape: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 60)
            .frame(width: 200, height: 200)
    }
}

SpotlightViewではmask修飾子を使って、引数にとったcontentで切り抜くように設定します。

SpotlightView.swift
import SwiftUI

struct SpotlightView<Content: View>: View {
    let content: Content
    var opacity: CGFloat = 0.5
    
    var body: some View {
        Rectangle()
            .fill(Color.black.opacity(opacity))
            .mask( // <- 追加
                ZStack {
                    // 切り抜きたい図形を黒く塗る
                    content.foregroundColor(.black)
                }
                    // 画面いっぱいに広げる
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            )
            .ignoresSafeArea(.all)
    }
}

プレビュー
図形を実際に引数に入れると、その図形の部分だけが残ります。やりたいことの真逆の状態ですね。
swiftui_spotlight_step2

3. マスク領域を反転させる

.compositingGroup()修飾子 -> これをつけたビューに効果を適用する前に、その親ビューに効果を適用する
.luminanceToAlpha()修飾子 -> 輝度が低い領域は透明度が高くなり、輝度が高いほど不透明度が高くなる(要するに、白い部分が残り、黒い部分が消える)
のコンビを使って反転させます。

ZStackの背景色を白くすることで、背景が残って図形部分が消えます。

SpotlightView.swift
import SwiftUI

struct SpotlightView<Content: View>: View {
    let content: Content
    var opacity: CGFloat = 0.5
    
    var body: some View {
        Rectangle()
            .fill(Color.black.opacity(opacity))
            .mask(
                ZStack {
                    content.foregroundColor(.black)
                }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(.white) // <- 追加
                    .compositingGroup() // <- 追加
                    .luminanceToAlpha() // <- 追加
            )
            .ignoresSafeArea(.all)
    }
}

使い方

SpotlightViewの引数に切り抜きたい図形のビューを入れて表示します。

SpotlightViewExample.swift
import SwiftUI

struct SpotlightViewExample: View {
    var body: some View {
        SpotlightView(content: CustomShape())
    }
}

実行結果

思い描いたように切り抜かれました。contentの中身や位置を変えれば、自由にスポットライトを当てられます。
swiftui_spotlight_step3

Jetpack Compose

こちらのStackOverflowでの質問を主に参考にさせていただきました。自分の調べた限りでは、コンポーザブルを使って切り抜きを行っている事例はなく、どれもCanvasにPathを描いて行っていました。もし可能な方法をご存知の方がいらっしゃいましたら、ご教示ください。

ステップ

  1. 全画面を覆う半透明のビューをつくる(Canvas)
  2. 切り抜きたい図形をPathで作成
  3. .clipPathClipOp.Differenceを使って切り抜く

1. 全画面を覆う半透明のビューをつくる(Canvas)

CanvasにdrawRectを使って、全画面を覆います。

SpotlightView.kt
@Composable
fun SpotlightView(
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            // 切り抜かれる背景
            drawRect(SolidColor(Color.Black.copy(alpha = 0.5f)))
        }
    }
}

プレビュー
jetpack_compose_spotlight_step1

2. 切り抜きたい図形のPathを作成

続いて、切り抜きたい図形のPathを返す関数を作成します。図形は、SwiftUIの例と同じ形状の角丸四角形にします。

画面のサイズを取得したいので、DrawScopeを引数にもらっています(Canvasの内部だと自動的に付加されるが、この関数は外で定義しているため)。
またCanvasだとPixelが基本単位のため、Dpのサイズ感で定義しつつ、これをもう一つの引数であるDensityによってPixel単位に変換します。 -> 参考

CustomPath.kt
fun customPath(drawScope: DrawScope, density: Density): Path {
    val path = Path().apply {
        val screenWidth = drawScope.size.width
        val screenHeight = drawScope.size.height
        val itemHeight = with(density) {200.dp.toPx()}
        val itemWidth = with(density) {200.dp.toPx()}
        val cornerRadius = CornerRadius(with(density) {60.dp.toPx()}, with(density) {60.dp.toPx()})

        addRoundRect(
            RoundRect(
                rect = Rect(
                    offset = Offset((screenWidth - itemWidth)/2, (screenHeight - itemHeight)/2),
                    size = Size(itemWidth, itemHeight)
                ),
                cornerRadius = cornerRadius,
            )
        )
    }
    return path
}

プレビュー
上記の図形をCanvasにdrawPathで描画したものがこちら(この描画の実装は不要です)
jetpack_compose_spotlight_step2

3. .clipPathClipOp.Differenceを使って切り抜く

先ほどの図形Pathの関数を引数で取り、それを使って背景を切り抜きます。ClipOp.Differenceについては、Google公式の説明によると「既存の領域から新しい領域を減算します。」ということで、まさに切り抜きをしてくれるオプションだそうです。

SpotlightView.kt
@Composable
fun SpotlightView(
    modifier: Modifier = Modifier,
    customPath: (DrawScope, Density) -> Path, // <- 追加
) {
    val density = LocalDensity.current // <- 追加
    Box(modifier = modifier) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            clipPath(
                // 切り抜く図形
                customPath(this, density), clipOp = ClipOp.Difference
            ) {
                // 切り抜かれる背景
                drawRect(SolidColor(Color.Black.copy(alpha = 0.5f)))
            }
        }
    }
}

使い方

SpotlightViewの中に、customPathとして定義した関数を入れます。

SpotlightViewExample.kt
@Composable
fun SpotlightViewExample(
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier,
        contentAlignment = Alignment.Center,
    ) {
        SpotlightView(customPath = { drawScope, density ->
            // 切り抜く図形
            customPath(drawScope, density)
        })
    }
}

実行結果

無事に切り抜くことができました。Pathを使う必要があるので、図形を作るのが少々面倒ですが、Canvasに慣れればきっと思い通りにスポットライトが当てられるはずです(希望)。
jetpack_compose_spotlight_step3

まとめ

スポットライト
対応関係を再掲します。
【SwiftUI】mask.compositingGroup()修飾子と.luminanceToAlpha()修飾子で反転(ビューの図形をそのまま使える)
【JetpackCompose】Canvasで.clipPathを使う(Canvasで行うため、Pathで図形をつくる必要がある)

チュートリアルなどでスポットライトのUIはよく使うため、両方のフレームワークで実現する方法がありよかったです。
SwiftUIのように、Jetpack Composeでコンポーザブルで定義した形状で切り抜けるようになれば、かなり実装は楽になりそうだと感じました。そのような機能が追加されるのを期待中です。

参考

SwiftUI
SwiftUI でスポットライト機能を実装する [motoshima1150さん]
【SwiftUI】Viewに影をつける(shadow) [capibara1969さん]
【SwiftUI】画面いっぱいにViewを広げる [yosshi4486さん]
[Swift] SwiftUI で画面のくり抜きを実現する [h_craneさん]
compositingGroup() [Apple公式]
luminanceToAlpha() [Apple公式]

Jetpack Compose
Jetpack Compose clip modifier inversion [StackOverflow]
Android Compose: draw transparent circle on image [StackOverflow]
【Android】composeでdp, pxを変換する [ymshunさん]
ClipOp.Companion [Google公式]

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?