xyk blog

最近は iOS 開発の記事が多めです。

Swift で UIView を穴を開ける

検証環境:
Xcode 11.4
Swift 5.2

Swift で UIView の穴を開ける実装を Playground で試してみます。
今回のサンプルはまず画像を配置し、その上に UIView を被せます。
被せた UView の内部に穴を開けることで下の画像が見えるようにします。
また、ついでに画像上に三分割法のグリッドと、枠線も入れてみます。

f:id:xyk:20200820173543p:plain

サンプルコード

import UIKit
import PlaygroundSupport

class CutoutView: UIView {
    
    let cutoutLayer: CALayer = {
        let cutoutLayer = CALayer()
        cutoutLayer.backgroundColor = UIColor.gray.cgColor
        cutoutLayer.opacity = 0.5
        return cutoutLayer
    }()
    
    let maskLayer = CAShapeLayer()
    
    let borderLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.lineWidth = 2
        shapeLayer.strokeColor = UIColor.white.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        return shapeLayer
    }()
    
    let gridLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.lineWidth = 0.5
        shapeLayer.strokeColor = UIColor.white.cgColor
        return shapeLayer
    }()

    var cutoutPath: UIBezierPath?

    override init(frame: CGRect) {
        super.init(frame: frame)
        
        layer.backgroundColor = UIColor.clear.cgColor
        
        layer.addSublayer(cutoutLayer)
        layer.addSublayer(borderLayer)
        layer.addSublayer(gridLayer)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        updateLayer()
    }
    
    private func updateLayer() {
        
        let maskPath = UIBezierPath(rect: bounds)
        
        // くり抜くサイズを指定。今回は比率 16:9 の矩形にする
        let width = bounds.insetBy(dx: 16, dy: 0).width
        let height = width * (9 / 16)
        let x = bounds.midX - width / 2
        let y = bounds.midY - height / 2
        let cutoutRect = CGRect(x: x, y: y, width: width, height: height)
        let cutoutPath = UIBezierPath(rect: cutoutRect)
        maskPath.append(cutoutPath)
        
        maskLayer.fillRule = .evenOdd
        maskLayer.path = maskPath.cgPath
        
        cutoutLayer.frame = bounds
        cutoutLayer.mask = maskLayer
        
        borderLayer.path = cutoutPath.cgPath
        
        addGridPath(x: x, y: y, width: width, height: height)

        self.cutoutPath = cutoutPath
    }
    
    private func addGridPath(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) {
        let gridPath = UIBezierPath()
        
        gridPath.move(to: .init(x: x + (width * 0.33), y: y))
        gridPath.addLine(to: .init(x: x + (width * 0.33), y: y + height))

        gridPath.move(to: .init(x: x + (width * 0.66), y: y))
        gridPath.addLine(to: .init(x: x + (width * 0.66), y: y + height))
        
        gridPath.move(to: .init(x: x, y: y + (height * 0.33)))
        gridPath.addLine(to: .init(x: x + width, y: y + (height * 0.33)))
        
        gridPath.move(to: .init(x: x, y: y + (height * 0.66)))
        gridPath.addLine(to: .init(x: x + width, y: y + (height * 0.66)))
        
        gridLayer.path = gridPath.cgPath
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if let cutoutPath = cutoutPath, cutoutPath.contains(point) {
            return nil
        }
        return bounds.contains(point) ? self : nil
    }
}

class MyViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemTeal
        
        addImageView()
        addCutoutView()
    }
    
    private func addImageView() {
        let image = UIImage(named: "0.jpg")!
        let imageView = UIImageView(image: image)
        imageView.contentMode = .scaleAspectFit
        view.addSubview(imageView)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            imageView.widthAnchor.constraint(equalToConstant: 300),
            imageView.heightAnchor.constraint(equalToConstant: 300),
        ])
    }
    
    private func addCutoutView() {
        let cView = CutoutView()
        view.addSubview(cView)
        cView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            cView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            cView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            cView.widthAnchor.constraint(equalToConstant: 300),
            cView.heightAnchor.constraint(equalToConstant: 300),
        ])
    }
}

PlaygroundPage.current.liveView = MyViewController()

マスク用の CAShapeLayer の fillRule は .evenOdd にします。
これにより穴の開いた矩形を作ることができます。

今回は穴を開けた部分にグリッドを追加しますが、Root layer をマスクすると子 layer すべてが影響を受け、マスクされてしまうので、穴を開けた layer は別 layer として Root layer に追加しています。

hitTest メソッドをオーバーライドし、穴が空いた部分のタッチイベントは nil を返すようにして、下のビューにタッチイベントをスルーしています。

参考:

Shape Fill Mode Values
https://developer.apple.com/documentation/quartzcore/cashapelayer/shape_fill_mode_values