[Swift5]文字を1文字ずつアニメーションする

#1.はじめに
文字列を1文字ずつランダムに出せたらかっこいいなと思い、いろいろ調べながら作ってみたので備忘がてら書いておきます。

かっこよくしたくて、とりあえず「設定した文字列をランダムに1文字ずつフェードインし、最後にまとめて全部フェードアウトする感じ(※)」にしています。
※キングダムハーツシリーズのイベントシーンをイメージしています。

#2.ソースコード
今回のソースコードの全体像は以下となります。
コピペで使えると思うので試してみてください。

```Swift:animation.swift
import UIKit

class animationLabel:UIView, CAAnimationDelegate{
    
    //プロパティ
    var title:String = ""
    var charMargin:CGFloat = 1
    var font:UIFont = UIFont(name: "Zapfino", size: 15.0)!
    var textColor : UIColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1)
    var roopCount : Int = 0
    var shuffledLabel : [UILabel]!
    var animateDuration : Double = 5
    var labelRect : CGRect = CGRect(x: 0, y: 0, width: 0, height: 0)
    
    private var labelArray : [UILabel] = []
    
    //イニシャライザ
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    //文字列をランダムにフェードインさせる関数
    func shuffleFadeAppear(){
        self.animate(animationID: 1,random: true)
    }
    
    
    private func animate(animationID:Int , random:Bool = false){
        
        if(animationID == 1){
            
            var startx : CGFloat = labelRect.origin.x
            
            for chr in self.title{
                let label = UILabel()
                label.text = String(chr)
                label.textColor = self.textColor
                label.font = self.font
                label.sizeToFit()
                label.frame.origin.x = startx
                startx += label.frame.width + self.charMargin
                label.frame.origin.y = labelRect.origin.y
                label.alpha = 0
                self.addSubview(label)
                self.labelArray.append(label)
            }
            
            roopCount = 0
            if(random){
                self.labelArray.shuffle()
            }
            let animationGroup = CAAnimationGroup()
            animationGroup.duration = animateDuration
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
            animationGroup.isRemovedOnCompletion = false
            
            //透明度(opacity)を1から0にする
            let animation1 = CABasicAnimation(keyPath: "opacity")
            animation1.fromValue = 0.0
            animation1.toValue = 1.0
            
            animationGroup.animations = [animation1]
            animationGroup.delegate = self
            self.labelArray[0].layer.add(animationGroup, forKey: nil)
            
            
            
        }
    }
    

    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        // アニメーションの終了
        if(roopCount == self.labelArray.count - 1){
            let animationGroup = CAAnimationGroup()
            animationGroup.duration = animateDuration
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
            animationGroup.isRemovedOnCompletion = false
            
            //透明度(opacity)を1から0にする
            let animation1 = CABasicAnimation(keyPath: "opacity")
            animation1.fromValue = 1.0
            animation1.toValue = 0.0
            
            animationGroup.animations = [animation1]
            animationGroup.delegate = self
            animationGroup.beginTime = CACurrentMediaTime() + 0.1
            self.layer.add(animationGroup, forKey: nil)
            roopCount += 1
        }
    }
    
    func animationDidStart(_ anim: CAAnimation){
        // アニメーションの開始
        if(roopCount < self.labelArray.count - 1){
            roopCount += 1
            
            let animationGroup = CAAnimationGroup()
            animationGroup.duration = animateDuration
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
            animationGroup.isRemovedOnCompletion = false
            
            //透明度(opacity)を0から1にする
            let animation1 = CABasicAnimation(keyPath: "opacity")
            animation1.fromValue = 0.0
            animation1.toValue = 1.0
            
            animationGroup.animations = [animation1]
            animationGroup.delegate = self
            animationGroup.beginTime = CACurrentMediaTime() + 0.5
            self.labelArray[roopCount].layer.add(animationGroup, forKey: nil)
        }
    }
    
}

class ViewController: UIViewController {

    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let label = animationLabel(frame: self.view.frame)
        label.labelRect.origin.x = 100
        label.labelRect.origin.y = 50
        label.title = "Hello World!!"
 
        view.addSubview(label)
        label.shuffleFadeAppear()

        // Do any additional setup after loading the view.
    }

}

//配列シャッフル用拡張メソッド
extension Array {
    
    mutating func shuffle() {
        for i in 0..<self.count {
            let j = Int(arc4random_uniform(UInt32(self.indices.last!)))
            if i != j {
                self.swapAt(i, j)
            }
        }
    }
    
    var shuffled: Array {
        var temp = Array<Element>(self)
        temp.shuffle()
        return temp
    }
}

```

#3.解説
今回は、文字列を1文字ずつアニメーションさせることを中心に解説します。
アニメーションにはCABasicAnimationを使用しているので、その説明は[[Swift5]Viewをアニメーションさせる](https://qiita.com/tojo/items/9008a405e5c43769e86e)をご確認ください。
また、配列シャッフル用にArrayの拡張用メソッドを利用しています。こちらは記入すれば使えるので解説は割愛します。

##3-1.クラスの作成
文字列のラベルを1文字ずつ処理できるラベルにするため、クラスを自作します。
イメージとしては、**「UIViewクラスに文字列プロパティを設定し、アニメーションするときにはその文字列を1文字ずつラベルに格納して、自作したUIViewの上に乗っける」**感じになります。

###3-1-1.クラス定義

```Swift:animation.swift
class animationLabel:UIView, CAAnimationDelegate{
...
}
```
UIViewを継承してクラスを作成します。この際、CAAnimationDelegateプロトコルも実装するのがポイントです。

Delegateというと難しそうなイメージがありましたが、簡単にいうと「処理の実行状況に対して何かアクションを起こす際に使用する」イメージのようです。
多分もっと複雑で難しいんですが、私はこんな感じで理解しています。

文字を1文字ずつアニメーションする場合、前のアニメーションが始まってから数秒後または終了してから次のアニメーションを実行する必要があります。
Delegateを使用しないと、これらがほぼ同時にアニメーションしてしまう(おそらく非同期とかそんな感じ)ので、プロトコルの実装は必ずしましょう。

###3-1-2.プロパティ定義
```Swift:animation.swift
    //プロパティ
    var title:String = ""
    var charMargin:CGFloat = 1
    var font:UIFont = UIFont(name: "Zapfino", size: 15.0)!
    var textColor : UIColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1)
    var roopCount : Int = 0
    var animateDuration : Double = 5
    var labelRect : CGRect = CGRect(x: 0, y: 0, width: 0, height: 0)
    private var labelArray : [UILabel] = []
```

自分なりに必要な項目だけ用意しています。
title:1文字ずつアニメーションさせたい文字列
charMargin:1文字ごとの間隔
font:フォント
textColor:文字の色
roopCount:ループ回数判定用変数
animateDulation:1文字のアニメーションにかかる時間
labelRect(※)：ラベルのx座標とかy座標とか入れる変数
　※widthとか使わないんでCGPointとかCGFloatとかでxとyを用意してもいいです
labelArray：1文字ずつ分割したラベルを格納する配列

###3-1-3.イニシャライザ定義
```Swift:animation.swift
    //イニシャライザ
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
```
UIViewのクラスを使うときに必要なイニシャライザを定義しておきます。fatalErrorみたいなのはXcodeが書いてくれたものですが、このままで問題ありません。


###3-1-4.関数定義
####3-1-4-1.アニメーション呼び出し処理
将来的にいろんなアニメーションができるような汎用性をもたせるため、細かく関数を分割しています。今回のものを動かすだけであれば、ここまで分割する必要はありません。

```Swift:animation.swift
    //文字列をランダムにフェードインさせる関数
    func shuffleFadeAppear(){
        self.animate(animationID: 1,random: true)
    }
```

animationIDでアニメの種類を、randomの値で文字を先頭から処理するかどうか選べるようにしています。

####3-1-4-2.アニメーションメイン処理
メインの部分です。まだ1つだけですが、アニメーションID毎にアニメーションを設定していくイメージで作りました。

```Swift:animation.swift
private func animate(animationID:Int , random:Bool = false){
        
        if(animationID == 1){
            
            var startx : CGFloat = labelRect.origin.x
            
            for chr in self.title{
                let label = UILabel()
                label.text = String(chr)
                label.textColor = self.textColor
                label.font = self.font
                label.sizeToFit()
                label.frame.origin.x = startx
                startx += label.frame.width + self.charMargin
                label.frame.origin.y = labelRect.origin.y
                label.alpha = 0
                self.addSubview(label)
                self.labelArray.append(label)
            }
            
            roopCount = 0
            if(random){
                self.labelArray.shuffle()
            }
            let animationGroup = CAAnimationGroup()
            animationGroup.duration = animateDuration
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
            animationGroup.isRemovedOnCompletion = false
            
            //透明度(opacity)を0から1にする
            let animation1 = CABasicAnimation(keyPath: "opacity")
            animation1.fromValue = 0.0
            animation1.toValue = 1.0
            
            animationGroup.animations = [animation1]
            animationGroup.delegate = self
            self.labelArray[0].layer.add(animationGroup, forKey: nil)
        }
    }
```

やってることはラベルを作ってアニメーションをしているだけです。animationGroupを使っていますが、1つなんで使わなくてもできると思います。

ここでのポイントは、以下の2点です。
1.Delegateを設定

```Swift:animation.swift
animationGroup.delegate = self
```

Delegateを設定します。クラス定義でCAAnimationDelegateを設定しておいたことで、selfが使えるようになります。selfで赤字が出る場合はご確認ください。

2.labelArray[0]にのみアニメーションを追加

```Swift:animation.swift
self.labelArray[0].layer.add(animationGroup, forKey: nil)
```

配列の1つ目だけアニメーションを行います。このアニメーションのDelegateに次の文字のアニメーションを行うように設定することで、1文字ずつのアニメーションを実現します。

####3-1-4-3.Animation終了後の処理
これは雰囲気で作りました。全体の処理が終わったときに、全ての文字をフェードアウトします。

```Swift:animation.swift
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        // アニメーションの終了
        if(roopCount == self.labelArray.count - 1){
            let animationGroup = CAAnimationGroup()
            animationGroup.duration = animateDuration
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
            animationGroup.isRemovedOnCompletion = false

            //透明度(opacity)を1から0にする
            let animation1 = CABasicAnimation(keyPath: "opacity")
            animation1.fromValue = 1.0
            animation1.toValue = 0.0

            animationGroup.animations = [animation1]
            animationGroup.delegate = self
            animationGroup.beginTime = CACurrentMediaTime() + 0.1
            self.layer.add(animationGroup, forKey: nil)
            roopCount += 1
        }
    }
```

####3-1-4-4.Animation開始後の処理

```Swift:animation.swift
func animationDidStart(_ anim: CAAnimation){
        // アニメーションの開始
        if(roopCount < self.labelArray.count - 1){
            roopCount += 1

            let animationGroup = CAAnimationGroup()
            animationGroup.duration = animateDuration
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
            animationGroup.isRemovedOnCompletion = false

            //透明度(opacity)を1から0にする
            let animation1 = CABasicAnimation(keyPath: "opacity")
            animation1.fromValue = 0.0
            animation1.toValue = 1.0

            animationGroup.animations = [animation1]
            animationGroup.delegate = self
            animationGroup.beginTime = CACurrentMediaTime() + 0.5
            self.labelArray[roopCount].layer.add(animationGroup, forKey: nil)
        }
    }
```

最後の文字にいくまで、アニメーションを追加し続けます。基本的に内容は一つ目のアニメーションと同じですが、下の部分を設定する必要があります。

```Swift:animation.swift
animationGroup.beginTime = CACurrentMediaTime() + 0.5
```

これを設定することで、Delegate対象のアニメーションが始まった0.5秒後に次のアニメーションが始まるようになります。

####3-1-4-5.ラベル作成処理
最後にviewDidLoad内に以下を追加すれば、今回作ったアニメーションをやってくれます。

```Swift:animation.swift
        let label = animationLabel(frame: self.view.frame)
        label.labelRect.origin.x = 100
        label.labelRect.origin.y = 50
        label.title = "Hello World!!"

        view.addSubview(label)
        label.shuffleFadeAppear()
```

#4.おわりに
多少前提知識が必要なところもあるかもしれませんが、ざっと書きました。

一つの動きを関数にするだけでこんなに記述量あるんだなあっていう感想を改めて持ちました。動画作成ソフトとかどんだけ作り込んでいるんだろうと思いますね。

以上。