0

Consider a chess game. A self-contained ViewController and a ChessPieceView appear below. (On my Xcode 10, Playgrounds do not support graphics, hence the use of a full-fledged, if minuscule, project.)

We animate the motion of the chess piece. We would also like to animate the color of a circle surrounding it (for chess tutorials and such).

Here we animate View.backgroundColor. But what we would really like to do is to animate the fill color of the circle. The backgroundColor of the piece will be the fill color of the from/to squares, whatever these are.

//  ViewController.swift
import UIKit
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let rect = CGRect(origin: CGPoint(x: 100, y: 100),
                          size: CGSize(width: 100, height: 100))
        let chessPieceView = ChessPieceView.init(fromFrame: rect)
        self.view.addSubview(chessPieceView)
    }
}


//  ChessPieceView.swift
import UIKit
class ChessPieceView: UIView {
    init(fromFrame frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.cyan
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func draw(_ rect: CGRect) {
        let circle = UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
                                  radius: 30,
                                  startAngle: 0, endAngle: CGFloat.pi*2,
                                  clockwise: false)
        UIColor.red.setFill()
        circle.fill()
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        addGestureRecognizer(UITapGestureRecognizer(target: self,
                                                    action: #selector(tapPiece(_ :))))
    }
    @objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
        guard gestureRecognizer.view != nil else { return }
        if gestureRecognizer.state == .ended {
            let animator = UIViewPropertyAnimator(duration: 1.2,
                                                  curve: .easeInOut,
                                                  animations: {
                                                    self.center.x += 100
                                                    self.center.y += 200
            })
            animator.startAnimation()
            UIView.animate(withDuration: 1.2) {
                self.backgroundColor = UIColor.yellow
            }
        }
    }
}

The difficulty appears that one "can't animate the fill color of a UIBezierPath directly". That is because, it seems, iOS does not re-render (aka re-scan-convert) the path at all. When an animation is requested, a (relatively cheap) composition operation is performed instead.

Sam
  • 563
  • 5
  • 15

2 Answers2

1

First, layoutSubviews() is absolutely NOT the place to put addGestureRecognizer() ... that should be done during initialization.

Second, you'll probably have much better results by using a CAShapeLayer instead of overriding draw().

Give this a try:

class ChessPieceView: UIView {
    let shapeLayer = CAShapeLayer()
    
    init(fromFrame frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.cyan
        layer.addSublayer(shapeLayer)
        shapeLayer.fillColor = UIColor.red.cgColor
        addGestureRecognizer(UITapGestureRecognizer(target: self,
                                                    action: #selector(tapPiece(_ :))))
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        let r: CGFloat = 30
        let rect = CGRect(x: bounds.midX - r, y: bounds.midY - r, width: r * 2, height: r * 2)
        shapeLayer.path = UIBezierPath(ovalIn: rect).cgPath
    }
    @objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
        guard gestureRecognizer.view != nil else { return }
        if gestureRecognizer.state == .ended {
            let animator = UIViewPropertyAnimator(duration: 1.2,
                                                  curve: .easeInOut,
                                                  animations: {
                                                    self.center.x += 100
                                                    self.center.y += 200
                                                    self.backgroundColor = UIColor.yellow
                                                  })
            animator.startAnimation()
        }
    }
}

Edit

Re-reading your question, it looks like you want to animate the fill-color of the circle.

In that case, you can use the shape layer as a mask. This class will draw a red-filled circle... tapping it will animate its position and animate the red-to-yellow color change:

class ChessPieceView: UIView {
    let shapeLayer = CAShapeLayer()
    
    init(fromFrame frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.red
        shapeLayer.fillColor = UIColor.black.cgColor
        addGestureRecognizer(UITapGestureRecognizer(target: self,
                                                    action: #selector(tapPiece(_ :))))
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        let r: CGFloat = 30
        let rect = CGRect(x: bounds.midX - r, y: bounds.midY - r, width: r * 2, height: r * 2)
        shapeLayer.path = UIBezierPath(ovalIn: rect).cgPath
        layer.mask = shapeLayer
    }
    @objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
        guard gestureRecognizer.view != nil else { return }
        if gestureRecognizer.state == .ended {
            let animator = UIViewPropertyAnimator(duration: 1.2,
                                                  curve: .easeInOut,
                                                  animations: {
                                                    self.center.x += 100
                                                    self.center.y += 200
                                                    self.backgroundColor = UIColor.yellow
                                                  })
            animator.startAnimation()
        }
    }
}

Edit 2

This example uses a subview to provide a cyan rectangle with a round red view in the center... each tap will animate the position of the rectangle, and the color of the circle in its center:

class ChessViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let rect = CGRect(origin: CGPoint(x: 50, y: 100),
                          size: CGSize(width: 100, height: 100))
        let chessPieceView = ChessPieceView.init(fromFrame: rect)
        self.view.addSubview(chessPieceView)
    }
}


//  ChessPieceView.swift

class ChessPieceView: UIView {
    let circleView = UIView()
    let shapeLayer = CAShapeLayer()
    var startCenter: CGPoint!
    
    init(fromFrame frame: CGRect) {
        super.init(frame: frame)
        startCenter = self.center
        backgroundColor = UIColor.cyan
        addSubview(circleView)
        circleView.backgroundColor = .red
        addGestureRecognizer(UITapGestureRecognizer(target: self,
                                                    action: #selector(tapPiece(_ :))))
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        let r: CGFloat = 30
        circleView.frame = CGRect(x: bounds.midX - r, y: bounds.midY - r, width: r * 2, height: r * 2)
        circleView.layer.cornerRadius = r
    }
    @objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
        guard gestureRecognizer.view != nil else { return }
        if gestureRecognizer.state == .ended {
            let targetCenter: CGPoint = self.center.x == startCenter.x ? CGPoint(x: startCenter.x + 100, y: startCenter.y + 200) : startCenter
            let targetColor: UIColor = self.circleView.backgroundColor == .red ? .yellow : .red
            let animator = UIViewPropertyAnimator(duration: 1.2,
                                                  curve: .easeInOut,
                                                  animations: {
                                                    self.center = targetCenter
                                                    self.circleView.backgroundColor = targetColor
                                                  })
            animator.startAnimation()
        }
    }
}

Result:

enter image description here

DonMag
  • 69,424
  • 5
  • 50
  • 86
  • All good, but the `self.backgroundColor = UIColor.yellow` at the end of your code is executed immediately (which we know to expect), but it also takes place immediately. The `backgroundColor` does not fade from the old to the new values during the animation. – Sam Nov 15 '20 at 01:01
  • @Sam - maybe I'm not completely understanding your goal? There are various ways to animate positions and color changes (along with other properties)... I put up another approach, using a subview for the center "circle". See **Edit 2** in my answer (including a capture of it in action). – DonMag Nov 15 '20 at 13:53
0

Correct, the manually rendered UIBezierPath is not directly animatable.

You have two options:

  1. Instead of a view with a custom draw(_:) that strokes/fills a path, instead add a CAShapeLayer and set its path. The fillColor of that shape layer is animatable (via CABasicAnimation).

  2. If you want to animate a view with a manual draw(_:) implementation (or animate any non-animatable property), you can call transition(with:duration:options:animations:completion:). It basically takes a snapshot of the “before” view, and animates the transition from that snapshot to the new rendition.

Generally we would use the CAShapeLayer approach, but I provide both options for your reference.

Rob
  • 415,655
  • 72
  • 787
  • 1,044