【SwiftUI】ChatGPTアプリのような文章生成アニメーションをサクッと実装する

# はじめに

こんにちは。
最近ChatGPTのiOSアプリをよく使用しているのですが、文章生成アニメーションと生成開始前後に発生する触覚フィードバック（振動）が心地よく個人的に気に入っています。
このUIってどうやって実装されているのかなと思い色々調べて実装してみたのですが、非常に簡単に実装できたので今回こちらを共有したいと思います！

:::note info
開発環境は以下の通りです。

- Swift 5.10
- Xcode 16.1
:::

# 実装

今回は以下のような仕様のアプリを実装します。

- ボタンをタップすると、文章がアニメーションで表示される
- ボタンのタップ直後に触覚フィードバック（振動）が発生する
- 文章の表示が完了した直後に触覚フィードバック（振動）が発生する

![chatgpt-like_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/f25b762e-4ee1-646e-f09d-795e628b3e06.gif)

## コード全体

まずはコード全体を以下に示します。

```swift
import SwiftUI
import Combine

struct ContentView: View {
    let fullText = "こんにちは、これはタイピングアニメーションのテストです。"
    @State private var displayedText = ""
    @State private var index = 0
    @State private var cancellable: AnyCancellable?

    var body: some View {
        VStack(spacing: 20) {
            Text(displayedText)
                .font(.title)
                .fontWeight(.bold)

            Button("スタート") {
                startTypingAnimation()
            }
            .disabled(cancellable != nil) // 途中でボタンを押せないようにする
        }
        .padding()
    }

    private func startTypingAnimation() {
        displayedText = ""
        index = 0
        cancellable?.cancel() // 既存の購読を解除

        // エフェクト開始時の触覚フィードバック
        let generator = UIImpactFeedbackGenerator(style: .light)
        generator.impactOccurred()

        cancellable = Timer.publish(every: 0.05, on: .current, in: .common)
            .autoconnect()
            .sink { _ in
                if index < fullText.count {
                    let nextIndex = fullText.index(fullText.startIndex, offsetBy: index)
                    displayedText.append(fullText[nextIndex])
                    index += 1
                } else {
                    cancellable?.cancel() // タイピングが完了したら購読を解除
                    cancellable = nil

                    // エフェクト終了時の触覚フィードバック
                    let generator = UINotificationFeedbackGenerator()
                    generator.notificationOccurred(.success)
                }
            }
    }
}
```

以下では、このコードの詳細について、文章性アニメーションと触覚フィードバックに分けて説明します。

## 文章生成アニメーション

こちらのアニメーションですが、一般的にTyping EffectやTypewriter Effectと呼ばれるようです。

<!-- markdownlint-disable MD034 -->
https://www.w3schools.com/howto/howto_js_typewriter.asp

https://css-tricks.com/snippets/css/typewriter-effect/
<!-- markdownlint-enable MD034 -->

まずはじめにボタンをタップしたときの処理は以下のようになります。

```swift
Button("スタート") {
    startTypingAnimation()
}
```

ここでは後述する`startTypingAnimation`メソッドを呼び出しています。

では次に`startTypingAnimation`メソッドを実装します。

```swift
private func startTypingAnimation() {
    displayedText = ""
    index = 0
    cancellable?.cancel() // 既存の購読を解除

    // エフェクト開始時の触覚フィードバック
    // ...

    cancellable = Timer.publish(every: 0.05, on: .current, in: .common)
        .autoconnect()
        .sink { _ in
            if index < fullText.count {
                let nextIndex = fullText.index(fullText.startIndex, offsetBy: index)
                displayedText.append(fullText[nextIndex])
                index += 1
            } else {
                cancellable?.cancel() // タイピングが完了したら購読を解除
                cancellable = nil

                // エフェクト終了時の触覚フィードバック
                // ...
            }
        }
}
```

ここでは具体的に以下のような処理を行っています。

1. 表示する文章と文章のインデックスをリセット、タイマーのサブスクリプションを解除
1. Timerを使用して0.05秒ごとに文字を表示
1. 文章の表示が完了したらタイマーのサブスクリプションを解除

またタイマー部分はCombineを使用しています。
タイマーの実装方法としては、Foundationの`Timer`クラスもあるのですが、[公式ドキュメント](https://developer.apple.com/documentation/combine/replacing-foundation-timers-with-timer-publishers)によると、Combineの方が簡単に実装できるとのことなのでこちらを採用しました。

:::note info
アニメーションをカスタムしたい場合は、`Timer`のインターバルを調整したり、`Text`Viewに`.animation()`を追加することで実現できると思います。
:::

## 触覚フィードバック

触覚フィードバックは英語ではHaptic Feedbackと呼ばれるようです。
[HIGの触覚フィードバックページ](https://developer.apple.com/jp/design/human-interface-guidelines/playing-haptics#Notification)を見ると、iOSでは以下の3種類の触覚フィードバックがあり、Swiftでは各々に専用のクラスが用意されています。

| 触覚フィードバック | クラス |
| --- | --- |
| Notification Feedback | `UINotificationFeedbackGenerator` |
| Impact Feedback | `UIImpactFeedbackGenerator` |
| Selection Feedback | `UISelectionFeedbackGenerator` |

今回は、以下のようにエフェクト開始時と終了時に触覚フィードバックを発生させます。

```swift
// エフェクト開始時の触覚フィードバック
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
```

```swift
// エフェクト終了時の触覚フィードバック
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
```

ここでは開始時には`.light`、終了時には`.success`を指定しています。[^1]

[^1]: 当初ChatGPTアプリに似せようとしたのですが、難しかったため適当な値を設定しました。。。おそらく終了時は`.success`に近いのですが、開始時はプリセットのものとは異なる印象をうけたため、`.light`を指定しています。触覚フィードバックはカスタムしたい場合は、[Core Haptics](https://developer.apple.com/documentation/corehaptics/)を使用すれば可能らしいですが、今回は省略します。

# 参考文献

<!-- markdownlint-disable MD034 -->
https://betterprogramming.pub/typewriter-effect-in-swiftui-ba81db10b570

https://developer.apple.com/documentation/combine/replacing-foundation-timers-with-timer-publishers

https://www.hackingwithswift.com/example-code/uikit/how-to-generate-haptic-feedback-with-uifeedbackgenerator
<!-- markdownlint-enable MD034 -->