Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【iOS】チュートリアル画面などで使えるUIViewに穴をあける方法

はじめに

チュートリアル画面などで、画面全体を半透明な黒いUIViewでおおい、一部分をハイライトして、なんらかの説明文言を表示するといったことがあるかと思います。 この際に、ハイライトしたい部分にUIImageなどを上から重ねるのも手としてはありますが、その部分について黒いUIViewに穴を開けられると便利です。 本記事では該当部分に穴を開ける方法と開けたい場所を指定する際に便利な方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.3.0
  • Swift 5.0.1

穴を開ける方法

UIViewlayerに対してmaskを使用することで円形の穴を開けます。 詳細な説明はコードに記載いたしましたが、大まかな流れとしては、mask用のlayerを作成し、そこに円形のPathを作成、 穴を開けたいUIViewlayermaskにmask用に作成したlayerを指定することで穴をあけるとなります。 ここでPathとして円の代わりに星型等を使用すれば星型の穴をあける等も可能です。

なお、UIViewControllerviewDidAppear(_:)などで穴をあけるlayerを追加したところ穴が問題なくあきましたので、 UIViewにextensionでメソッドを作成すると便利に使用できて良いと思います。 当然UIViewを継承しているUIButtonなどでも同様に使用可能です。

public extension UIView {
    
    func makeHole(at point: CGPoint, radius: CGFloat) {
        
        let maskLayer = CAShapeLayer()

        // fillはPathの内部を指定の色で塗りつぶします
        // そのためには内部かどうかの判定が必要になりますが、fillRuleはこの判定方法です
        // .evenOddはある点Pから任意の方向へ無限遠点に射線を引きPathとの交差回数を数えます
        // Pathとの交差回数が奇数回の場合は内側、偶数回の場合は外側と判定します
        // 詳細は参考文献を参照ください
        maskLayer.fillRule = .evenOdd
        maskLayer.fillColor = UIColor.black.cgColor
        
        // 画面全体にPathを描きます
        let maskPath = UIBezierPath(rect: self.frame)
        maskPath.move(to: point)
        
        // addArcで弧形を描画します
        // centerとradiusを指定し、 0.0 ~ 2πで描画するため円形となります
        maskPath.addArc(withCenter: point, radius: radius, startAngle: 0.0, endAngle: 2.0 * CGFloat.pi, clockwise: true)
        
        // 上記から円の内側から無限遠まで射線を引くと、円のPathと画面全体の外縁のPathとで2回交わるため
        // 外側に、円の外側から無限遠まで射線を引くと、奇数回Pathと交わるため内側になります
        // すなわち画面全体のうち円以外の部分が黒色に塗りつぶされます
        maskLayer.path = maskPath.cgPath
        
        // 自身のlayerのmaskとして上記で作成したmask用のlayerを指定しました
        // maskの黒色の部分と重複している箇所の色が残るため、
        // 結果として円の内側は色がなくなり、円形の穴があくことになります
        self.layer.mask = maskLayer
    }
    
}

穴を開けたい場所を指定する際の方法

穴をあける場所に関しては、既存のいづれかのUIViewの上に開けたい場合はその場所を指定するのが良さそうです。 ただ、 view.frameで指定した場合の座標は親のUIViewからの相対位置となるため、画面全体に広げたUIViewからの位置を指定する場合は正しい値となりません。 そのため、以下のように画面全体のViewに対する相対座標を求めるのがおすすめです。

    // MARK: IBOutlet
    @IBOutlet private weak var targetView: UIView!
    @IBOutlet private weak var parentView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        let frame = targetView.frame
        print("frame")
        print(frame)
        
        print("parentView frame")
        print(parentView.frame)
        
        print("converted frame")
        let convertedFrame = parentView.convert(targetView.frame, to: self.view)
        print(convertedFrame)
    }

上記でtargetViewが対象のView、parentViewがtargetViewの親Viewです。 上記の実行結果は下記になります。

frame
(20.0, 20.0, 40.0, 40.0)
parentView frame
(107.0, 348.0, 200.0, 200.0)
converted frame
(127.0, 368.0, 40.0, 40.0)

規定のself.viewからの座標が出力されているのがわかります。 この値を使うことで、画面全体を覆った場合等に適切な箇所に穴をあけることが可能になります。

まとめ

チュートリアルのような画面でUIViewに穴を開けたい際には以下のように行うのがおすすめです。

  • maskLayerをつかって穴を開ける
  • 穴の場所を指定するために基準とするViewからの相対位置を指定する(convertPoint:toView:など)

参考