✂️

SwiftUIでViewをくり抜く(even-odd rule algorithm)

5 min read

はじめに

SwiftUI初心者のアマゾネスです。
先日、自分が書いた二年前の資料に自分が助けられるという経験をしました。
なので、本日は二年後の自分と、この世界の困っている誰かに向けて、SwiftUIでViewをくり抜いた方法と、その道中で出会った面白いアルゴリズムについて記録しておこうと思います。

実現すること

そもそもくり抜くって何?
オンボーディングとかでこういうUI、昔ありましたよね。


こういうやつやりたい

画面全体を半透明のViewで覆って、注目させたい所だけくり抜いて目立たせるやつです。
いろんな実現方法がありそうですが、今回はSwiftUIのPathと、今回のおもしろポイントであるeven-odd rule algorithmを使ってくり抜いてみようと思います。

やってみる

まず、基本となる画面を用意します。
テキストが中央に表示されて、その上に黒の半透明のレイヤーがある状態の画面です。

    var body: some View {
        ZStack {
            VStack(spacing: 15) {
                Text("First, tap here.")
                Text("Next, tap here💡")
                Text("Finally, tap here.")
            }
            Rectangle()
                .foregroundColor(Color.black.opacity(0.8))
        }
        .edgesIgnoringSafeArea(.all)
    }

シンプル。こんな感じになります↓

では、早速くり抜いていこうと思います。
私ははじめ 「mask使えばええんでない?」と思いました。

そりゃそうですよね、という感じなんですが、やりたいこととはになっちゃいました。

やりたいことは、これとはです。どうすればいいのでしょう?
そこで発見したのがeven-odd rule algorithmです。

DeepLさんのお力を借りると、こんな説明が書かれているかと思います。

この規則は、その点から無限大まで任意の方向に線を描き、その線が交差する図形からのパスセグメントの数を数えることによって、キャンバス上の点の「不真正」を決定します。この数が奇数の場合、その点は内側にあり、偶数の場合、その点は外側にあります。

今回やりたいことを基に、図を描いてみました。


やりたいことを基に描いた図

ピンクの線がマスクするためのPathです。
そして、ポイントAというのがあると思います。それが上記説明の「その点」に匹敵する物です。説明に従い、それらのポイントから超長い青い線を引いています。そうすると、ピンクのPathと交差する箇所が出てくると思います。それが説明の「パスセグメント」です。

パスセグメントの数を数えることによって、キャンバス上の点の「不真正」を決定する...つまり、点から伸びた超長い線とPathが交差する箇所の数を数えることによって、「その点」がある場所が、内側なのか、外側なのかが決まるようです。

マスクをかけるPathにこのアルゴリズムを適応するのであれば、ポイントAが内側だとマスクがかかる、つまりは、初めにマスクをかけたときのように、やりたいことと逆の状態になってしまうので、ポイントAが外側になれば良さそうです。アルゴリズムのルールを見ると、

この数が奇数の場合、その点は内側にあり、偶数の場合、その点は外側にあります。

つまりポイントAから伸びた線とマスクのPathが交差する箇所の数が偶数になるようにしたい...
じゃあどうする?外側にもう一本Pathを追加すれば良さそう(単純)💡


Pathを追加してみた図

ポイントAから伸びる線と、Pathと交差する箇所が偶数になりました!
それからポイントBから伸びる線にも注目してみてください。Aポイントの結果の逆、つまり交差する箇所が奇数になってる!面白い〜✨

そして、それを実装してみると...

「こういうやつやりたい」の図と全く同じ感じになりました🎉

コードはというと、大体こんな感じになりました↓
FillStyle(eoFill: true) となっている所が、even-odd rule algorithmを適応してるところです。

struct ContentView: View {
    var body: some View {
        ZStack {
            VStack(spacing: 15) {
                Text("First, tap here.")
                Text("Next, tap here💡")
                Text("Finally, tap here.")
            }
            Rectangle()
                .foregroundColor(Color.black.opacity(0.8))
                .mask(
                    rectangleShapeMaskPath(getRectangleRectFromCenter(with: 25,
                                                                      width: 200))
                        .fill(style: FillStyle(eoFill: true))
                )
        }
        .edgesIgnoringSafeArea(.all)

    }

    private func rectangleShapeMaskPath(_ rect: CGRect) -> Path {
        // 外側のPath
	var shape = Path(CGRect(origin: .zero, size: UIScreen.main.bounds.size)) 
	// 内側のPath
        shape.addPath(Path(rect)) 
        return shape
    }

    private func getRectangleRectFromCenter(with hight: CGFloat, width: CGFloat) -> CGRect {
        let point = getRectanglePointFromCenter(width: width, height: hight)
        return CGRect(x: point.x, y: point.y, width: width, height: hight)
    }

    private func getRectanglePointFromCenter(width: CGFloat, height: CGFloat) -> CGPoint {
        let screenSize = UIScreen.main.bounds.size
        let center = CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
        let x = center.x - (width / 2)
        let y = center.y - (height / 2)
        return CGPoint(x: x, y: y)
    }
}

もちろん丸だったり、三角だったり、他の形状のパスでも同じことはできます。それから、出したい場所についても、今回はセンターに限定した形の実装になっちゃってますが、好きな場所に出せるようにすればもっと良い感じになるかなーと思います。

まとめ

いかがだったでしょうか、even-odd rule algorithm。
もしかしたらデザイナーの方とかには当たり前の知識なのかな?と思ったりしました。
私は、なんとなーくつかって「お、なんか知らんけど実現した!よし!」みたいになっていた可能性大でしたが、「eoFill」ってなんやと思って深堀したら、思いのほか面白いアルゴリズムに出会えたので、とてもよかったです😊
面白いことはどこに潜んでいるかわかりませんね〜。

参考:

https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/user-interface/shapes/fillrules
https://stackoverflow.com/questions/46017839/how-does-fill-rule-evenodd-work-on-a-star-svg

PS:エバ楽しみ。有給とらなくては。

Discussion

ログインするとコメントできます