カテゴリー
iOS Swift

TabView遷移と画面回転時のアニメーション

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)
  }
}