カテゴリー
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)
  }
}
カテゴリー
iOS Swift

Core AnimationでChartをアニメーションする

import UIKit

class ViewController: UIViewController {
  // dummy y values
  let yValue: [CGFloat] = [3.0, 2, 5, 6, 1, 3]	

  // bezierPath to use as the fromValue for animation
  var oldDotPath = UIBezierPath()
  var oldLinePath = UIBezierPath()  

  // basic computed properties
  var topInset: CGFloat {
    viewHeight/2 - appAreaHeight/2
  }

  var xOffset: CGFloat {
    appAreaWidth/CGFloat(yValue.count-1)
  }
	
  var dotX: CGFloat {
    viewWidth/2 - appAreaWidth/2
  }

  var rate: CGFloat {
    appAreaHeight/yValue.max()!
  }

  var viewHeight: CGFloat {
		view.bounds.height
  }
  
  var viewWidth: CGFloat {
    view.bounds.width
  }

  var appAreaHeight: CGFloat {
    viewHeight*0.8
  }
	
  var appAreaWidth: CGFloat {
    viewWidth*0.9
  }
	
  let dotLayer = CAShapeLayer()
  let lineLayer = CAShapeLayer()
	
  lazy var button : UIButton = {
    let button = UIButton()
    button.setTitle("tap", for: .normal)
    button.setTitleColor(.blue, for: .normal)
    button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
    return button
  }()
	
  override func viewDidLoad() {
    super.viewDidLoad()
    addToView()
    drawInitialLines()
  }
	
  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    layoutButton()
  }
	
  @objc func buttonTapped() {
    updatePathValues()
  }
	
  private func addToView() {
    view.addSubview(button)
  }
	
  private func layoutButton() {
    let buttonWidth: CGFloat = 100
    let buttonHeight: CGFloat = 50
    let buttonX = viewWidth/2 - buttonWidth/2
    let buttonY = viewHeight/2 + appAreaHeight/2 - buttonHeight
    button.frame = CGRect(x: buttonX, y: buttonY, width: buttonWidth, height: buttonHeight)
  }
	
  private func updatePathValues() {
    let linePath = UIBezierPath()
		
      // for dots
    let dotPath = UIBezierPath()
    let dotRadius: CGFloat = 5
		
    for i in 0..<yValue.count {
      let point = CGPoint(x: dotX + xOffset*CGFloat(i), y: topInset+appAreaHeight-rate*yValue[i])
      if i == 0 {
        dotPath.move(to: point)
        dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
        linePath.move(to: point)
      } else {
        dotPath.move(to: point)
        dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
        linePath.addLine(to: point)
      }
    }
		
    let dotAnimation = CABasicAnimation(keyPath: "path")
    dotAnimation.fromValue = oldDotPath.cgPath
    dotAnimation.toValue = dotPath.cgPath
    dotAnimation.duration = 0.9
		
    let lineAnimation = CABasicAnimation(keyPath: "path")
    lineAnimation.fromValue = oldLinePath.cgPath
    lineAnimation.toValue = linePath.cgPath
    lineAnimation.duration = 0.9
	
    // add animation to the layers	
    dotLayer.add(dotAnimation, forKey: "path")
    lineLayer.add(lineAnimation, forKey: "path")
	
    // persisting path changes by assigning the info	
    dotLayer.path = dotPath.cgPath
    lineLayer.path = linePath.cgPath
  }
	
  private func drawInitialLines() {
    // for lines
    let linePath = UIBezierPath()
		
    // for dots
    let dotPath = UIBezierPath()
    let dotRadius: CGFloat = 5
	
    // drawing lines and dots that fit dummy y values	
    for i in 0..<yValue.count {
      let point = CGPoint(x: dotX + xOffset*CGFloat(i), y: topInset+appAreaHeight/2)
      if i == 0 {
        dotPath.move(to: point)
        dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
        linePath.move(to: point)
      } else {
        dotPath.move(to: point)
        dotPath.addArc(withCenter: point, radius: dotRadius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
        linePath.addLine(to: point)
      }		
    }
		
    // retaining the paths for the later use in animation
    oldDotPath = dotPath
    oldLinePath = linePath
		
    dotLayer.fillColor = UIColor.systemPink.cgColor
    dotLayer.lineWidth = 3
    // setting the path here. by changing the path, can animate the dots.
    dotLayer.path = dotPath.cgPath
		
    lineLayer.strokeColor = UIColor.systemCyan.cgColor
    lineLayer.fillColor = UIColor.clear.cgColor 
    lineLayer.lineWidth = 3
    lineLayer.lineJoin = .round
    lineLayer.lineCap = .round
    // setting the path here...
    lineLayer.path = linePath.cgPath
	
    // adding these layers to the view's backing layer	
    view.layer.addSublayer(lineLayer)
    view.layer.addSublayer(dotLayer)
  }
}
カテゴリー
iOS Swift

UIViewをSwiftUIで使いたい

筆者はSwiftUIからiOS開発を始めました。ストーリーボードを使わずにコードだけでViewを記述出来るSwiftUIは気に入っていますが、現時点でSwiftUIにないUIViewを使いたい場合は「橋渡し」としてUIViewRepresentableを使うこと実現出来ます。UIViewRepresentable protocolを理解する上で少々とっつきにくかったので記事にしようと思いました。

下記コードは最も基本的なカスタムUIViewをSwiftUIで使ってみている例です。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, world!")
            BasicUIViewRepresentable() // 下記structをここでSwiftUIのViewと同様に使用できる
        }
    }
}
struct BasicUIViewRepresentable: UIViewRepresentable {
    func makeUIView(context: Context) -> some UIView { 
    // この必須関数はinitのような役目、some UIViewをreturnする
        let view = UIView() // ベーシックなUIViewインスタンスを生成
        view.backgroundColor = .red // わかり易いように赤色にしてるだけ
        return view
    }
    func updateUIView(_ uiView: UIViewType, context: Context) { 
   // この必須関数はSwiftUI側からデータを送りUIView側をアップデートする場合に使用される
    }
}

UIViewRepresentable protocolの記述を見るとより理解が深まると思います。意訳してみました。

public protocol UIViewRepresentable : View where Self.Body == Never {

    /// typealias UIViewType = UITextField などのように提供される型を指定できる
    associatedtype UIViewType : UIView

    /// Viewオブジェクトを生成時に一度だけ呼ばれ、初期ステートを設定する必須メソッド。
    /// 初回以降のViewのアップデートは``UIViewRepresentable/updateUIView(_:context:)``
    /// が呼ばれる。
    /// - パラメータ context: システムの現在のステートを保持するcontext struct
    /// - 戻り値: 提供された情報により設定されたUIKit view
    func makeUIView(context: Self.Context) -> Self.UIViewType

    /// SwiftUIからの情報をもとにViewを更新するメソッド。
    /// appのステートが変化する度に、SwiftUIは該当の情報(@State変数など)を更新し
    /// contextに応じてUIKit Viewの該当箇所を更新することができる。
    /// - パラメータ:
    ///   - uiView: カスタムViewオブジェクト
    ///   - context: システムの現在のステートを保持するcontext struct
    func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)

    /// 対象のUIKit Viewとcoordinatorをクリーンアップするメソッド。
    /// 例としてobserverを取り除く、などに使用できる。
    /// - パラメータ:
    ///   - uiView: カスタムViewオブジェクト
    ///   - coordinator: UIViewからSwiftUIへデータを渡すカスタムコーディネータインスタンス。
    ///     もしカスタムコーディネータを用いない場合は、システムによりデフォルトインスタンス
    ///     が提供される。
    static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

    /// A type to coordinate with the view.
    associatedtype Coordinator = Void

    /// UIViewからSwiftUIへデータを渡すコーディネータインスタンスを生成。
    /// カスタムCoordinatorインスタンスを用いて@Bindingプロパティを扱うなどをしたい場合
    /// [以下の例: Coordinator]
    ///
    /// このメソッドはSwiftUIにより``UIViewRepresentable/makeUIView(context:)``の前に
    /// 呼ばれる。
    func makeCoordinator() -> Self.Coordinator

    typealias Context = UIViewRepresentableContext<Self>
}

下記はシンプルなUITextFieldViewの実装例

struct UITextFieldViewRepresentable: UIViewRepresentable {
    
    @Binding var text: String
    
    func makeUIView(context: Context) -> UITextField {
        let textField = getTextField()
        textField.delegate = context.coordinator as? UITextFieldDelegate
        return textField
    }
    
    // from SwiftUI to UIKit
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = text
    }
    
    private func getTextField() -> UITextField {
        let textField = UITextField(frame: .zero)
        let placeHolder = NSAttributedString(string: "Type here", attributes: [.foregroundColor: UIColor.red])
        textField.attributedPlaceholder = placeHolder
        return textField
    }
    
    // from UIKit to SwiftUI
    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        
        @Binding var text: String
        
        init(text: Binding<String>) {
            self._text = text
        }
        private func textFieldDidChangeSelection(_ textField: UITextField) {
            text = textField.text ?? ""
        }
    }
    
    typealias UIViewType = UITextField
}