最后需要完成的效果:

首先要做的就是用CAShapeLayer画一个圆。这里先创建一个路径,把圆的中心放在视图的中心,半径设为100,然后设置起始角度和结束角度,并将clockwise设为true。
然后将shapeLayer的路径设为刚才创建的路径,最后将shapeLayer添加到view的layer的sublayer中。
let shapeLayer = CAShapeLayer()
let circularPath = UIBezierPath(arcCenter: .zero, radius: 100, startAngle: 0, endAngle: CGFloat.pi * 2, clockwise: true)
shapeLayer.path = circularPath.cgPath
shapeLayer.position = view.center
view.layer.addSublayer(shapeLayer)
(滑动显示更多)
接下来在shapeLayer外面添加一圈红色的圆圈。
shapeLayer.strokeColor = UIColor.red.cgColor
shapeLayer.lineWidth = 10(滑动显示更多)
效果如下:

接下来为view添加一个点击手势,以便后面来进行外面圆圈的动画。
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))(滑动显示更多)
添加响应方法
@objc func handleTap() {
}在添加外面圆圈的动画时候需要用到shapeLayer,所以把shapeLayer放到外面声明让VC持有。
在viewDidLoad中将shapeLayer的.strokeEnd 设为0
shapeLayer.strokeEnd = 0之后在handleTap中为shapeLayer添加动画。
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 2
shapeLayer.add(basicAnimation, forKey: "stokeAnimation")(滑动显示更多)
这样点击之后就会画一个圆圈了。

这里有个问题就是开始的位置应该是圆的上方而不是右边,这里就需要去修改shapeLayer的transform,这样就会从圆的上方开始动画了。
shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi / 2, 0, 0, 1)(滑动显示更多)
这里还有个问题就是圆圈的头是直线而不是圆形的,这里只需要改shapeLayer的lineCap就可以了。
shapeLayer.lineCap = .round接下来移除掉圆圈中间的黑色部分,这里修改shapeLayer的填充色为无色。
shapeLayer.fillColor = UIColor.clear.cgColor(滑动显示更多)
接下来为进度条添加一个底部轨道(track layer)。
let trackLayer = CAShapeLayer()
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 10
trackLayer.position = view.center
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round(滑动显示更多)
现在的效果:

完整代码:
import UIKit
class ViewController: UIViewController {
let shapeLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let circularPath = UIBezierPath(arcCenter: .zero, radius: 100, startAngle: 0, endAngle: CGFloat.pi * 2, clockwise: true)
let trackLayer = CAShapeLayer()
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 10
trackLayer.position = view.center
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round
view.layer.addSublayer(trackLayer)
shapeLayer.path = circularPath.cgPath
shapeLayer.strokeEnd = 0
shapeLayer.position = view.center
shapeLayer.strokeColor = UIColor.red.cgColor
shapeLayer.lineWidth = 10
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = .round
shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi / 2, 0, 0, 1)
view.layer.addSublayer(shapeLayer)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
}
@objc func handleTap() {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 2
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "stokeAnimation")
}
}
(滑动显示更多)
接下来模拟下载文件的过程。
创建一个方法来下载文件,这里使用URLSession以方便观察下载的情况。
这里需要遵守URLSessionDownloadDelegate,并实现代理方法,否则会报错。
func beginDownloadingFile() {
let configuration = URLSessionConfiguration.default
let operationQueue = OperationQueue()
let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: operationQueue)
guard let url = URL(string: urlString) else { return }
let downloadTask = urlSession.downloadTask(with: url)
downloadTask.resume()
}
(滑动显示更多)
接下来在URLSession的代理方法中,根据已经写的data和总共需要写的data计算出百分比,然后根据得出的百分比改变shapeLayer的strokeEnd。
这里改变strokeEnd的时候需要放在主线程,否则视图不会发生改变。
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let percentage = CGFloat(totalBytesWritten) / CGFloat(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.shapeLayer.strokeEnd = percentage
}
print(percentage)
}
(滑动显示更多)
接下来需要创建一个label放在shapelayer的中间进行显示。
创建一个UILabel让VC持有,文字为Start,文字居中对齐,字体大小为加粗32号,字体颜色为黑色。
let percentageLabel: UILabel = {
let label = UILabel()
label.text = "Start"
label.textAlignment = .center
label.font = UIFont.boldSystemFont(ofSize: 32)
label.textColor = .black
return label
}()
(滑动显示更多)
在视图中添加percentageLabel为视图的子view,设置好label的frame,并将其放在视图中心。
view.addSubview(percentageLabel)
percentageLabel.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
percentageLabel.center = view.center
(滑动显示更多)
接下来需要在下载任务开始时,将percentageLabel的文字改为对应的百分比。
这个就在刚才URLSession的代理方法里面设置strokeEnd的地方设置percentageLabel的text就好了。
这里注意设置text也需要在主线程里面。
DispatchQueue.main.async {
self.percentageLabel.text = "\(Int(percentage * 100))%"
self.shapeLayer.strokeEnd = percentage
}
(滑动显示更多)
到这里进度条就会跟着下载的进度前进,并且在中间显示白分比了。

完整代码:
import UIKit
class ViewController: UIViewController,URLSessionDownloadDelegate {
let shapeLayer = CAShapeLayer()
let percentageLabel: UILabel = {
let label = UILabel()
label.text = "Start"
label.textAlignment = .center
label.font = UIFont.boldSystemFont(ofSize: 32)
label.textColor = .black
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.addSubview(percentageLabel)
percentageLabel.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
percentageLabel.center = view.center
let circularPath = UIBezierPath(arcCenter: .zero, radius: 100, startAngle: 0, endAngle: CGFloat.pi * 2, clockwise: true)
let trackLayer = CAShapeLayer()
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 10
trackLayer.position = view.center
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round
view.layer.addSublayer(trackLayer)
shapeLayer.path = circularPath.cgPath
shapeLayer.strokeEnd = 0
shapeLayer.position = view.center
shapeLayer.strokeColor = UIColor.red.cgColor
shapeLayer.lineWidth = 10
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = .round
shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi / 2, 0, 0, 1)
view.layer.addSublayer(shapeLayer)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
}
let urlString = "https://firebasestorage.googleapis.com/v0/b/firestorechat-e64ac.appspot.com/o/intermediate_training_rec.mp4?alt=media&token=e20261d0-7219-49d2-b32d-367e1606500c"
func beginDownloadingFile() {
shapeLayer.strokeEnd = 0
let configuration = URLSessionConfiguration.default
let operationQueue = OperationQueue()
let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: operationQueue)
guard let url = URL(string: urlString) else { return }
let downloadTask = urlSession.downloadTask(with: url)
downloadTask.resume()
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let percentage = CGFloat(totalBytesWritten) / CGFloat(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.percentageLabel.text = "\(Int(percentage * 100))%"
self.shapeLayer.strokeEnd = percentage
}
print(percentage)
}
@objc func handleTap() {
beginDownloadingFile()
// let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
// basicAnimation.toValue = 1
// basicAnimation.duration = 2
// basicAnimation.fillMode = .forwards
// basicAnimation.isRemovedOnCompletion = false
// shapeLayer.add(basicAnimation, forKey: "stokeAnimation")
}
}
(滑动显示更多)
接下来要为进度条添加扩散的特效
添加一个shapeLayer让VC持有,名字叫做pulsatingLayer。
let pulsatingLayer = CAShapeLayer()在viewDidLoad中设置好属性并添加为view的layer的sublayer
pulsatingLayer.path = circularPath.cgPath
pulsatingLayer.strokeColor = UIColor.clear.cgColor
pulsatingLayer.lineWidth = 10
pulsatingLayer.position = view.center
pulsatingLayer.fillColor = UIColor.yellow.cgColor
pulsatingLayer.lineCap = .round
view.layer.addSublayer(pulsatingLayer)(滑动显示更多)
添加一个方法animatePulsatingLayer,并且在添加pulsatingLayer后调用。在方法里为pulsatingLayer添加一个放大1.5倍。
时间为1秒的动画,并将其autoreverses设为true,这样放大后会自动缩放到原来的大小,最后将重复次数设为无限大。
func animatePulsatingLayer(){
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.toValue = 1.5
animation.duration = 1
animation.autoreverses = true
animation.repeatCount = Float.infinity
pulsatingLayer.add(animation, forKey: "pulsing")
}
(滑动显示更多)
接下来微调一下
trackLayer.fillColor = UIColor.black.cgColor
view.backgroundColor = .black
label.textColor = .white
(滑动显示更多)
就可以看到这样的动画

这样已经十分接近想要的效果了,但是还有一个很严重的bug就是当切换到主屏幕在回来的时候,动画就失效了。
这就需要去监听回到前台的状态,如果收到通知就做相对应的处理。
添加通知的观察者,并添加响应方法。
NotificationCenter.default.addObserver(self, selector: #selector(handleEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)(滑动显示更多)
在响应方法中调用 animatePulsatingLayer()。
@objc private func handleEnterForeground() {
animatePulsatingLayer()
}(滑动显示更多)
这样一个会动的进度条就完成了。但是现在整个文件有点太多代码,难以阅读了。
所以需要稍微重构一下,比如提取出一个方法来创建并返回 shapeLayer
private func createCircleShapeLayer(strokeColor: UIColor, fillColor: UIColor) -> CAShapeLayer {
let layer = CAShapeLayer()
let circularPath = UIBezierPath(arcCenter: .zero, radius: 100, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
layer.path = circularPath.cgPath
layer.strokeColor = strokeColor.cgColor
layer.lineWidth = 20
layer.fillColor = fillColor.cgColor
layer.lineCap = kCALineCapRound
layer.position = view.center
return layer
}
(滑动显示更多)
这样创建shapelayer的时候就只要三行代码了。
pulsatingLayer = createCircleShapeLayer(strokeColor: .clear, fillColor: UIColor.pulsatingFillColor)
let trackLayer = createCircleShapeLayer(strokeColor: .trackStrokeColor, fillColor: .backgroundColor)
shapeLayer = createCircleShapeLayer(strokeColor: .outlineStrokeColor, fillColor: .clear)
(滑动显示更多)
重构过的完整代码
import UIKit
class ViewController: UIViewController, URLSessionDownloadDelegate {
var shapeLayer: CAShapeLayer!
var pulsatingLayer: CAShapeLayer!
let percentageLabel: UILabel = {
let label = UILabel()
label.text = "Start"
label.textAlignment = .center
label.font = UIFont.boldSystemFont(ofSize: 32)
label.textColor = .white
return label
}()
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(self, selector: #selector(handleEnterForeground), name: .UIApplicationWillEnterForeground, object: nil)
}
@objc private func handleEnterForeground() {
animatePulsatingLayer()
}
private func createCircleShapeLayer(strokeColor: UIColor, fillColor: UIColor) -> CAShapeLayer {
let layer = CAShapeLayer()
let circularPath = UIBezierPath(arcCenter: .zero, radius: 100, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
layer.path = circularPath.cgPath
layer.strokeColor = strokeColor.cgColor
layer.lineWidth = 20
layer.fillColor = fillColor.cgColor
layer.lineCap = kCALineCapRound
layer.position = view.center
return layer
}
override func viewDidLoad() {
super.viewDidLoad()
setupNotificationObservers()
view.backgroundColor = UIColor.backgroundColor
setupCircleLayers()
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
setupPercentageLabel()
}
private func setupPercentageLabel() {
view.addSubview(percentageLabel)
percentageLabel.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
percentageLabel.center = view.center
}
private func setupCircleLayers() {
pulsatingLayer = createCircleShapeLayer(strokeColor: .clear, fillColor: UIColor.pulsatingFillColor)
view.layer.addSublayer(pulsatingLayer)
animatePulsatingLayer()
let trackLayer = createCircleShapeLayer(strokeColor: .trackStrokeColor, fillColor: .backgroundColor)
view.layer.addSublayer(trackLayer)
shapeLayer = createCircleShapeLayer(strokeColor: .outlineStrokeColor, fillColor: .clear)
shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi / 2, 0, 0, 1)
shapeLayer.strokeEnd = 0
view.layer.addSublayer(shapeLayer)
}
private func animatePulsatingLayer() {
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.toValue = 1.5
animation.duration = 0.8
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animation.autoreverses = true
animation.repeatCount = Float.infinity
pulsatingLayer.add(animation, forKey: "pulsing")
}
let urlString = "https://firebasestorage.googleapis.com/v0/b/firestorechat-e64ac.appspot.com/o/intermediate_training_rec.mp4?alt=media&token=e20261d0-7219-49d2-b32d-367e1606500c"
private func beginDownloadingFile() {
print("Attempting to download file")
shapeLayer.strokeEnd = 0
percentageLabel.text = "0%"
let configuration = URLSessionConfiguration.default
let operationQueue = OperationQueue()
let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: operationQueue)
guard let url = URL(string: urlString) else { return }
let downloadTask = urlSession.downloadTask(with: url)
downloadTask.resume()
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let percentage = CGFloat(totalBytesWritten) / CGFloat(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.percentageLabel.text = "\(Int(percentage * 100))%"
self.shapeLayer.strokeEnd = percentage
}
print(percentage)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
print("Finished downloading file")
}
fileprivate func animateCircle() {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 2
basicAnimation.fillMode = kCAFillModeForwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "urSoBasic")
}
@objc private func handleTap() {
print("Attempting to animate stroke")
beginDownloadingFile()
// animateCircle()
}
}
extension UIColor {
static func rgb(r: CGFloat, g: CGFloat, b: CGFloat) -> UIColor {
return UIColor(red: r/255, green: g/255, blue: b/255, alpha: 1)
}
static let backgroundColor = UIColor.rgb(r: 21, g: 22, b: 33)
static let outlineStrokeColor = UIColor.rgb(r: 234, g: 46, b: 111)
static let trackStrokeColor = UIColor.rgb(r: 56, g: 25, b: 49)
static let pulsatingFillColor = UIColor.rgb(r: 86, g: 30, b: 63)
}
(滑动显示更多)