본문 바로가기

iOS

[CoreAnimation] Layer Masking (feat. 키 모양 만들기)

 

안녕하세요! 오늘은 레이어 마스킹에 대해서 알아보려 합니다. 트러블 슈팅도 조금 있습니다.

 

뷰의 일부를 잘라서 보여주고 싶은 애니메이션 구현 내용이 있었습니다. 보통 뷰의 모서리부분을 둥글게 자르는 프로퍼티로 layer.cornerRadius와 maskToBounds를 사용했습니다. 하지만 저희가 완전히 원하는 모양으로 잘라서 일부만 보여주고 싶은 상황이었습니다.

 

이 상황은 layer mask 프로퍼티를 이용해서 해결할 수 있었습니다. 

 

애플에선 이렇게 설명을 합니다. 

 

An optional view whose alpha channel is used to mask a view’s content.

 

뷰의 콘텐츠를 마스킹하는데 알파 채널을 사용하는 선택적 뷰라고 해석할 수 있는데 뷰의 일부만을 보여주고 싶을 때 사용하는 뷰라고 이해할 수 있습니다. 위의 정의는 UIView의 프로퍼티인 mask였는데 CALayer의 프로퍼티에도 mask가 있고 같은 의미로 해석이 됩니다. 

 

그럼 사용해보기 위해서 뷰하나를 준비했습니다.

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        let size = CGSize(width: 200, height: 200)
        let frame = CGRect(
            origin: .init(x: view.center.x-size.width/2,
                          y: view.center.y-size.height/2),
            size: size
        )

        let maskView = MaskingView(frame: frame)
        view.addSubview(maskView)
    }
}

class MaskingView: UIView {

    init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .purple
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

보통 이 사각형을 둥글게 만들기 위해서는 layer.cornerRadius를 줘서 만들지만 이번엔 마스킹을 이용해볼게요.

layer의 mask 프로퍼티는 CALayer 클래스입니다. 여기서, CALayer를 상속받는 CAShapeLayer를 이용해서 쉽게 레이어를 그릴 수 있습니다. CAShapeLayer의 path를 이용해서 둥근 모양을 만듭니다. 순서는 아래와 같습니다.

 

1. 만들고 싶은 원형의 CGRect를 구합니다. (draw를 이용한다면 rect 매개변수를 이용할 수 있습니다)

2. UIBezierPath와 위에서 구한 CGRect를 이용해서 보여질 콘텐츠의 모양을 만듭니다.

3. 위에서 구한 path를 이용해서 CAShapeLayer를 생성합니다.

4. 마스킹할 레이어의 mask 프로퍼티에 할당합니다. 

 

이를 코드로 구현하면 아래와 같습니다. 아래 코드에서는 UIBezierPath에 append 함수를 이용했는데 여러개의 path를 추가해서 마스킹해줄 수 있습니다. 

override func draw(_ rect: CGRect) {
    let maskLayer = CAShapeLayer()
    let bezierPath = UIBezierPath()

    bezierPath.append(UIBezierPath(ovalIn: rect))
    maskLayer.path = bezierPath.cgPath

    layer.mask = maskLayer
}

시뮬레이터를 보면 구현한 의도대로 뷰가 둥글게 잘라져서 일부만 나오는 것을 볼 수 있습니다. 

 

이걸 만들고 있었는데 친구가 옆에서 키도 만들 수 있냐고 물어봤습니다. 

 

키요..?

 

키??

키 양 옆에 이빨이 많이 튀어나와있고 손잡이 부분은 구멍이 뚫려있습니다. 

 

먼저, 아까처럼 원을 손잡이로 만들고 거기에 얖 옆 이빨 3개의 path와 키의 몸을 담당하는 path를 추가해줄 것입니다. 그리고 key의 위치는 view의 중앙에 오도록 할 것인데 width와 height는 제가 임의로 주겠습니다. 

 

키 몸을 담당하는 부분의 CGRect를 구하기 위해서는 (손잡이가 시작하는 x + (손잡이 너비 - 키몸 너비) / 2) 를 계산했습니다..!

override func draw(_ rect: CGRect) {
    let maskLayer = CAShapeLayer()
    let bezierPath = UIBezierPath()


    let handleRect = CGRect(origin: .init(x: 50, y: 0), size: .init(width: 100, height: 100))
    let handlePath = UIBezierPath(ovalIn: handleRect)

    let keyWidth: CGFloat = 40
    let bodyRect = CGRect(origin: .init(x: 50+(100-keyWidth)/2, y: 60), size: .init(width: keyWidth, height: bounds.height-50))
    let bodyPath = UIBezierPath(rect: bodyRect)

    let rightTeethRect1 = CGRect(origin: .init(x: 50+keyWidth+(100-keyWidth)/2, y: 120), size: .init(width: 30, height: 20))
    let rightPath1 = UIBezierPath(rect: rightTeethRect1)
    let rightTeethRect2 = CGRect(origin: .init(x: 50+keyWidth+(100-keyWidth)/2, y: 150), size: .init(width: 20, height: 20))
    let rightPath2 = UIBezierPath(rect: rightTeethRect2)

    let leftTeethRect = CGRect(origin: .init(x: 50+(100-keyWidth)/2-20, y: 130), size: .init(width: 20, height: 20))
    let leftPath = UIBezierPath(rect: leftTeethRect)

    bezierPath.append(handlePath)
    bezierPath.append(bodyPath)
    bezierPath.append(rightPath1)
    bezierPath.append(rightPath2)
    bezierPath.append(leftPath)
    
    maskLayer.path = bezierPath.cgPath
    layer.mask = maskLayer
}

그럼 일단 키 비스므레한 물체는 만들 수 있습니다. 하지만 친구가 그려준 키가 되기 위해서는 손잡이 중간에 원을 뚫어야합니다. 

 

근데 아무리 생각을 해봐도 중간에 뚫린 모양을 만들려면 마스킹만으로는 부족해보였습니다.

레이어를 더 얹어서 가운데가 뚫린 것같이 느낌을 줄 수 있는 방법밖에 떠오르지 않았습니다. 

 

그래서 조금 찾아보다가 인터넷에서 CALayer의 fillRule 프로퍼티를 .evenOdd로 설정하면 된다는 글을 봤습니다. 

 

https://stackoverflow.com/questions/68144024/using-swift-and-cashapelayer-with-masking-how-can-i-avoid-inverting-the-mask

 

Using Swift and CAShapeLayer() with masking, how can I avoid inverting the mask when masked regions intersect?

This question was challenging to word, but explaining the situation further should help. Using the code below, I'm essentially masking a circle on the screen wherever I tap to reveal what's underne...

stackoverflow.com

 

이 프로퍼티의 정의를 해석해봅시다.

 

Specifies the even-odd winding rule. Count the total number of path crossings. If the number of crossings is even, the point is outside the path. If the number of crossings is odd, the point is inside the path and the region containing it should be filled.

 

짝수-홀수 규칙입니다. 위 해석을 이해하자면, 만약 중첩된 곳이 짝수라면, 그 부분은 뚫리고, 홀수라면 구 부분은 그대로 남아있는다. 라는 내용이었습니다.

 

그럼, 이제 아래와 같이 진행해주었습니다.

 

1. 마스킹 레이어의 fillRule을 evenOdd로 설정합니다.

2. 손잡이구멍의 Rect를 구하고 UIBezierPath를 하나 더 생성합니다.

3. 마스킹 레이어의 path에 위에서 구한 UIBezierPath를 추가합니다.

 

코드로 작성해보았습니다.

 

중앙을 뚫어줄 지름이 60인 원을 중앙에 위치하도록 origin을 계산해주었습니다..! 위 코드에 아래 네줄을 추가했습니다. 

let roundRect = CGRect(origin: .init(x: 70, y: 20), size: .init(width: 60, height: 60))
let roundPath = UIBezierPath(ovalIn: roundRect)
bezierPath.append(roundPath)
maskLayer.fillRule = .evenOdd

 

음.. 웬 이상한 그림이 나왔습니다.. 키의 손잡이와 몸대가 겹쳐있었기 때문에 evenOdd 규칙에 의해서 레이어가 3개 겹쳐진 부분은 콘텐츠가 그대로 보이고 2개의 레이어가 겹친 부위는 뚫립 모양이 나왔습니다. 이건 키의 몸대를 내리면 되겠다라고 생각할 수 있겠지만.. 그럼 키의 모양이 나오지 않습니다.

 

키의 몸대를 겹치지 않게 끝까지 내리게 된다면 손잡이와 연결된 모양이 아니라 직사각형 위에 원이 얹혀진 모양일테고 연결되도록 조금 겹친다면 evenOdd 규칙때문에 비어 보일테고.. 정말 난제입니다.

 

그래서 손잡이부분과 몸대 부분이 합쳐진 키의 path를 직접 그려주었습니다. 

손잡이 부분은 그대로 UIBezierPath(ovalIn:) initializer를 사용해주고, 몸대부분은 addLine(to:) 함수를 이용했습니다. 원의 호를 그리는 부분이 난제였는데 원의 중심과 반지름을 알기 때문에 asin을 이용하면 구할 수 있었습니다.

 

코드로 구현하면 아래와 같습니다. 키부분을 나타내는 코드와 바꾸면 됩니다.

let handleRect = CGRect(origin: .init(x: 50, y: 0), size: .init(width: handleWidth, height: handleWidth))
let keyPath = UIBezierPath(ovalIn: handleRect)
keyPath.move(to: .init(x: 50+(100-bodyWidth)/2, y: 100))
keyPath.addLine(to: .init(x: 50+(100-bodyWidth)/2, y: bounds.height))
keyPath.addLine(to: .init(x: 50+(100+bodyWidth)/2, y: bounds.height))
keyPath.addLine(to: .init(x: 50+(100+bodyWidth)/2, y: 100))
keyPath.addArc(withCenter: .init(x: 100, y: 50),
                  radius: 50,
                  startAngle: 66.42 * .pi / 180,
                  endAngle: 113.58 * .pi / 180,
                  clockwise: true)
keyPath.close()
bezierPath.append(keyPath)

 

드디어 원하는 키의 모양이 만들어졌습니다!

 

두둥탁