收录:原文地址git
iOS 的动画框架很成熟,提供必要的信息,譬如动画的起始位置与终止位置,动画效果就出来了面试
动画的实现方式挺多的,app
有系统提供的简单 API ,直接提供动画般的交互效果。框架
有手动设置交互效果,看起来像是动画,通常要用到插值。async
至于动画框架,有 UIView 级别的,有功能强劲的 CALayer 级别的动画。ide
CALayer 级别的动画经过灵活设置的 CoreAnimation,CoreAnimation 的常规操做,就是自定义路径函数
固然有苹果推了几年的 UIViewPropertyAnimator, 动画可交互性作得比较好;工具
话很少说;直接来看案例布局
navigationController?.hidesBarsOnSwipe = true
简单设置 hidesBarsOnSwipe
属性,就能够了。学习
该属性,除了能够调节头部导航栏,还能够调节底部标签工具栏 toolbar
一眼看起来有点炫,实际设置很简单
func openLock() { UIView.animate(withDuration: 0.4, delay: 1.0, options: [], animations: { // Rotate keyhole. self.lockKeyhole.transform = CGAffineTransform(rotationAngle: CGFloat.pi) }, completion: { _ in UIView.animate(withDuration: 0.5, delay: 0.2, options: [], animations: { // Open lock. let yDelta = self.lockBorder.frame.maxY self.topLock.center.y -= yDelta self.lockKeyhole.center.y -= yDelta self.lockBorder.center.y -= yDelta self.bottomLock.center.y += yDelta }, completion: { _ in self.topLock.removeFromSuperview() self.lockKeyhole.removeFromSuperview() self.lockBorder.removeFromSuperview() self.bottomLock.removeFromSuperview() }) }) }
总共有四个控件,先让中间的锁控件旋转一下,而后对四个控件,作移位操做
用简单的关键帧动画,处理要优雅一点
看上去有些眼花的动画,能够分解为三个动画
一波未平,一波又起,作一个动画效果的叠加,就成了动画的第一幅动画
一个动画波动效果,效果用到了透明度的变化,还有范围的变化
范围的变化,用的就是 CoreAnimation 的路径 path
CoreAnimation 简单设置,就是指明 from 、to,动画的起始状态,和动画终止状态,而后选择使用哪种动画效果。
动画的起始状态,通常是起始位置。简单的动画,就是让他动起来
func sonar(_ beginTime: CFTimeInterval) { let circlePath1 = UIBezierPath(arcCenter: self.center, radius: CGFloat(3), startAngle: CGFloat(0), endAngle:CGFloat.pi * 2, clockwise: true) let circlePath2 = UIBezierPath(arcCenter: self.center, radius: CGFloat(80), startAngle: CGFloat(0), endAngle:CGFloat.pi * 2, clockwise: true) let shapeLayer = CAShapeLayer() shapeLayer.strokeColor = ColorPalette.green.cgColor shapeLayer.fillColor = ColorPalette.green.cgColor shapeLayer.path = circlePath1.cgPath self.layer.addSublayer(shapeLayer) // 两个动画 let pathAnimation = CABasicAnimation(keyPath: "path") pathAnimation.fromValue = circlePath1.cgPath pathAnimation.toValue = circlePath2.cgPath let alphaAnimation = CABasicAnimation(keyPath: "opacity") alphaAnimation.fromValue = 0.8 alphaAnimation.toValue = 0 // 组动画 let animationGroup = CAAnimationGroup() animationGroup.beginTime = beginTime animationGroup.animations = [pathAnimation, alphaAnimation] // 时间有讲究 animationGroup.duration = 2.76 // 不断重复 animationGroup.repeatCount = Float.greatestFiniteMagnitude animationGroup.isRemovedOnCompletion = false animationGroup.fillMode = CAMediaTimingFillMode.forwards // Add the animation to the layer. // key 用来 debug shapeLayer.add(animationGroup, forKey: "sonar") }
波动效果调用了三次
func startAnimation() { // 三次动画,效果合成, sonar(CACurrentMediaTime()) sonar(CACurrentMediaTime() + 0.92) sonar(CACurrentMediaTime() + 1.84) }
这是 UIView 框架自带的动画,看起来不错,就是作了一个简单的缩放,经过 transform
属性作仿射变换
func startAnimation() { dotOne.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) dotTwo.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) dotThree.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) // 三个不一样的 delay, 渐进时间 UIView.animate(withDuration: 0.6, delay: 0.0, options: [.repeat, .autoreverse], animations: { self.dotOne.transform = CGAffineTransform.identity }, completion: nil) UIView.animate(withDuration: 0.6, delay: 0.2, options: [.repeat, .autoreverse], animations: { self.dotTwo.transform = CGAffineTransform.identity }, completion: nil) UIView.animate(withDuration: 0.6, delay: 0.4, options: [.repeat, .autoreverse], animations: { self.dotThree.transform = CGAffineTransform.identity }, completion: nil) }
这个也是 UIView 的动画
动画的实现效果,是经过更改约束。
约束动画要注意的是,确保动画的起始位置准确,起始的时候,通常要调用其父视图的 layoutIfNeeded
方法,确保视图的实际位置与约束设置的一致。
这里的约束动画,是经过 NSLayoutAnchor
作得。
通常咱们用的是 SnapKit 设置约束,调用也差很少。
func animateContraintsForUnderlineView(_ underlineView: UIView, toSide: Side) { switch toSide { case .left: for constraint in underlineView.superview!.constraints { if constraint.identifier == ConstraintIdentifiers.centerRightConstraintIdentifier { constraint.isActive = false let leftButton = optionsBar.arrangedSubviews[0] let centerLeftConstraint = underlineView.centerXAnchor.constraint(equalTo: leftButton.centerXAnchor) centerLeftConstraint.identifier = ConstraintIdentifiers.centerLeftConstraintIdentifier NSLayoutConstraint.activate([centerLeftConstraint]) } } case .right: for constraint in underlineView.superview!.constraints { if constraint.identifier == ConstraintIdentifiers.centerLeftConstraintIdentifier { // 先失效,旧的约束 constraint.isActive = false // 再新建约束,并激活 let rightButton = optionsBar.arrangedSubviews[1] let centerRightConstraint = underlineView.centerXAnchor.constraint(equalTo: rightButton.centerXAnchor) centerRightConstraint.identifier = ConstraintIdentifiers.centerRightConstraintIdentifier NSLayoutConstraint.activate([centerRightConstraint]) } } } UIView.animate(withDuration: 0.6, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.0, options: [], animations: { self.view.layoutIfNeeded() }, completion: nil) }
这个没有用到动画框架,就是作了一个交互插值
就是补插连续的函数 scrollViewDidScroll
, 及时更新列表视图头部的位置、尺寸
override func scrollViewDidScroll(_ scrollView: UIScrollView) { updateHeaderView() } func updateHeaderView() { var headerRect = CGRect(x: 0, y: -tableHeaderHeight, width: tableView.bounds.width, height: tableHeaderHeight) // 决定拉动的方向 if tableView.contentOffset.y < -tableHeaderHeight { // 就是改 frame headerRect.origin.y = tableView.contentOffset.y headerRect.size.height = -tableView.contentOffset.y } headerView.frame = headerRect }
用到了 CoreAnimation,也用到了插值。
每一段插值都是一个 CoreAnimation 动画,进度的完成分为屡次插值。
这里动画效果的主要用到 strokeEnd
属性, 笔画结束
插值的时候,要注意,下一段动画的开始,正是上一段动画的结束
// 这个用来,主要的效果 let progressLayer = CAShapeLayer() // 这个用来,附加的颜色 let gradientLayer = CAGradientLayer() // 给个默认值,外部设置 var range: CGFloat = 128 var curValue: CGFloat = 0 { didSet { animateStroke() } } func setupLayers() { progressLayer.position = CGPoint.zero progressLayer.lineWidth = 3.0 progressLayer.strokeEnd = 0.0 progressLayer.fillColor = nil progressLayer.strokeColor = UIColor.black.cgColor let radius = CGFloat(bounds.height/2) - progressLayer.lineWidth let startAngle = CGFloat.pi * (-0.5) let endAngle = CGFloat.pi * 1.5 let width = bounds.width let height = bounds.height let modelCenter = CGPoint(x: width / 2, y: height / 2) let path = UIBezierPath(arcCenter: modelCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) // 指定路径 progressLayer.path = path.cgPath layer.addSublayer(progressLayer) // 有一个渐变 gradientLayer.frame = CGRect(x: 0.0, y: 0.0, width: bounds.width, height: bounds.height) // teal, 蓝绿色 gradientLayer.colors = [ColorPalette.teal.cgColor, ColorPalette.orange.cgColor, ColorPalette.pink.cgColor] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) gradientLayer.mask = progressLayer // Use progress layer as mask for gradient layer. layer.addSublayer(gradientLayer) } func animateStroke() { // 前一段的终点 let fromValue = progressLayer.strokeEnd let toValue = curValue / range let animation = CABasicAnimation(keyPath: "strokeEnd") animation.fromValue = fromValue animation.toValue = toValue progressLayer.add(animation, forKey: "stroke") progressLayer.strokeEnd = toValue } } // 动画路径,结合插值
这个渐变更画,主要用到了渐变图层 CAGradientLayer
的 locations
位置属性,用来调整渐变区域的分布
另外一个关键点是用了图层 CALayer
的遮罩 mask
,
简单理解,把渐变图层所有蒙起来,只露出文本的形状,就是那几个字母的痕迹
class LoadingLabel: UIView { let gradientLayer: CAGradientLayer = { let gradientLayer = CAGradientLayer() gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) // 灰, 白, 灰 let colors = [UIColor.gray.cgColor, UIColor.white.cgColor, UIColor.gray.cgColor] gradientLayer.colors = colors let locations = [0.25, 0.5, 0.75] gradientLayer.locations = locations as [NSNumber]? return gradientLayer }() // 文字转图片,而后绘制到视图上 // 经过设置渐变图层的遮罩 `mask` , 为指定文字,来设置渐变闪烁的效果 @IBInspectable var text: String! { didSet { setNeedsDisplay() UIGraphicsBeginImageContextWithOptions(frame.size, false, 0) text.draw(in: bounds, withAttributes: textAttributes) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() // 从文字中,抽取图片 let maskLayer = CALayer() maskLayer.backgroundColor = UIColor.clear.cgColor maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0) maskLayer.contents = image?.cgImage gradientLayer.mask = maskLayer } } // 设置位置与尺寸 override func layoutSubviews() { gradientLayer.frame = CGRect(x: -bounds.size.width, y: bounds.origin.y, width: 2 * bounds.size.width, height: bounds.size.height) } override func didMoveToWindow() { super.didMoveToWindow() layer.addSublayer(gradientLayer) let gradientAnimation = CABasicAnimation(keyPath: "locations") gradientAnimation.fromValue = [0.0, 0.0, 0.25] gradientAnimation.toValue = [0.75, 1.0, 1.0] gradientAnimation.duration = 1.7 // 一直循环 gradientAnimation.repeatCount = Float.infinity gradientAnimation.isRemovedOnCompletion = false gradientAnimation.fillMode = CAMediaTimingFillMode.forwards gradientLayer.add(gradientAnimation, forKey: nil) } }
首先经过方法 scrollViewDidScroll
和 scrollViewWillEndDragging
作插值
extension PullRefreshView: UIScrollViewDelegate{ // MARK: - UIScrollViewDelegate func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetY = CGFloat(max(-(scrollView.contentOffset.y + scrollView.contentInset.top), 0.0)) self.progress = min(max(offsetY / frame.size.height, 0.0), 1.0) // 作互斥的状态管理 if !isRefreshing { redrawFromProgress(self.progress) } } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { if !isRefreshing && self.progress >= 1.0 { delegate?.PullRefreshViewDidRefresh(self) beginRefreshing() } } }
画面中飞碟动来动去,是经过 CAKeyframeAnimation(keyPath: "position")
,关键帧动画的位置属性,设置的
func redrawFromProgress(_ progress: CGFloat) { /* PART 1 ENTER ANIMATION */ let enterPath = paths.start // 动画指定路径走 let pathAnimation = CAKeyframeAnimation(keyPath: "position") pathAnimation.path = enterPath.cgPath pathAnimation.calculationMode = CAAnimationCalculationMode.paced pathAnimation.timingFunctions = [CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)] pathAnimation.beginTime = 1e-100 pathAnimation.duration = 1.0 pathAnimation.timeOffset = CFTimeInterval() + Double(progress) pathAnimation.isRemovedOnCompletion = false pathAnimation.fillMode = CAMediaTimingFillMode.forwards flyingSaucerLayer.add(pathAnimation, forKey: nil) flyingSaucerLayer.position = enterPath.currentPoint let sizeAlongEnterPathAnimation = CABasicAnimation(keyPath: "transform.scale") sizeAlongEnterPathAnimation.fromValue = 0 sizeAlongEnterPathAnimation.toValue = progress sizeAlongEnterPathAnimation.beginTime = 1e-100 sizeAlongEnterPathAnimation.duration = 1.0 sizeAlongEnterPathAnimation.isRemovedOnCompletion = false sizeAlongEnterPathAnimation.fillMode = CAMediaTimingFillMode.forwards flyingSaucerLayer.add(sizeAlongEnterPathAnimation, forKey: nil) } // 设置路径 func customPaths(frame: CGRect = CGRect(x: 4, y: 3, width: 166, height: 74)) -> ( UIBezierPath, UIBezierPath) { // 两条路径 let startY = 0.09459 * frame.height let enterPath = UIBezierPath() // ... enterPath.addCurve(to: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height), controlPoint1: CGPoint(x: frame.minX + 0.04828 * frame.width, y: frame.minY + 0.68225 * frame.height), controlPoint2: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height)) enterPath.addCurve(to: CGPoint(x: frame.minX + 0.36994 * frame.width, y: frame.minY + 0.92990 * frame.height), controlPoint1: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height), controlPoint2: CGPoint(x: frame.minX + 0.33123 * frame.width, y: frame.minY + 0.93830 * frame.height)) // ... enterPath.usesEvenOddFillRule = true let exitPath = UIBezierPath() exitPath.move(to: CGPoint(x: frame.minX + 0.98193 * frame.width, y: frame.minY + 0.15336 * frame.height)) exitPath.addLine(to: CGPoint(x: frame.minX + 0.51372 * frame.width, y: frame.minY + 0.28558 * frame.height)) // ... exitPath.miterLimit = 4 exitPath.usesEvenOddFillRule = true return (enterPath, exitPath) } }
这个动画比较复杂,须要作大量的数学计算,还要调试,具体看文尾的 git repo.
通常这种动画,咱们用 Lottie
这个动画有些复杂,重点使用了 CoreAnimation 的组动画,叠加了五种效果,缩放、尺寸、布局、位置与透明度。
具体看文尾的 git repo.
class func animation(_ layer: CALayer, duration: TimeInterval, delay: TimeInterval, animations: (() -> ())?, completion: ((_ finished: Bool)-> ())?) { let animation = CLMLayerAnimation() DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)) { var animationGroup: CAAnimationGroup? let oldLayer = self.animatableLayerCopy(layer) animation.completionClosure = completion if let layerAnimations = animations { CATransaction.begin() CATransaction.setDisableActions(true) layerAnimations() CATransaction.commit() } animationGroup = groupAnimationsForDifferences(oldLayer, newLayer: layer) if let differenceAnimation = animationGroup { differenceAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) differenceAnimation.duration = duration differenceAnimation.beginTime = CACurrentMediaTime() layer.add(differenceAnimation, forKey: nil) } else { if let completion = animation.completionClosure { completion(true) } } } } class func groupAnimationsForDifferences(_ oldLayer: CALayer, newLayer: CALayer) -> CAAnimationGroup? { var animationGroup: CAAnimationGroup? var animations = [CABasicAnimation]() // 叠加了五种效果 if !CATransform3DEqualToTransform(oldLayer.transform, newLayer.transform) { let animation = CABasicAnimation(keyPath: "transform") animation.fromValue = NSValue(caTransform3D: oldLayer.transform) animation.toValue = NSValue(caTransform3D: newLayer.transform) animations.append(animation) } if !oldLayer.bounds.equalTo(newLayer.bounds) { let animation = CABasicAnimation(keyPath: "bounds") animation.fromValue = NSValue(cgRect: oldLayer.bounds) animation.toValue = NSValue(cgRect: newLayer.bounds) animations.append(animation) } if !oldLayer.frame.equalTo(newLayer.frame) { let animation = CABasicAnimation(keyPath: "frame") animation.fromValue = NSValue(cgRect: oldLayer.frame) animation.toValue = NSValue(cgRect: newLayer.frame) animations.append(animation) } if !oldLayer.position.equalTo(newLayer.position) { let animation = CABasicAnimation(keyPath: "position") animation.fromValue = NSValue(cgPoint: oldLayer.position) animation.toValue = NSValue(cgPoint: newLayer.position) animations.append(animation) } if oldLayer.opacity != newLayer.opacity { let animation = CABasicAnimation(keyPath: "opacity") animation.fromValue = oldLayer.opacity animation.toValue = newLayer.opacity animations.append(animation) } if animations.count > 0 { animationGroup = CAAnimationGroup() animationGroup!.animations = animations } return animationGroup }
从 gif 文件里面取出每桢图片,算出持续时间,设置动画图片
internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? { // 须要喂图片, // 喂动画持续时间 let count = CGImageSourceGetCount(source) var data: (images: [CGImage], delays: [Int]) = ([CGImage](), [Int]()) // Fill arrays for i in 0..<count { // Add image if let image = CGImageSourceCreateImageAtIndex(source, i, nil) { data.images.append(image) } let delaySeconds = UIImage.delayForImageAtIndex(Int(i), source: source) data.delays.append(Int(delaySeconds * 1000.0)) // Seconds to ms } // Calculate full duration let duration: Int = { var sum = 0 for val: Int in data.delays { sum += val } return sum }() let gcd = gcdForArray(data.delays) var frames = [UIImage]() var frame: UIImage var frameCount: Int for i in 0..<count { frame = UIImage(cgImage: data.images[Int(i)]) frameCount = Int(data.delays[Int(i)] / gcd) for _ in 0..<frameCount { frames.append(frame) } } let animation = UIImage.animatedImage(with: frames, duration: Double(duration) / 1000.0) return animation }