Good afternoon!
I have a problem with animation in the function addHoleToMovableView.
When trying the slow animation from point A to point B, I have not obtained a satisfactory result. I hope to have a very short animation of the type that moves from one place to another on the y-axis from top to bottom or bottom to top. It's as if it moves from one point to another with defined seconds.
My controller:
import UIKit
class TutorialViewController: UIViewController {
var tutorialView: TutorialView!
var view1: UIView!
var view2: UIView!
var view3: UIView!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
tutorialView = TutorialView()
tutorialView.translatesAutoresizingMaskIntoConstraints = false
tutorialView.backgroundColor = .black.withAlphaComponent(0.7)
setViews()
view.addSubview(tutorialView)
NSLayoutConstraint.activate([
tutorialView.topAnchor.constraint(equalTo: view.topAnchor),
tutorialView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tutorialView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tutorialView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tutorialView.onNextButtonTapped = { [weak self] in
self?.moveMovableViewToCurrentPosition()
}
tutorialView.onPreviousButtonTapped = { [weak self] in
self?.moveMovableViewToCurrentPosition()
}
tutorialView.onOkButtonTapped = { [weak self] in
self?.tutorialView.currentState += 1
self?.removeTutorialViewFromSuperView()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
moveMovableViewToCurrentPosition()
}
private func addHoleToMovableView(frame: CGRect) {
let holeLayer = CAShapeLayer()
let path = UIBezierPath(rect: self.tutorialView.bounds)
let holePath = UIBezierPath(rect: frame)
path.append(holePath.reversing())
holeLayer.path = path.cgPath
holeLayer.fillRule = .evenOdd
self.tutorialView.layer.mask = holeLayer
let animation = CABasicAnimation(keyPath: "patch")
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
holeLayer.add(animation, forKey: "positionAnimation")
}
func setViews() {
view1 = UIView()
view2 = UIView()
view3 = UIView()
view1.translatesAutoresizingMaskIntoConstraints = false
view2.translatesAutoresizingMaskIntoConstraints = false
view3.translatesAutoresizingMaskIntoConstraints = false
view1.backgroundColor = .blue
view2.backgroundColor = .yellow
view3.backgroundColor = .green
view.addSubview(view1)
view.addSubview(view2)
view.addSubview(view3)
NSLayoutConstraint.activate([
view1.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
view1.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view1.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view1.heightAnchor.constraint(equalToConstant: 60),
view2.topAnchor.constraint(equalTo: view1.bottomAnchor, constant: 10),
view2.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
view2.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
view2.heightAnchor.constraint(equalToConstant: 200),
view3.topAnchor.constraint(equalTo: view2.bottomAnchor, constant: 10),
view3.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view3.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view3.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
func removeTutorialViewFromSuperView() {
tutorialView.removeFromSuperview()
view.layoutIfNeeded()
}
func moveMovableViewToCurrentPosition() {
switch tutorialView.currentState {
case 0:
tutorialView.updateControlContainerConstraints(position: .below, referenceFrame: view1.frame)
addHoleToMovableView(frame: view1.frame)
case 1:
tutorialView.updateControlContainerConstraints(position: .below, referenceFrame: view2.frame)
addHoleToMovableView(frame: view2.frame)
case 2:
tutorialView.updateControlContainerConstraints(position: .above, referenceFrame: view3.frame)
addHoleToMovableView(frame: view3.frame)
default:
break
}
}
}
This is currently the "blinking" effect.
To animate a path - whether it's for drawing or to use as a mask - we need to set the .toValue
for the animation.
The keyPath
also needs to be "path" instead of "patch" (but I'm guessing that was a typo):
let animation = CABasicAnimation(keyPath: "path")
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
// fromValue is not strictly necessary here, but we use it for consistency
animation.fromValue = curPath
animation.toValue = newPath
animation.isRemovedOnCompletion = false
// adjust animation speed as desired
animation.duration = 0.75
holeLayer.add(animation, forKey: "positionAnimation")
We also want to use CATransaction
with a completion block, because we need to update the holeLayer.path
when the animation ends.
I think you'll also find it much easier to keep the "hole layer" and animation code inside the overlay view (your TutorialView).
Here is some sample code, based on your example, with modifications:
enum ControlPosition {
case above, below
}
class TutorialViewController: UIViewController {
var tutorialView: TutorialView = TutorialView()
var view1: UIView!
var view2: UIView!
var view3: UIView!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setViews()
tutorialView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tutorialView)
NSLayoutConstraint.activate([
tutorialView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
tutorialView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
tutorialView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
tutorialView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
])
tutorialView.onNextButtonTapped = { [weak self] in
self?.moveMovableViewToCurrentPosition()
}
tutorialView.onPrevButtonTapped = { [weak self] in
self?.moveMovableViewToCurrentPosition()
}
tutorialView.onDoneButtonTapped = { [weak self] in
self?.removeTutorialViewFromSuperView()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// let the translucent tutorial view show up to begin with
// start with the "hole" above the top of the vivew
tutorialView.updateHole(.init(x: 0.0, y: -20.0, width: tutorialView.frame.width, height: 20.0), animated: false, cPos: .above)
// then animate
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
self.moveMovableViewToCurrentPosition()
})
}
func setViews() {
view1 = UIView()
view2 = UIView()
view3 = UIView()
view1.translatesAutoresizingMaskIntoConstraints = false
view2.translatesAutoresizingMaskIntoConstraints = false
view3.translatesAutoresizingMaskIntoConstraints = false
view1.backgroundColor = .blue
view2.backgroundColor = .yellow
view3.backgroundColor = .green
view.addSubview(view1)
view.addSubview(view2)
view.addSubview(view3)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
view1.topAnchor.constraint(equalTo: g.topAnchor, constant: 20),
view1.leadingAnchor.constraint(equalTo: g.leadingAnchor),
view1.trailingAnchor.constraint(equalTo: g.trailingAnchor),
view1.heightAnchor.constraint(equalToConstant: 60),
view2.topAnchor.constraint(equalTo: view1.bottomAnchor, constant: 10),
view2.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 10),
view2.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -10),
view2.heightAnchor.constraint(equalToConstant: 200),
view3.topAnchor.constraint(equalTo: view2.bottomAnchor, constant: 10),
view3.leadingAnchor.constraint(equalTo: g.leadingAnchor),
view3.trailingAnchor.constraint(equalTo: g.trailingAnchor),
view3.bottomAnchor.constraint(equalTo: g.bottomAnchor)
])
}
func removeTutorialViewFromSuperView() {
tutorialView.removeFromSuperview()
view.layoutIfNeeded()
}
func moveMovableViewToCurrentPosition() {
switch tutorialView.currentState {
case 0:
tutorialView.updateHole(view1.frame, animated: true, cPos: .below)
case 1:
tutorialView.updateHole(view2.frame, animated: true, cPos: .below)
case 2:
tutorialView.updateHole(view3.frame, animated: true, cPos: .above)
default:
break
}
}
}
class TutorialView: UIView {
var onNextButtonTapped: (()->())?
var onPrevButtonTapped: (()->())?
var onDoneButtonTapped: (()->())?
var currentState: Int = 0
let translucentLayer = CALayer()
let holeLayer = CAShapeLayer()
var curPath: CGPath!
var newPath: CGPath!
let controlContainer = UIView()
let prevBtn = UIButton()
let nextBtn = UIButton()
let doneBtn = UIButton()
let infoLabel = UILabel()
var ccTop: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
holeLayer.fillColor = UIColor.black.cgColor
holeLayer.fillRule = .evenOdd
translucentLayer.backgroundColor = UIColor.black.withAlphaComponent(0.7).cgColor
layer.addSublayer(translucentLayer)
controlContainer.translatesAutoresizingMaskIntoConstraints = false
addSubview(controlContainer)
ccTop = controlContainer.topAnchor.constraint(equalTo: topAnchor)
NSLayoutConstraint.activate([
ccTop,
controlContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
controlContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
controlContainer.heightAnchor.constraint(equalToConstant: 60.0),
])
prevBtn.setTitle("Voltar", for: [])
nextBtn.setTitle("Próximo", for: [])
doneBtn.setTitle("OK, entendi", for: [])
infoLabel.textAlignment = .center
infoLabel.textColor = .white
for v in [prevBtn, nextBtn, doneBtn, infoLabel] {
v.translatesAutoresizingMaskIntoConstraints = false
controlContainer.addSubview(v)
}
NSLayoutConstraint.activate([
prevBtn.leadingAnchor.constraint(equalTo: controlContainer.leadingAnchor, constant: 8.0),
prevBtn.bottomAnchor.constraint(equalTo: controlContainer.bottomAnchor, constant: -4.0),
nextBtn.trailingAnchor.constraint(equalTo: controlContainer.trailingAnchor, constant: -8.0),
nextBtn.bottomAnchor.constraint(equalTo: controlContainer.bottomAnchor, constant: -4.0),
doneBtn.trailingAnchor.constraint(equalTo: controlContainer.trailingAnchor, constant: -8.0),
doneBtn.bottomAnchor.constraint(equalTo: controlContainer.bottomAnchor, constant: -4.0),
infoLabel.topAnchor.constraint(equalTo: controlContainer.topAnchor, constant: 8.0),
infoLabel.leadingAnchor.constraint(equalTo: controlContainer.leadingAnchor, constant: 8.0),
infoLabel.trailingAnchor.constraint(equalTo: controlContainer.trailingAnchor, constant: -8.0),
])
nextBtn.addTarget(self, action: #selector(nextTapped(_:)), for: .touchUpInside)
prevBtn.addTarget(self, action: #selector(prevTapped(_:)), for: .touchUpInside)
doneBtn.addTarget(self, action: #selector(doneTapped(_:)), for: .touchUpInside)
controlContainer.backgroundColor = .systemRed
// we start with control container alpha at Zero
controlContainer.alpha = 0.0
}
override func layoutSubviews() {
super.layoutSubviews()
translucentLayer.frame = bounds
}
public func updateHole(_ r: CGRect, animated: Bool, cPos: ControlPosition) {
translucentLayer.mask = holeLayer
let path = UIBezierPath(rect: bounds)
// create a path for the "hole" in the layer
var newR: CGRect = r
newR.origin.x = 0.0
newR.size.width = bounds.width
let holePath = UIBezierPath(rect: newR)
// this "cuts a hole" in the path
path.append(holePath)
path.usesEvenOddFillRule = true
newPath = path.cgPath
if !animated {
self.curPath = self.newPath
self.holeLayer.path = self.curPath
} else {
// fade-out controlContainer
UIView.animate(withDuration: 0.3, animations: {
self.controlContainer.alpha = 0.0
}, completion: { _ in
// update next/prev/done buttons visiblity
self.nextBtn.isHidden = self.currentState == 2 ? true : false
self.prevBtn.isHidden = self.currentState == 0 ? true : false
self.doneBtn.isHidden = !self.nextBtn.isHidden
self.infoLabel.text = "Current State: \(self.currentState)"
// update control container position
self.ccTop.constant = cPos == .below ? r.maxY : r.minY - self.controlContainer.frame.height
self.animPath()
})
}
}
private func animPath() {
CATransaction.begin()
CATransaction.setCompletionBlock({
// on animation completion, we want to
// update the holeLayer's path, and
// fade-in the controls container
self.curPath = self.newPath
self.holeLayer.path = self.curPath
UIView.animate(withDuration: 0.3, animations: {
self.controlContainer.alpha = 1.0
})
})
let animation = CABasicAnimation(keyPath: "path")
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
// fromValue is not strictly necessary here, but we use it for consistency
animation.fromValue = curPath
animation.toValue = newPath
animation.isRemovedOnCompletion = false
// adjust animation speed as desired
animation.duration = 0.75
holeLayer.add(animation, forKey: "positionAnimation")
CATransaction.commit()
}
@objc func nextTapped(_ sender: Any?) {
currentState += 1
onNextButtonTapped?()
}
@objc func prevTapped(_ sender: Any?) {
currentState -= 1
onPrevButtonTapped?()
}
@objc func doneTapped(_ sender: Any?) {
onDoneButtonTapped?()
}
}