Créer une "CALayer Animatable property" personnalisée sur iOS

le 30/08/2019 par Paul Bancarel
Tags: Software Engineering, Mobile

Cet article faisant suite aux deux précédents de Renaud Cousin (liens en bas de page), nous continuons à explorer les animations sur iOS et nous allons voir comment animer une propriété custom d'une vue. Si on peut bien accorder une chose à iOS c'est que son framework graphique (UIKit) est très bien pensé. Pour faire varier la couleur d'une vue en l'animant, on peut tout simplement faire:

view.backgroundColor = UIColor.red
UIView.animate(withDuration: 1.0) {
    view.backgroundColor = UIColor.blue
}

Résultat:

Simple et efficace. Cool pas vrai ? ☺️

Et si on créait notre propre propriété

Bien qu'UIKit nous donne un certain nombre de propriété "animable": backgroundColorframeopacityposition pour les plus connues.

Voir la liste exhaustive ici

Il serait intéressant de pouvoir en créer une nous même. Quelque chose dans ce genre là:

UIView.animate(withDuration: 1.0) {
    view.distortion = 1.0
}

Où la distortion serait une propriété qui déforme notre UIView et dont la valeur varie de 0.0 à 1.0.

À noter que cette propriété serait pleinement compatible avec le framework UIKit, on pourrait donc s'amuser à faire des variations de la sorte sans problème.

UIView.animate(
    withDuration: 1.0,
    delay: 0.0,
    usingSpringWithDamping: 0.4,
    initialSpringVelocity: 0.6,
    options: [],
    animations: {
        view.distortion = 1.0
    }, completion: nil)

1. Commençons

Comme vous le savez une UIView s’occupe de la mise en page, de la gestion des événements tactiles. En revanche elle ne s’occupe pas directement du dessin ou des animations. UIKit délègue cette tâche à CoreAnimationUIView est en fait juste un wrapper sur CALayer. Lorsque vous définissez une taille sur votre UIView, la vue définit simplement la taille sur sa couche de support CALayer. Si vous appelez layoutIfNeeded sur une UIView, l’appel est transféré à la couche racine CALayer. Chaque UIView a une couche CALayer racine, qui peut contenir des sous-couches.

Enfin un CALayer possède une propriété presentationLayer qui retourne une copie de lui même représentant l'état de la couche qui est actuellement affichée à l'écran (utile lors d'une animation par exemple).

2. Définissez votre "layer"

Nous allons donc commencer par créer notre propre Layer.

class DistortionLayer: CAShapeLayer {
    @NSManaged var distortion: CGFloat
    
    override init() {
        super.init()
    }
    
    override init(layer: Any) {
        super.init(layer: layer)
        if let layer = layer as? DistortionLayer {
            distortion = layer.distortion
        }
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    private class func isCustomAnimKey(_ key: String) -> Bool {
        return key == "distortion"
    }
    
    override class func needsDisplay(forKey key: String) -> Bool {
        if self.isCustomAnimKey(key) {
            return true
        }
        return super.needsDisplay(forKey: key)
    }
    
    override func action(forKey event: String) -> CAAction? {
      guard DistortionLayer.isCustomAnimKey(event) else { return super.action(forKey: event) }
        
      guard let action = super.action(forKey: #keyPath(backgroundColor)) as? CAAnimation,
                let animation: CABasicAnimation = (action.copy() as? CABasicAnimation) else {
                        setNeedsDisplay()
                        return nil
               }
        
      if let presentationLayer = presentation() {
          animation.fromValue = presentationLayer.distortion
      }
      animation.keyPath = event
      animation.toValue = nil
      return animation
    }
}
  1. Notez la présence du @NSManaged qui permet de prévenir le compilateur que cette propriété sera initialisé au runtime.
  2. Dans la méthode action nous nous basons sur l'animation de la propriété backgroundColor et en faisons une copie, libre à vous de définir une CABasicAnimation ou de copier l'animation d'une autre propriété déjà existante. (Nota bene: vous n'êtes pas obligé d'animer la propriété backgroundColor pour que l'animation distortion soit effective).

3. Définissez votre vue

class DistortionView: UIView {
    var distortion: CGFloat {
      set {
       (layer as? DistortionLayer)?.distortion = newValue
      }
      get {
       return (layer as? DistortionLayer)?.distortion ?? 0
      }
    }

    override class var layerClass: AnyClass {
        return DistortionLayer.self
    }

    override func display(_ layer: CALayer) {
        guard let presentationLayer = layer.presentation() as? DistortionLayer else { return }
        guard let castLayer = layer as? CAShapeLayer else { return }

        let width = frame.width
        let height = frame.height
        let distortionValue = (max(width, height)/8) * presentationLayer.distortion
        let x0 = CGPoint(x: 0, y: 0)
        let p0 = CGPoint(x: width/2, y: 0 + distortionValue)
        let x1 = CGPoint(x: width, y: 0)
        let p1 = CGPoint(x: width - distortionValue, y: height/2)
        let x2 = CGPoint(x: width, y: height)
        let p2 = CGPoint(x: width/2, y: height - distortionValue)
        let x3 = CGPoint(x: 0, y: height)
        let p3 = CGPoint(x: 0 + distortionValue, y: height/2)

        let path = UIBezierPath()
        path.move(to: x0)
        path.addQuadCurve(to: x1, controlPoint: p0)
        path.addQuadCurve(to: x2, controlPoint: p1)
        path.addQuadCurve(to: x3, controlPoint: p2)
        path.addQuadCurve(to: x0, controlPoint: p3)
        path.close()

        let maskShape = CAShapeLayer()
        maskShape.path = path.cgPath
        castLayer.mask = maskShape
   }
  1. Dans la fonction display vous pouvez utiliser la propriété distortion qui varie entre 0.0 et 1.0 (durant l'animation) pour dessiner votre vue. Ici en l'occurrence on dessine un carré en faisant varier les points de Bézier afin de déformer sa structure.
  2. N'oubliez pas de surcharger la layerClass de votre vue par votre custom layer avec override class var layerClass: AnyClass

4. Animez votre propriété

Comme nous le remarquions plus haut, cette propriété est pleinement compatible avec les APIs UIKit. On peut donc jouer sur deux propriétés par exemple.

self.distortionView.backgroundColor = UIColor.red
self.distortionView.distortion = 0
UIView.animate(withDuration: 2.0) {
    self.distortionView.backgroundColor = UIColor.blue
    self.distortionView.distortion = 1
}

Résultat:

Ou bien ajouter un damping et une vélocité:

self.distortionView.backgroundColor = UIColor.red
self.distortionView.distortion = 0
UIView.animate(withDuration: 1.0,
    delay: 0.0,
    usingSpringWithDamping: 0.4,
    initialSpringVelocity: 0.6,
    options: [],
    animations: {
        self.distortionView.distortion = 1
        self.distortionView.backgroundColor = UIColor.blue
}, completion: nil)

Résultat:

Ou ajouter une completion

self.distortionView.backgroundColor = UIColor.red
self.distortionView.distortion = 0
UIView.animate(withDuration: 0.3,
                 delay: 0.0,
                 options:[.curveEaseOut, .autoreverse],
                 animations: {
                      self.distortionView.distortion = 1
                      self.distortionView.backgroundColor = UIColor.blue
},completion: { finished in
                  if finished == true {
                       self.distortionView.distortion = 0
                       self.distortionView.backgroundColor = UIColor.red
                  }
})

Résultat:

Le fait que la propriété soit compatible avec les APIs UIKit nous permet de modifier très simplement l'animation de notre vue.

https://blog.octo.com/pourquoi-et-comment-faire-des-animations-sur-ios-coreanimation/

https://blog.octo.com/les-animations-sur-ios-par-la-pratique-careplicatorlayer/