본문 바로가기

iOS/Deep dive!!!

[iOS] CALayer의 mask 프로퍼티와 gradient animation 파해치기!!

 

안녕하세요.

 

이번 포스트는 CALayer의 mask와 gradient 주는 방법과 CABasicAnimation으로 애니메이션 처리하는 방법에 대해서 소개하려고 합니다.

 

 

이런 느낌으로 말입니다!!

 

시작하기 전에 프로젝트 링크 참고해주시면 감사합니다.(깃허브 프로젝트 링크 바로 들여다보기)

 

 

 

위와같이 화면을 구성하기 위해 알아야 할 개념은 3가지 입니다.

 

  • CALayer와 mask
  • CABasicAnimation (keyPath: locations)
  • UIGraphicsImageRenderer(size:)

CALayer와 Mask

https://developer.apple.com/documentation/quartzcore/calayer/1410861-mask

 

CALayer는 UIView와 같이 아래 사진처럼 계층구조를 가질 수 있습니다.

 

 

CALayer는 mask란 프로퍼티가 있습니다. 이는 alpha channel을 통해서 layer's content를 활용해 마스킹을 할 수 있습니다. 여기서 alpha는 투명도를 의미합니다. 픽셀의 alpha에 따라서 마스킹 됩니다.

 

기본적으로 alpha == 0인 완전히 투명한 픽셀이 있는 mask layer를 지정할 경우 해당 픽셀 위치에서 그 하위 layer, view계층 등 화면에 보이지 않습니다.

 

반대로 alpha == 0이 아닌 일부만 불투명한 그런 픽셀들은 화면에 보여지게 됩니다.

 

mask Layer의 content's로 이미지를 주로 사용하는데, 이미지는 픽셀로 이루어져있고 alpah에 따라서 원하는 부분만 화면에 보여지도록 표현할 수 있습니다.

 

 

사용될 이미지

 

화살표 두 개를 포스트 상단의 동영상과 같이 만들기 화면에 보여주기 위해서, 사진의 배경 alpha = 0인 부분과 화살표 부분만 alpha != 0으로 이루어진 사진이 필요합니다.

 

다음은 mask layer를 생성하는 코드입니다.

 

final class AnimatedChevronView: UIView {
  private var isGradientLayerMaskSet = false
  override func layoutSubviews() {
    super.layoutSubviews()
    // 1.
    if !isGradientLayerMaskSet {
      isGradientLayerMaskSet.toggle()
      // 2.
      let maskLayer: CALayer = {
        $0.backgroundColor = UIColor.clear.cgColor
        $0.frame = CGRect(
          x: bounds.origin.x + bounds.width/2 - 14,
          y: bounds.origin.y + 20, 
          width: 28, height: 40)
        // 3.
        guard let image = UIImage(named: "double-chevron") else { return .init() }
        $0.contents = image.cgImage
        $0.fillMode = .backwards
        return $0
      }(CALayer())
      
    }
  }
}

 

 

  1. layoutSubviews() 함수는 뷰의 생명주기에서 자주 호출되는 메서드입니다. 저는 한 번만 mask layer를 초기화 해주고 싶기 때문에 bool을 통해서 단 한번만 호출되도록 처리했습니다.
  2. maskLayer를 생성하고, frame을 통해 AnimatedChevronView의 어느 위치에 보여질 것인지 position을 지정합니다.(필수!)
  3. assets에 정의한 더블 화살표 이미지를 불러와서 layer의 contents로 이미지를 지정합니다.

 

cf. UIImageView의 image와 CALayer's contents를 통해 이미지를 화면에 보여주는 방식이 크게 두 가지가 있습니다. 이미지뷰는 마찬가지로 루트 layer를 갖고 있기 때문입니다. 뭐가 더 일반적으로 성능이 좋을지 궁금했습니다. 상황마다 다른데 UIImageView는 간편하게 사용할 수 있습니다. 상황마다 이미지를 어떻게 활용할지 다르기에,, 프로파일링으로 성능 비교하는게 가장 정확하다는 글이 많았습니다.

 

다시 돌아와서.. 이렇게 contents로 image가 담긴 layer를 상위 레이어한테 등록하면 됩니다!! 이때 gradient animation을 주기 전에 gradient layer를 생성했습니다.

 

final AnimatedChevronView: UIView {
  private let gradientLayer: CAGradientLayer = {
    $0.startPoint = CGPoint(x: 0.5, y: 0)
    $0.endPoint = CGPoint(x: 0.5, y: 1)
    $0.colors = [UIColor.black.cgColor, UIColor.white.cgColor, UIColor.black.cgColor]
    $0.locations = [0.1, 0.4, 0.9]
    return $0
  }(CAGradientLayer())
  ...
}

 

마찬가지로 CALayer를 상속받은 CAGradientLayer를 사용해 그라디에이션을 지정했습니다. StartPoint는 colors와 locations에 기반한 그라디에이션이 시작될 위치를 나타내고 endPoint는 그라디에이션이 끝날 위치를 표시합니다. 

 

 

locations에 따라 그라데이션 색 변화가 일어납니다. (이때 살짝 물 부은 느낌?,,)

 

 

만약 mask layer를 추가하지 않았다면 오른쪽과 같이 적용됩니다.

 

그리고 다시 layoutSubviews에 maskLyaer인스턴스 생성한 곳에 아래와 같은 코드를 추가하면 왼쪽과 같이 mask가 적용되서 maskLayer 컨텐츠의 alpha == 0인 부분의 픽셀들은 화면에 보여지지 않게 됩니다. 

 

final class AnimatedChevronView: UIView {
  ...
  func layoutSubviews() {
  ...
  gradientLayer.mask = maskLayer
  gradientLayer.frame = bounds
  }  
  ...
}

 

 

이렇게 gradient layer 프로퍼티에 마스킹이 적용됬지만 아직 에니메이션은 주지 않았습니다. 그리고 그 전에 gradientLayer가 AnimatedChevronView 인스턴스 생성시에 보여지려면 AnimatedChevronView's layer에 sublayer로 추가를 해야합니다.

CABasicAnimation을 통해 gradient colors, locations 애니메이션 주기

애니메이션을 주기 위해서는 CABasicAnimation을 사용해야합니다. keyPath를 통해 UIView에서 제공하는 애니메이션보다 더 많은 애니메이션, 제어를 할 수 있습니다. 그 중 "locations"를 사용합니다.

 

final class AnimatedChevronView: UIView {
  ...
  override func didMoveToWindow() {
    super.didMoveToWindow()
    layer.addSublayer(gradientLayer)
    // 1.
    let gradientAnimation: CABasicAnimation = {
     // 2.
      $0.fromValue = [0, 0, 0.35]
      $0.toValue   = [0.36, 1, 1]
      // 3.
      $0.duration  = 4
      // 4.
      $0.repeatCount = Float.infinity
      // 5.
      $0.timingFunction = CAMediaTimingFunction(name: .linear)
      return $0
    }(CABasicAnimation(keyPath: "locations"))
    gradientLayer.add(gradientAnimation, forKey: nil)
  }
}

 

 

 

  1. CABasicAnimation's keypath가 locations인 경우 gardientLayer's locations에 대한 애니메이션을 적용할 수 있습니다.
  2. UIView의 애니메이션처럼 근본은 같습니다. fromValue와 toValue를 활용해 초기 -> 애니메이션 -> 결과를 지정하면 됩니다. 그렇게 시작 할 때 gradient layer's location을 3개 영역으로 지정했기에, 초기에는 [0, 0, 0.35]에서 시작해 [0.36, 1, 1]로 끝나도록 locations을 변화하도록 지정했습니다.
  3. 이때 위에서 지정한 locations의 변화는 4초로 지정했습니다.
  4. 무한반복을 합니다.
  5. UIView's animate()함수에선 options 매개변수에서 ease를 지정할 수 있듯 CABasicAnimation에서도 ease를 timingFunction으로 지정할 수 있습니다.

 

 

add(_:forKey:)를 통해 지정한 애니메이션을 등록하면 AnimatedChevronView에서 애니메이션이 동작됩니다.

 

텍스트에 gradientLayer와 mask를 활용한 애니메이션

텍스트도 이미지와 같이 간단합니다. 텍스트의 배경색은 alpha = 0으로 제거한 후 텍스트를 이미지로 변환하면 됩니다. 이때 UIGraphicsBeginImageContextWithOptions 보다 UIGraphicsImageRenderer 객체 사용하면 포멧에 맞게 최적화된 이미지를 반환해줍니다. 특히 이번 프로젝트는 흑백의 텍스트만 사용하기 때문입니다.

 

 

final class AnimatedLabel: UILabel {
  ...
  override func layoutSubviews() {
    super.layoutSubviews()
    gradientLayer.frame = CGRect(
      x: -bounds.size.width, y: bounds.origin.y,
      width: bounds.size.width*3, height: bounds.size.height)
    let maskLayer = CALayer()
    maskLayer.backgroundColor = UIColor.clear.cgColor
    maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
    let image = UIGraphicsImageRenderer(size: bounds.size)
      .image { _ in
        (attributedText ?? .init(string: "")).draw(in: bounds)
      }
    maskLayer.contents = image.cgImage
    gradientLayer.mask = maskLayer
  }
}

 

더블 화살표와 코드가 다른 점은 maskLayer's contents에 주입할 이미지를 텍스트를 UIGraphicsImageRenderer를 통해 만들었다는 것 입니다.

또 다른점은 외부에서 AnimatedLabel.text를 변경할 때 intrinsicContentSize가 변하게됩니다. 이때 layoutSubview()가 호출되어 그 텍스트 크기에 맞게 새롭게 mask가 씌워집니다. 

 

 

지금 코드는 추가적으로 2초마다 텍스트를 변경한 것입니다.

 

이번 프로젝트에서 AniamtedLabel 객체를 구현할 때 초기 지정된 텍스트에 대해서 자연스럽게 그라디에이션을 주기 위해서 gradientLayer의 가로 크기를 크게하고 maskLayer를 gradientLayer의 가로 크기는 1/3크기로 정 가운데에 위치하도록 position했습니다. 그래서 초기 텍스트 지정 후 새롭게 텍스트를 지정한다면 위와 같은 동작이 발생됩니다.

 

 

 

만약 서버에서 텍스트를 받을 때마다 변경되야 한다면 gradientLayer를 bounds에 딱 맞게, maskLayer도 bounds에 딱 맞게 변경하면 위와 같이 변경됩니다.

 

 

CALayer의 masksToBounds는 bounds크기에 맞게 mask를 하는 것입니다. 그러나 원하는 이미지, path, pattern에 맞게 특정 영역만 화면에 보여주고 싶다면, 위와같이 CALayer의 mask를 사용하면 좋을 것 같습니다. 이미지를 활용하지 않고도 BezierPath를 통해 직접 도형을 그려 특정 영역만 화면에 보여주는 mask에 활용할 수 있습니다.

 

Resources:

https://stackoverflow.com/questions/50588932/uiimageview-uiimage-vs-calayer-content-efficiency

https://medium.com/@peteliev/layer-masking-for-beginners-c18a0a10743

https://developer.apple.com/documentation/quartzcore/calayer/1410861-mask

https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40004514

https://stackoverflow.com/questions/44230796/what-is-the-full-keypath-list-for-cabasicanimation