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

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に
- きれいに対応関係がまとまっている
- コピペで動く
- 最もシンプルな実装

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

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

# 対応関係

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

# SwiftUI
こちらの[motoshima1150さんの記事](https://blog.studysapuri.jp/entry/2021/09/18/182000_1)を主に参考にさせていただきました。

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

## 1. 全画面を覆う半透明のビューをつくる
```swift: 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/f2cc1e15-0789-d58a-271c-73441a96170a.png)

## 2. 切り抜きたい図形のビューを作成し、マスクする
切り抜きたい図形を、ビューとしてつくります。今回は角丸四角形にしてみます。
```swift: CustomShape.swift
import SwiftUI

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

SpotlightViewでは```mask```修飾子を使って、引数にとったcontentで切り抜くように設定します。
```swift: 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/0ab75172-709b-f9bd-2e51-273f034987de.png)


## 3. マスク領域を反転させる
```.compositingGroup()```修飾子 -> [これをつけたビューに効果を適用する前に、その親ビューに効果を適用する](https://developer.apple.com/documentation/swiftui/image/compositinggroup()#:~:text=makes%20compositing%20effects%20in%20this%20view%E2%80%99s%20ancestor%20views%2C%20such%20as%20opacity%20and%20the%20blend%20mode%2C%20take%20effect%20before%20this%20view%20is%20rendered)
```.luminanceToAlpha()```修飾子 -> [輝度が低い領域は透明度が高くなり、輝度が高いほど不透明度が高くなる](https://developer.apple.com/documentation/swiftui/link/luminancetoalpha()#:~:text=Regions%20of%20lower%20luminance%20become%20more%20transparent%2C%20while%20higher%20luminance%20yields%20greater%20opacity.)(要するに、白い部分が残り、黒い部分が消える)
のコンビを使って反転させます。

ZStackの背景色を白くすることで、背景が残って図形部分が消えます。
```swift: 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の引数に切り抜きたい図形のビューを入れて表示します。

```swift: SpotlightViewExample.swift
import SwiftUI

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

## 実行結果
思い描いたように切り抜かれました。contentの中身や位置を変えれば、自由にスポットライトを当てられます。
![swiftui_spotlight_step3](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/ec0ff193-185c-9586-6edf-d375090ff40a.png)


# Jetpack Compose
こちらの[StackOverflowでの質問](https://stackoverflow.com/questions/72074117/jetpack-compose-clip-modifier-inversion)を主に参考にさせていただきました。自分の調べた限りでは、コンポーザブルを使って切り抜きを行っている事例はなく、どれもCanvasに```Path```を描いて行っていました。もし可能な方法をご存知の方がいらっしゃいましたら、ご教示ください。

## ステップ
1. 全画面を覆う半透明のビューをつくる(Canvas)
1. 切り抜きたい図形を```Path```で作成
1. ```.clipPath```の```ClipOp.Difference```を使って切り抜く

## 1. 全画面を覆う半透明のビューをつくる(Canvas)
Canvasに```drawRect```を使って、全画面を覆います。
```kotlin: 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/46bbb1a9-4439-a2d3-317f-e8fccaece934.png)

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

画面のサイズを取得したいので、```DrawScope```を引数にもらっています(Canvasの内部だと自動的に付加されるが、この関数は外で定義しているため)。
またCanvasだとPixelが基本単位のため、Dpのサイズ感で定義しつつ、これをもう一つの引数である```Density```によってPixel単位に変換します。 -> [参考](https://qiita.com/ymshun/items/4a339966c196488a792c)
```kotlin: 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/1b0aed8a-b221-6966-434d-f7237861aaa7.png)

## 3. ```.clipPath```の```ClipOp.Difference```を使って切り抜く
先ほどの図形Pathの関数を引数で取り、それを使って背景を切り抜きます。```ClipOp.Difference```については、Google公式の説明によると「[既存の領域から新しい領域を減算します。](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/ClipOp.Companion#:~:text=Difference%3A%C2%A0ClipOp-,Subtract%20the%20new%20region%20from%20the%20existing%20region.,-Intersect)」ということで、まさに切り抜きをしてくれるオプションだそうです。

```kotlin: 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として定義した関数を入れます。

```kotlin: 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/d76b278b-7c88-9c80-3b2c-b3cc7c4b0406.png)

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

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


# 参考
SwiftUI
[SwiftUI でスポットライト機能を実装する [motoshima1150さん]](https://blog.studysapuri.jp/entry/2021/09/18/182000_1)
[【SwiftUI】Viewに影をつける（shadow) [capibara1969さん]](https://capibara1969.com/2017/)
[【SwiftUI】画面いっぱいにViewを広げる [yosshi4486さん]](https://qiita.com/yosshi4486/items/f38f7401a67b3a61b5f7)
[[Swift] SwiftUI で画面のくり抜きを実現する [h_craneさん]](https://qiita.com/hcrane/items/7211453164ff6726a73c)
[compositingGroup() [Apple公式]](https://developer.apple.com/documentation/swiftui/image/compositinggroup())
[luminanceToAlpha() [Apple公式]](https://developer.apple.com/documentation/swiftui/link/luminancetoalpha())

Jetpack Compose
[Jetpack Compose clip modifier inversion [StackOverflow]](https://stackoverflow.com/questions/72074117/jetpack-compose-clip-modifier-inversion)
[Android Compose: draw transparent circle on image [StackOverflow]](https://stackoverflow.com/questions/69047325/android-compose-draw-transparent-circle-on-image/69048352#69048352)
[【Android】composeでdp, pxを変換する [ymshunさん]](https://qiita.com/ymshun/items/4a339966c196488a792c)
[ClipOp.Companion [Google公式]](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/ClipOp.Companion#:~:text=Difference%3A%C2%A0ClipOp-,Subtract%20the%20new%20region%20from%20the%20existing%20region.,-Intersect)