[Swift] SwiftUI で画面のくり抜きを実現する


# 前置き

**ユーザーにヒントを出す画面**は多くのアプリで実装されているかと思います。

![img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/611ce8ea-4ab6-45a4-d7a3-83cda021290e.png)



この画面は実際に``SwiftUI``で作られています。
では、どのように行ったのかをみていきましょう。


# 実装方法

２パターン紹介したいと思います。

## 1. UIBezierPath on SwiftUI で実装

`UIKit`の場合は`UIBezierPath`を使った実装方法がありました。
以下は`Objective-C`のコードなので古いですが、比較的にわかりやすいコード例です。

https://qiita.com/yuch_i/items/9819492dee555906b7b8

`UIBezierPath` で「画面全体」と「くり抜く部分」を重ねることで、重なった部分のみ透過できるというやり方です。これを `SwiftUI` の `View` に適応するだけです。


今回は、以下のような少し簡単にした画面で紹介していきます。

|くり抜き前|くり抜き後|
|:---:|:---:|
|![before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/c0179296-1a24-d89a-3da4-0e4cbe0c6ecd.png)|![after.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/55b69a55-930a-f64e-dc56-1f34d2366ab6.png)|

### 画面構成

画面自体は、以下の３つを組み合わせたとてもシンプルな構成になっています。

|①アラート画面|②黒いフィルター画面|③透明な穴の画面|
|:---:|:---:|:---:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/ee444bbb-ab09-d546-24f1-cfebe18e78b7.png" width="300px">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/49d2508c-654d-a716-3cd4-36597d88ddc4.png" width="300px">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/468488bd-3026-0c13-9e7d-a3b375c5c975.png" width="300px">



#### ① アラート画面

```.swift
struct TutorialAlertView: View {

    var handler: (() -> Void)?

    var body: some View {
        VStack {
            Text("Title")
                .padding(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0))

            Text("Description")
                .padding(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0))

            Button(action: { handler?() }, label: {
                Text("OK")
                    .frame(width: 252, height: 44)
                    .foregroundColor(.white)
                    .background(Color.red)
                    .cornerRadius(4)
                    .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
            })
            .contentShape(Rectangle())
        }
        .frame(maxWidth: 300)
        .background(Color.white)
        .cornerRadius(4)
    }
}
```

#### ② 黒いフィルター画面

```.swift
struct TutorialFilterView: View {

    var body: some View {
        Color.black
            .opacity(0.7)
            .mask(TutorialHoleView())
            .edgesIgnoringSafeArea(.all)
    }
}
```

`mask()`を使うことでフィルター部分の切り抜きを行なっています。

#### ③ 透明な穴の画面

```.swift
struct TutorialHoleView: View {

    var body: some View {
        // 穴
        let holePath = UIBezierPath(roundedRect: CGRect(
            x: 8,
            y: 60,
            width: 100,
            height: 80
        ), cornerRadius: 24)

        // 全体
        var shape = Path(CGRect(
            origin: .zero,
            size: UIScreen.main.bounds.size
        ))
        shape.addPath(Path(holePath.cgPath)) // Pathに変換
        return shape.fill(style: FillStyle(eoFill: true))
    }
}
```

`SwiftUI`では`Path`というものがあり、`UIBezierPath`から変換することで扱うことができます。変換自体は`.cgPath`を参照するだけで可能なのでとても簡単です。``FillStyle``がないと透過部分が反転してしまうのでつけてください。

#### ①~③を組み合わせた画面

これを`ZStack`で重ねて組み合わせると完成します。


|画面|重なり|
|:---:|:---:|
|![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/d1076695-dd33-07fa-6c54-c68fd4701a8b.png)|![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/d367e743-af8b-d25c-ed49-ccd4ecd69695.png)|



```.swift
struct TutorialView: View {

    var body: some View {
        ZStack {
            TutorialFilterView()
            TutorialAlertView { /* do some button action */ }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}
```

今回は例なので`frame`を固定にしていますが、位置を調整する場合は前の画面から渡して行く必要があります。


## 2. SwiftUI のみで実装


先ほど実装した、`UIBezierPath`で実装した部分を`SwiftUI`の**Shapeを使って実装**します。

基本的にはほぼ同じで、「③ 透明な穴の画面」のコードで作成した`TutorialHoleView`の部分を、以下のように`Shape`（Rectangle）にします。

### example code

```.swift
struct TutorialHoleView: View {

    var body: some View {
        Rectangle()
            .cornerRadius(24)
            .frame(width: 100, height: 80)
            .position(x: 60, y: 150)
            .background(Color.white)
            .compositingGroup() // ※1
            .luminanceToAlpha() // ※2
    }
}
```

`Shape` を使う場合は、注意点として以下の２つを設定する必要があります。

- ``compositingGroup()`` : ZStack の中身をグループ化する
- ``luminanceToAlpha()`` : 暗い領域は透明に、明るい領域は不透明な黒にする

これを設定することで、くり抜きを反映することができます。

<br>

また、`Rectangle`を使用していますが、`Shape`であれば良いので、

- RotatedShape
- RoundedRectangle
- Circle

など、さまざまな図形を用いて、くり抜くことができます。

### 組み合わせた画面

|画面|重なり|
|:---:|:---:|
|![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/1464eda6-3917-7cbe-9d8a-baf2f8255a37.png)|![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/1f07bbc6-e421-700f-df23-12b0114db8e9.png)|

このように`SwiftUI`が用意した図形を用いることで、シンプルな実装にすることができます。

## 使い分け

簡単なくり抜きの実装であれば、`SwiftUI`の`Shape`を使った実装の方が楽に実装可能です。ただ、星形やハートなど、**難しい図形をくり抜きたい**場合は、`Path` or `UIBezierPath`で図形を作成することになります。

動作するリポジトリを置いておくので、いろいろ試してみると面白いかと思います。

https://github.com/crane-hiromu/SwiftUI_Mask_Sample




# その他


- 「1. UIBezierPath on SwiftUI で実装」の実装で参考になったもの

https://zenn.dev/zones/articles/cb7bb204a5de16


- 「2. SwiftUI のみで実装」の実装で参考になったもの

https://quipper.hatenablog.com/entry/2021/09/18/182000_1

https://teratail.com/questions/360414



- `UIBezierPath`の方法を紹介しましたが、`Path`で図形を作る方法も掲載しておきます

https://capibara1969.com/2723/