【SwiftUI】「読み込み中...」の「...」のみアニメーションさせる

こんにちは。[ymurao2](https://twitter.com/ymurao2)です！
「読み込み中...」のような文字列で、末尾の「...」のみアニメーションさせたいときがあると思います。
今回はこちらをご紹介します。

![Simulator Screen Recording - iPhone 15 Pro - 2023-12-12 at 20.50.33.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/817b85a9-f5c7-1eb1-92c1-fe21dd35af6e.gif)

# 全体像
コードだけ見たい方、お忙しい方のためにまずは全体像です。
```swift
struct AnimatedTextView: View {
    @State private var dotCount = 0
    var dots: String {
        String(repeating: ".", count: dotCount)
    }
    
    let timer = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()
    
    var body: some View {
        HStack(spacing: 0) {
            Text("読み込み中")
                .font(.title)
                .foregroundStyle(Color.gray)
            Text("...")
                .font(.title)
                .hidden()
                .overlay {
                    Text(dots)
                        .font(.title)
                        .foregroundStyle(Color.gray)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .onReceive(timer) { _ in
                            if dotCount < 3 {
                                dotCount += 1
                            } else {
                                dotCount = 1
                            }
                        }
                }
        }
    }
}
```

# おわり
以上です。結構簡単に実装できました！

# 解説
順を追って解説します。

### 1. まず「読み込み中...」を用意します
HStackで書くのがミソですね
```swift
HStack(spacing: 0) {
    Text("読み込み中")
        .font(.title)
        .foregroundStyle(Color.gray)
    Text("...")
        .font(.title)
}
```
![Screenshot 2023-12-12 at 20.53.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/24507ff2-9156-48d9-ed06-c95d3056f1bc.png)

### 2. 「...」を非表示にします
「...」の領域を確保するために`.hidden`モディファイアを使用してViewを非表示にします
このとき実際に表示する「...」と大きさを揃えるために`.font`で大きさを調整しています

```diff_swift
HStack(spacing: 0) {
    Text("読み込み中")
        .font(.title)
        .foregroundStyle(Color.gray)
    Text("...")
        .font(.title)
+       .hidden()
}
```
![Screenshot 2023-12-12 at 20.56.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/fd3459fc-da38-ef02-73bd-7259ab322bda.png)

### 3. `.overlay`モディファイアで「...」の上に「...」を表示する

```diff_swift
HStack(spacing: 0) {
    Text("読み込み中")
        .font(.title)
        .foregroundStyle(Color.gray)
    Text("...")
        .font(.title)
        .hidden()
+       .overlay {
+           Text("...")
+               .font(.title)
+               .foregroundStyle(Color.gray)
+       }
}
```
![Screenshot 2023-12-12 at 21.01.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/e5befb08-ce75-2668-1730-d90077de2386.png)

### 4. アニメーションさせる
0.3秒ごとにアクションを発火させ、`dotCount`を増やすことで、いい感じにアニメーションさせています

```diff_swift
+ @State private var dotCount = 0
+ var dots: String {
+     String(repeating: ".", count: dotCount)
+ }

// 0.3秒ごとに発火
+ let timer = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()

var body: some View {
    HStack(spacing: 0) {
        Text("読み込み中")
            .font(.title)
            .foregroundStyle(Color.gray)
        Text("...")
            .font(.title)
            .hidden()
            .overlay {
                Text(dots)
                    .font(.title)
                    .foregroundStyle(Color.gray)
+                   .frame(maxWidth: .infinity, alignment: .leading)
+                   // timerを監視
+                   .onReceive(timer) { _ in
+                       if dotCount < 3 {
+                           dotCount += 1
+                       } else {
+                           dotCount = 1
+                       }
+                   }
            }
    }
}
```

:::note 
この記事がお役に立てば、ぜひいいね👍お願いします！
:::