TabBarのボタンで他のTabViewに移動し戻ってきた時のアニメーションと、画面回転時のアニメーションとでは、コールするメソッドが違うので少し苦労しました。Viewのライフサイクルに合わせて調整が必要でした。
画面回転、TabView遷移の際のView Lifecycleは以下のようになっていました。
== first launch ==
viewDidLoad called
viewWillAppear called
viewWillLayoutSubviews called *
viewDidLayoutSubviews called *
viewDidAppear called
== rotated to landscape ==
viewWillTransition called
viewWillLayoutSubviews called *
viewDidLayoutSubviews called *
== rotated to portrait ==
viewWillTransition called
viewWillLayoutSubviews called *
viewDidLayoutSubviews called *
== went to another tab ==
viewDidDisappear called
== came back from another tab view ==
viewWillAppear called
viewWillLayoutSubviews called *
viewDidLayoutSubviews called *
viewDidAppear called
import UIKit
import CoreData
class StatisticsIntervalVC: UIViewController {
// this gets updated from records in Core Data in viewWillAppear
var intervals: [StatisticInterval] = []
// if true, will trigger the animation in viewWillLayoutSubviews() and viewDidLayoutSubviews() methods...
var rotated: Bool = false
let initialChartValue: CGFloat = 0.6
// some Core Data setups...
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
let request = Record.fetchRequest()
var fetchedData: [Record]?
var viewHeight: CGFloat {
view.bounds.height
}
var viewWidth: CGFloat {
view.bounds.width
}
var diameter: CGFloat {
if viewHeight > viewWidth {
return viewWidth*0.7
} else {
return viewHeight*0.7
}
}
// CALayer to draw dots and lines in
let dotLayer = CAShapeLayer()
let lineLayer = CAShapeLayer()
// initial path for dots and lines. these are needed to make animations.
var oldDotPath = UIBezierPath()
var oldLinePath = UIBezierPath()
override func viewDidLoad() { // view lifecycle [1]
super.viewDidLoad()
// filling intervals array
Interval.allCases.forEach { _ in
let interval = StatisticInterval(count: 0, correctRate: 0, triedAnswers: [], timeTookAverage: 0)
intervals.append(interval)
}
// fetching data from Core Data. This happens only once during the lifecycle of this view (including rotation and going to another tab view and come back.
fetchData(request: request)
iterate()
print("viewDidLoad called")
}
override func viewWillAppear(_ animated: Bool) { // view lifecycle [2]
super.viewWillAppear(animated)
// this is called when the view will appear via the selector button
drawInitialLines()
print("viewWillAppear called")
}
override func viewWillLayoutSubviews() { // view lifecycle [3]
super.viewWillLayoutSubviews()
print("viewWillLayoutSubviews called")
if rotated {
drawInitialLines()
}
}
override func viewDidLayoutSubviews() { // view lifecycle [4]
super.viewDidLayoutSubviews()
print("viewDidLayoutSubviews called")
if rotated {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.updateLines()
}
}
// resetting this to false
rotated = false
}
override func viewDidAppear(_ animated: Bool) { // view lifecycle [5]
super.viewDidAppear(animated)
// this is called when coming back from another tab view
// Is it ok to call asyncAfter on the main thread? It appears to be ok... 26 Jun 2022
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.updateLines()
}
print("viewDidAppear called")
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
print("viewWillTransition called")
rotated = true
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// this is called when transitioned to another tab view
print("viewDidDisappear called")
}
// Core Data
private func fetchData(request: NSFetchRequest<Record>) {
do {
fetchedData = try context.fetch(request)
} catch {
print(error)
}
}
// test func. to be replaced by a better business logic and ... name?
private func iterate() {
guard let fetchedData = fetchedData else {
print("not fetched data available1")
return
}
for (i, interval) in Interval.allCases.enumerated() {
// intervalRecord includes all record for the interval
let intervalRecord = fetchedData.filter{ $0.correctAnswer == interval.rawValue }
// obtaining all triedAnswer
var timeTook: Int32 = 0
intervalRecord.forEach { record in
timeTook += record.timeTook
do {
// converting back to [Int32] from Data
if let unwrappedTriedAnswers = record.triedAnswers {
let intArray: [Int32] = try JSONDecoder().decode([Int32].self, from: unwrappedTriedAnswers)
intArray.forEach { answer in
guard let interval = Interval(rawValue: answer) else {
print("could not convert Int32 to Interval")
return
}
intervals[i].triedAnswers.insert(interval)
}
}
} catch {
print(error)
}
}
// counting how many data there is for the interval
let count = intervalRecord.count
// getting only the records with isCorrect == true
let isCorrect = intervalRecord.filter{ $0.isCorrect }
let isCorrectCount = isCorrect.count
intervals[i].count = count
intervals[i].correctRate = Float(isCorrectCount) / Float(count)
intervals[i].timeTookAverage = Float(timeTook) / Float(count)
}
}
private func updateLines() {
// for lines
let linePath = UIBezierPath()
// for dots
let dotPath = UIBezierPath()
let dotRadius: CGFloat = 5
let centerX = viewWidth/2
let centerY = viewHeight/2
var initialPoint: CGPoint = .zero
for i in 0..<Interval.allCases.count {
let index = CGFloat(i)
let thetaOffset: CGFloat = CGFloat.pi*2/12
var value = CGFloat(intervals[i].correctRate)
if value.isNaN {
value = initialChartValue
}
let point = CGPoint(x: centerX + cos(thetaOffset*index)*value*diameter/2, y: centerY - sin(thetaOffset*index)*value*diameter/2 )
if i == 0 {
initialPoint = point
linePath.move(to: point)
dotPath.move(to: point)
dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
} else {
linePath.addLine(to: point)
dotPath.move(to: point)
dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
}
}
// add line to the initial starting point
linePath.addLine(to: initialPoint)
let lineAnimation = CABasicAnimation(keyPath: "path")
let dotAnimation = CABasicAnimation(keyPath: "path")
lineAnimation.fromValue = oldLinePath.cgPath
lineAnimation.toValue = linePath.cgPath
lineAnimation.duration = 1.3
dotAnimation.fromValue = oldDotPath.cgPath
dotAnimation.toValue = dotPath.cgPath
dotAnimation.duration = 1.3
// I think this needs to be called on the main thread
lineLayer.add(lineAnimation, forKey: "path")
dotLayer.add(dotAnimation, forKey: "path")
lineLayer.path = linePath.cgPath
dotLayer.path = dotPath.cgPath
}
private func drawInitialLines() {
lineLayer.removeFromSuperlayer()
dotLayer.removeFromSuperlayer()
// for lines
let linePath = UIBezierPath()
// for dots
let dotPath = UIBezierPath()
let dotRadius: CGFloat = 5
let centerX = viewWidth/2
let centerY = viewHeight/2
var initialPoint: CGPoint = .zero
for i in 0..<Interval.allCases.count {
let index = CGFloat(i)
let thetaOffset: CGFloat = CGFloat.pi*2/12
let point = CGPoint(x: centerX + cos(thetaOffset*index)*initialChartValue*diameter/2,
y: centerY - sin(thetaOffset*index)*initialChartValue*diameter/2 )
if i == 0 {
initialPoint = point
linePath.move(to: point)
dotPath.move(to: point)
dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
} else {
linePath.addLine(to: point)
dotPath.move(to: point)
dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
}
}
// add line to the initial starting point
linePath.addLine(to: initialPoint)
oldDotPath = dotPath
oldLinePath = linePath
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 1.3
dotLayer.fillColor = UIColor.systemPink.cgColor
dotLayer.lineWidth = 3
dotLayer.path = dotPath.cgPath
dotLayer.add(animation, forKey: "opacity")
lineLayer.strokeColor = UIColor.systemCyan.cgColor
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineWidth = 3
lineLayer.lineJoin = .round
lineLayer.lineCap = .round
lineLayer.path = linePath.cgPath
lineLayer.add(animation, forKey: "opacity")
view.layer.addSublayer(lineLayer)
view.layer.addSublayer(dotLayer)
}
}