[Swift]波のアニメーションを学ぶ過程を記録してみた

# 始めに
可愛いスプラッシュ画面を見つけたので真似して作りたい！
# 作りたい画面
<img width="150" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1640998/2db2b7e9-ed16-d584-1e07-5fafb0324d6f.jpeg">

# OSSで似たものを見つけた
同じような波のアニメーションを公開している方がいらっしゃったので、このコードから学ばせていただきます！🙇

https://github.com/noa4021J/WaveAnimationView

## 実際に書いてみる
コードを書いていく上で、わからない部分があれば調べてメモする！

:::note info
全体像はOSSを見てください！
数学に長けているわけではないので、解釈が間違っている場合はコメントにて教えていただけると、嬉しいです！🙇
:::

完成図
<img width="150" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1640998/53fac270-0138-b161-c976-cb2041d55444.gif">

### UIBezierPathとは？
```swift:
private let frontWaveLine: UIBezierPath = UIBezierPath()
private let backWaveLine: UIBezierPath = UIBezierPath()
```
UIBezierPathについては、下記を参考にすると基本的な動作が理解できた。

https://dev.classmethod.jp/articles/play-uibezierpath-1/

### CAShapeLayerとは？
```swift:
private let frontWaveLayer: CAShapeLayer = CAShapeLayer()
private let backWaveSubLayer: CAShapeLayer = CAShapeLayer()
```

CAShapeLayerについては、下記を参考にするとわかりやすかった。

https://uruly.xyz/%e3%80%90swift-3%e3%80%91calayer%e3%82%92%e7%94%a8%e3%81%84%e3%81%a6%e5%9b%b3%e5%bd%a2%e3%82%92%e7%a7%bb%e5%8b%95%e3%83%bb%e6%8b%a1%e5%a4%a7%e7%b8%ae%e5%b0%8f%e3%81%97%e3%81%a6%e3%81%bf%e3%81%9f/

### UIBezierPathとCAShapeLayerの違いは？
違いについて、ChatGPTに聞いてみた！
> CAShapeLayerとUIBezierPathはiOS開発において描画やアニメーションを行うために使われるクラスで、それぞれ異なる目的と機能を持っています。
> 
> UIBezierPath: これはUIKitの一部で、2次元の図形（線、曲線、円、多角形など）を表現するためのパスを作成します。UIBezierPathオブジェクトはパスを描画したり、パス内部を塗りつぶしたり、パスをクリッピング領域として使用したりします。UIBezierPathのメソッドを使って直線、曲線、長方形、円形、弧などを描くことができます。
> 
> CAShapeLayer: これはQuartz Coreの一部で、任意の図形を表現するためのカスタムの描画を提供します。このレイヤーは、描画内容をGPUに直接アクセス可能な形式でキャッシュするため、アニメーションに非常に適しています。また、このレイヤーは自動的にアニメーションを実行します。たとえば、パス自体やパスのストローク色、フィル色、線幅などを変更すると、その変更は自動的にアニメーション化されます。CAShapeLayerは主にパスベースの図形の描画とアニメーションに使用されます。
> 
> つまり、UIBezierPathはパスを描画するためのもので、CAShapeLayerはそのパスを描画し、レンダリングし、アニメーション化するためのものと言えます。したがって、これら二つは互いに補完的な役割を果たします。UIBezierPathで作成したパスは、CAShapeLayerのpathプロパティに設定できます。それにより、CAShapeLayerを使用してパスの描画やアニメーションを行うことができます。

### maskLayerは何をしている？
```swift:
    private var maskLayer: CALayer?
    
    //MARK: Possible to mask the WaveAnimationView just by setting an image containing Solid and Alpha Areas.
    // ①
    open var maskImage: UIImage? {
        didSet {
            //mask
            // ②
            maskLayer = CALayer()
            maskLayer?.frame = self.frame
            maskLayer?.contents = maskImage?.cgImage
            // ③
            self.layer.mask = maskLayer
        }
    }
```
<details><summary>①について</summary>

`open`はモジュール外からもアクセスできて、override、サブクラス化がOK!
モジュールとは、ライブラリなど`import`を使って取り込むまとまりのこと！(ex: UIKit, MapKit)
OSSに使われることが多い修飾子とのこと！[(参考)](https://dev.classmethod.jp/articles/swift3_scoped_access_level/)

</details>

<details><summary>②について</summary>
`CALayer`とは、コンテンツやジオメトリ、視覚的属性情報など情報を管理して、`View`とは異なり独自の外観を定義しない。

[公式](https://developer.apple.com/documentation/quartzcore/calayer)には以下のように記載してあった。
> An object that manages image-based content and allows you to perform animations on that content.

詳細について、わかりやすかったサイト！
* [参考](https://tech.amefure.com/swift-calayer)

</details>

<details><summary>③について</summary>

これは、別の`CALayer`を`mask`にセットすると、もともとの`CAlayer`の図形が「マスク」され部分的に表示されるという機能！この場合であれば、`UIImage`に設定された形で部分的に表示されるようになる。

以下がわかりやすかった！
* [参考](https://medium.com/@bj1024/calayer-mask-79a7a0dc38c6)

</details>

### required init?とは？
```swift:
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
```
[公式]()には以下のように記載されていた。
> Write the required modifier before the definition of a class initializer to indicate that every subclass of the class must implement that initializer:
> 
> You must also write the required modifier before every subclass implementation of a required initializer, to indicate that the initializer requirement applies to further subclasses in the chain. You don’t write the override modifier when overriding a required designated initializer:

`required`がついたメソッドは必ず`override`する必要があり、修飾子は記述しなくて良い！
以下のサイトがわかりやすかった。
* [参考](https://ja.stackoverflow.com/questions/18367/uiview%E3%81%AE%E3%82%B5%E3%83%96%E3%82%AF%E3%83%A9%E3%82%B9%E6%99%82%E3%81%ABrequire-init%E3%81%8C%E5%BF%85%E8%A6%81%E3%81%AA%E5%A0%B4%E5%90%88%E3%81%AE%E7%90%86%E7%94%B1)

### convenience init()とは？
`convenience initializer`は他のイニシャライザに処理を委譲（delegate）することができる。つまりイニシャライザの中で別のイニシャライザを呼ぶことが出来る。
以下は[公式](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/initialization/)に記載されていた例！使い方についてわかりやすい。
```swift:
class Food {
    var name: String
    init(name: String) {
        self.name = name
    }
    convenience init() {
        self.init(name: "[Unnamed]")
    }
}

class RecipeIngredient: Food {
    var quantity: Int
    init(name: String, quantity: Int) {
        self.quantity = quantity
        super.init(name: name)
    }
    override convenience init(name: String) {
        self.init(name: name, quantity: 1)
    }
}
```

以下のサイトがわかりやすかった。
* [参考](https://qiita.com/edo_m18/items/733d8c81fb00942e3167)

### setNeedDisplay()とは？
```swift:
    @objc private func waveAnimation() {
        self.setNeedsDisplay()
    }
```
[公式](https://developer.apple.com/documentation/uikit/uiview/1622437-setneedsdisplay)には、以下のように記載されていた。
> You can use this method or the setNeedsDisplay(_:) to notify the system that your view’s contents need to be redrawn. This method makes a note of the request and returns immediately. The view is not actually redrawn until the next drawing cycle, at which point all invalidated views are updated.
>
> You should use this method to request that a view be redrawn only when the content or appearance of the view change. 

`setNeedDisplay()`は再描画が必要なときに呼び出すメソッド。UIViewのサブViewには適用されない。再描画を行う処理は`draw()`になる。
draw()が呼び出されるタイミングについて記載されているサイトがあるので参考に！

https://llcc.hatenablog.com/entry/2017/09/20/140750

### drawSinメソッドについて
このメソッドで波の形を設定している。

```swift:
    private func drawSin(path: UIBezierPath, time: CGFloat, delay: CGFloat) {
        
        let unit:CGFloat = 100.0
        let zoom:CGFloat = 1.0
        var x = time
        /// ①
        var y = sin(x)/zoom
        /// ②
        let start = CGPoint(x: yAxis, y: unit*y+xAxis)
        /// ③
        path.move(to: start)
        
        var i = yAxis
        /// ④
        while i <= width+10 {
            /// ⑤
            x = time+(-yAxis+i)/unit/zoom
            /// ⑥
            y = sin(x - delay)/self.waveHeight
            /// ⑦
            path.addLine(to: CGPoint(x: i, y: unit*y+xAxis))
            
            i += 10
        }
    }
```
<details><summary>①について</summary>

「𝑦=𝑎sin𝜃」の形のグラフの形。
> 𝑦=𝑎sin𝜃 のグラフは、基本の𝑦=sin𝜃 のグラフを 𝑦 軸方向に 𝑎 倍に拡大（縮小）したものです。

今回は、zoomに`1`が設定されているので、y軸の変化はない。
[4.1 y=asinθのグラフ](https://rikeilabo.com/trigonometric-function-graph)を参照する。

</details>

<details><summary>②について</summary>

起点の設定をしている。`yAxis`については、初期値で`0.0`が設定されている。ここで初期値を変更すれば、起点が変更できる。

`unit*y+xAxis`については、`y`に`unit(100.0)`をかけることで、1から-1の範囲で動く波の高さが100から-100の振幅へと変更される。`xAxis`をプラスすることで、`viewの高さの3/1`が基準となって波が設定される。`xAxis`を`0`として上下に`100,-100`のメモリが設定される感じ。

最初にこのメソッドが実行されたときは、`time`が`0`なので、`y`の値は`0*xAxis`になる。

</details>

<details><summary>③について</summary>

`UIBezierPath`で起点を作成している。

</details>

<details><summary>④について</summary>

起点からviewの幅分まで点を打っていく。
`while i <= width`にすると右端に隙間ができてしまうので、それより少し大きいところを条件に入れているのだと思う。

</details>

<details><summary>⑤について</summary>

次に打つ点の情報を設定していく。
`time(経過時間)`に対して、`yAxis(Xの起点)`と`i`を足していく。
`i`は10ずつ増加していき、x座標を表す。ここでunitで割っているのは、100を1として、x座標が10動いた場合に、経過時間を0.1増やすためだと思われる。実際、`unit`は波の高さを表すときに使用しているので、別の定数を作成して入れた方がわかりやすい気もした！


</details>

<details><summary>⑥について</summary>

xで求めた地点のsinθが求められる。`-delay`については、`backWave`を`frontWave`よりも遅らせて表示するための設定である。`-delay`で設定した数だけ並行移動するので、グラフがずれて表示される。
[𝑦=sin(𝜃–𝑝)  のグラフ](https://rikeilabo.com/trigonometric-function-graph)を参考にする。

ここで、`waveHeight`を割っているが、これは①で行った処理と同じ感じ。ここで縮小するよりも、`unit`を変更して全体的な振幅を変えてあげた方が良い気もする！実際にやってみたら同じ動作になった！

</details>

<details><summary>⑦について</summary>

終点を作成している。
`x`は10ずつ増えていき、`y`は②と同じ！

</details>

### drawWaveメソッドについて
`deawSin`で設定した波を閉じて、カラーをつける。

```swift:
    private func drawWave(layer: CAShapeLayer,path: UIBezierPath,color: UIColor,delay:CGFloat) {
        // ①
        drawSin(path: path,time: drawElapsedTime/0.5, delay: delay)
        // ②
        path.addLine(to: CGPoint(x: width+10, y: height))
        // ③
        path.addLine(to: CGPoint(x: 0, y: height))
        path.close()
        
        layer.fillColor = color.cgColor
        layer.path = path.cgPath
        self.layer.insertSublayer(layer, at: 0)
    }
```
<details><summary>①について</summary>

`drawElapsedTime`については後で出てくる。ここで/0.5をすることについては、ChatGPTがわかりやすかった。
> 実際には、drawElapsedTime / 0.5により、drawElapsedTimeの値は2倍になりますが、この値が波形の周波数を決定します。サイン関数におけるxの係数は周波数に相当し、その値が大きいほど波の周期は短くなります。
> 
> したがって、drawElapsedTime / 0.5により、drawElapsedTimeが2倍になることで波の周波数が2倍になり、結果的に波の周期は半分になります。言い換えると、波形が「横に縮む」ことを意味します。
> 
> そのため、drawElapsedTime / 0.5により、サイン波はより速く振動し、その結果、波の周期が半分（つまり、π）になります。

0.5がある場合とない場合を比べても、波の数に違いがあるのがわかる。
[三角関数のグラフの書き方：y=sin(kθ)のグラフ](https://goukaku-suppli.com/archives/37406#ysink)を参考にする。

</details>

<details><summary>②について</summary>

右下に終点を作成。

</details>

<details><summary>③について</summary>

左下に終点を作成。

</details>

### waveメソッドについて
波の処理を実行し、経過時間を計算する。
```swift:
    @objc private func wave(layer: CAShapeLayer, path: UIBezierPath, color: UIColor, delay:CGFloat) {
        path.removeAllPoints()
        drawWave(layer: layer, path: path, color: color, delay: delay)
        drawSeconds += 0.009
        // ①
        drawElapsedTime = drawSeconds*CGFloat(Double.pi)
        // ②
        if drawElapsedTime >= CGFloat(Double.pi) {
            drawSeconds = 0.0
            drawElapsedTime = 0.0
        }
    }
```
<details><summary>①について</summary>

2πは1周期分を表しているので、ここではπを用いて、半周期分のうち今がどこにいるのかを求めている。
以下はChatGPTに聞いてみた内容。

> drawSeconds*CGFloat(Double.pi)は時間経過をラジアンに変換しており、これはdrawSecondsが半周期のどこにあるかを示しています。
>
> 時間軸上で見ると、1単位時間がπ（約3.14）単位の角度（ラジアン）に対応します。つまり、経過時間drawSecondsをπで乗じると、それが半周期のどこに位置するかを求めることができます。
> 
> したがって、 "1:3.14=0.09:x" は、この変換を良く表しています。0.09単位時間が経過した場合、それは約0.28単位のラジアンに対応します（つまり、0.09*π ≈ 0.28）。これは、半周期の約9%の位置に対応します。

</details>

<details><summary>②について</summary>

経過時間が半周期を超えた場合、リセットするようにしている。
実際、リセットしなくても問題なく動作したけど、ずっと溜まりまくるとかデメリットがあるのかな？

</details>

# 終わりに
真似をして作ってみたが、難しかった。
というよりも、やはり数学力は直結してくるし、あればあるほど実現できることは多いなと実感！

ここまでご覧いただきありがとうございました！
そして、コードを上げてくれる先輩方に本当に感謝です。。