SwiftUIで作るお洒落なテキストアニメーション


![SwiftUITextAnimation-3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/297fb50d-d885-c3eb-34f1-289e12b5d92f.gif)

**2022/2/4更新**

# はじめに

SwiftUIでは、animationモディファイアやwithAnimationファンクションを使って簡単にアニメーションを実装できます。

ただ、標準のメソッドだけでは座標を動かしたり透明度を変えたりといった単純な動きしか作れない・・・と物足りなさがありました。

ところが、ほんのちょっと工夫するだけで意外にバリエーション豊かなアニメーションを作ることができることに最近気がつきました。

そこから実験的に、色々なテキストアニメーションを簡単に実装するためのライブラリを作り始めました。
まだ未整備な部分が大半ですが、GitHubにコードを公開しています。

https://github.com/chitomo12/SwiftUI-Text-Animation-Library

本エントリーではその中でもお気に入りのアニメーションを簡単に作れるコツを紹介したいと思います。

# Blur　ブラー
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/b993f901-ef9b-3479-1478-a8e0f9a5df81.gif" width="450">
String型の文字列を文字単位に切り分け、文字ごとにアニメーションの開始タイミングをずらすことで、パワーポイントのようにテキストを少しずつ表示するアニメーションを作ることができます。

ここでは`.blur`モディファイアと`.opacity`モディファイアを組み合わせることで、霧の中からフワッと現れてくるようなアニメーションを実現しています。

```swift:BlurSample.swift
struct Blur: View {    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Blur Animation")
                .fontWeight(.heavy)
                .foregroundColor(.gray)
                .padding(.bottom, 10)
            VStack(alignment: .leading) {
                BlurView(text: "どうしようもない", textSize: 38, startTime: 0.41)
                BlurView(text: "わたしが", textSize: 38, startTime: 1.85)
                BlurView(text: "歩いている", textSize: 38, startTime: 2.76)
                BlurView(text: "種田山頭火", textSize: 16, startTime: 3.76)
                        .padding(.top, 30)
            }
        }
    }
}

struct BlurView: View {
    let characters: Array<String.Element>
    let baseTime: Double
    let textSize: Double
    @State var blurValue: Double = 10
    @State var opacity: Double = 0
    
    init(text:String, textSize: Double, startTime: Double) {
        characters = Array(text)
        self.textSize = textSize
        baseTime = startTime
    }
    
    var body: some View {
        HStack(spacing: 1){
            ForEach(0..<characters.count) { num in
                Text(String(self.characters[num]))
                    .font(.custom("HiraMinProN-W3", fixedSize: textSize))
                    .blur(radius: blurValue)
                    .opacity(opacity)
                    .animation(.easeInOut.delay( Double(num) * 0.15 ), value: blurValue)
            }
        }
        .onAppear{
            DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
                if blurValue == 0{
                    blurValue = 10
                    opacity = 0.01
                } else {
                    blurValue = 0
                    opacity = 1
                }
            }
        }
    }
}
```


# Floating Text　浮遊するテキスト

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/8cf1b42b-b9e4-3315-96a2-5c89e00a2ae1.gif" width="400" >
`shadow`モディファイアと`offset`モディファイアを組み合わせることで、あたかも浮遊しているかのように見せています。
特に特殊なことはしていません。

```swift:FloatingTextSample.swift
struct DropShadowSample: View {
    var body: some View{
        VStack {
            DropShadow(text: "行秋や芒痩せたる影法師",
                       textSize: 26,
                       startTime: 0.1)
            DropShadow(text: "寺田寅彦",
                       textSize: 24,
                       startTime: 0.1)
                    .padding()
        }
    }
}

struct DropShadow: View {
    let characters: String
    let baseTime: Double
    let textSize: Double
    @State var shadowSize: Double = 0
    @State var offsetX: Double = 0
    @State var offsetY: Double = 0
    
    init(text:String, textSize: Double, startTime: Double) {
        self.characters = text
        self.textSize = textSize
        self.baseTime = startTime
    }
    
    var body: some View {
        Text(characters)
            .font(.custom("HiraMinProN-W3", fixedSize: textSize))
            .offset(x: offsetX, y: offsetY)
            .animation(.easeInOut, value: offsetX)
            .shadow(color: .black.opacity(0.8),
                    radius: shadowSize, x: shadowSize, y: shadowSize)
            .animation(.easeInOut, value: shadowSize)
            .onTapGesture {
                animate(delayTime: 0.0)
            }
            .onAppear{
                animate(delayTime: 0.2)
            }
    }
    
    func animate(delayTime: Double) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.01 + delayTime) {
            shadowSize = 0
            self.offsetX = 2
            self.offsetY = 2
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3 + delayTime) {
            shadowSize = 6
            self.offsetX = -6
            self.offsetY = -6
        }
    }
}
```

# Distort In and Out　ディストートイン＆アウト
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/43513ff1-91b1-ffc3-97dd-b45d7af22221.gif" width="450">
画面外に吸い込まれていくようにテキストが遷移するアニメーション。
`scaleEffect`と`offset`モディファイアが主役です。

こちらも原理は最初のブラーと同じで、テキストを文字単位を切り分けタイミングをずらしてアニメーションしています。

サンプルでは綺麗にループするよう、テキストが透明になったタイミングでoffsetの値を変更しています。

```swift:DistortInAndOut.swift
struct DistortInOut: View {
    let characters = Array("秋空を二つに断てり椎大樹")
    @State var scaleSwitch: Bool = false
    @State var TextOffsetX: CGFloat = 0
    
    var body: some View {
        VStack{
            Text("Distort In and Out").padding()
            HStack(spacing:0){
                ForEach(0..<characters.count) { num in
                    Text(String(self.characters[num]))
                        .font(.custom("HiraMinProN-W3", fixedSize: 24))
                        .offset(x: TextOffsetX, y: 0)
                        .scaleEffect(x: 1, y: scaleSwitch ? 0 : 1)
                        .animation(.easeInOut.delay( Double(num) * 0.05 ), value: TextOffsetX)
                        .animation(.easeInOut.delay( Double(num) * 0.05 ), value: scaleSwitch)
                }
            }
            .onTapGesture {
                scaleSwitch.toggle()
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                    TextOffsetX = -500
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
                    TextOffsetX = 500
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                    TextOffsetX = 0
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                        scaleSwitch.toggle()
                    }
                }
            }
        }
    }
}
```

# Bounce Text　跳ねるテキスト
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/8086a8de-af41-3cee-5de4-822b869b4628.gif" width="450">
着地点で気持ちよくバウンドするテキストアニメーション。

例によってテキストを文字単位で切り分けて動かしています。
`opacity`と`offset`モディファイアと`.spring`アニメーションの組み合わせがキモです。

これを応用すれば下や斜め方向から飛び出すアニメーションも作ることができます。

```swift:Bounce.swift
struct Bounce: View {
    var body: some View {
        VStack {
            Text("Bounce Animation")
                .fontWeight(.heavy)
                .padding()
            
            BounceAnimationView(text: "枯菊や日日にさめゆくいきどほり", startTime: 0.0)
            BounceAnimationView(text: "萩原朔太郎", startTime: 1.5)
                .padding(.top, 30)
        }
    }
}

struct BounceAnimationView: View {
    let characters: Array<String.Element>
    
    @State var offsetYForBounce: CGFloat = -50
    @State var opacity: CGFloat = 0
    @State var baseTime: Double
    
    init(text: String, startTime: Double){
        self.characters = Array(text)
        self.baseTime = startTime
    }
    
    var body: some View {
        HStack(spacing:0){
            ForEach(0..<characters.count) { num in
                Text(String(self.characters[num]))
                    .font(.custom("HiraMinProN-W3", fixedSize: 24))
                    .offset(x: 0, y: offsetYForBounce)
                    .opacity(opacity)
                    .animation(.spring(response: 0.2, dampingFraction: 0.5, blendDuration: 0.1).delay( Double(num) * 0.1 ), value: offsetYForBounce)
            }
            .onTapGesture {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.0) {
                    opacity = 0
                    offsetYForBounce = -50
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
                    opacity = 1
                    offsetYForBounce = 0
                }
            }
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline: .now() + (0.8 + baseTime)) {
                    opacity = 1
                    offsetYForBounce = 0
                }
            }
        }
    }
}
```

# Block Revealing Animation

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/fca42358-4906-344f-3522-9b45ac481cab.gif" width="450">
覆いの中からテキストが登場するアニメーション。
モーショングラフィックっぽいモダンな感じが個人的にお気に入りです。

こちらはテキストではなくむしろそれを覆うRectangleが主体のアニメーションです。

仕組みとしてはおよそ下記の通り。

1. Textを描写し、`GeometryReader`でサイズを取得
1. 1.で取得したサイズを適用してRectangleをテキストに丁度重なるように描画
1. Rectangleにscaleモディファイアを適用（scaleのプロパティは`x`に0を、anchorに左 `.leading` をセット）
1. `withAnimation`で、RectangleのX軸scaleを0から1に増加させる（覆いが右向きに登場するアニメーション）
1. X軸scaleが1になるタイミングでanchorを右`.trailing`に切り替えて、X軸scaleを0に減らす（覆いが右向きに退場するアニメーション）

少し長めですが、サンプルコード全体を載せます。

```swift:Block.swift
struct Block: View {
    var body: some View {
        ZStack {
            ScrollView{
                VStack(alignment: .leading){
                    
                    BlockTextAnimation(text: "Block Reveal Animation",
                                       font: .custom("Avenir-Black", size: 16),
                                       startTime: 1.0)
                    
                    BlockTextAnimation(text: "秋空を二つに断てり椎大樹",
                                       font: .custom("HiraMinProN-W3", fixedSize: 27),
                                       startTime: 1.0)
                    
                    Image("sampleImage1")
                        .resizable()
                        .scaledToFit()
                        .padding()
                        .frame(height: 200)
                        .clipped()
                    
                    BlockTextAnimation(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
                                       font: .custom("Avenir-Light", fixedSize: 17),
                                       startTime: 1.5)
                        .padding(.top, 50)
                    
                }.padding()
            }
        }
    }
}

struct BlockTextAnimation: View {
    let characters: Array<String.Element>
    var font: Font
    
    @State var rectHeight: CGFloat = 0.1
    @State var pathWidth: CGFloat = 100
    @State var pathHeight: CGFloat = 100  
    
    @State var rectScale: Double = 0.0
    @State var rectAnchor: UnitPoint = .leading
    
    @State var textOpacity: Double = 0.0
    
    var baseTime: Double = 1.0
    
    init(text: String, font: Font, startTime: Double) {
        self.characters = Array(text)
        self.font = font
        self.baseTime = startTime
    }
    
    var body: some View {
        ZStack {
            Text(String(characters))
                .font(font)
                .opacity(textOpacity)
                .background(GeometryReader{ geometry -> Text in
                    // NavigationLinkなどで遷移した際、
                    // 正しく描画後のサイズが取れないことがあるのでバッファ時間を設ける
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                        rectHeight = geometry.frame(in: .local).height
                        pathWidth = geometry.frame(in: .local).width
                        pathHeight = geometry.frame(in: .local).height
                    }
                    return Text("")
                })
            
            Rectangle()
                .scale(x: rectScale, y: 1, anchor: rectAnchor)
                .frame(width: pathWidth, height: pathHeight, alignment: .center)
                .onAppear {
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 + baseTime) {
                        withAnimation(.easeInOut) {
                            rectScale = 1
                        }
                    }
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 + baseTime) {
                        textOpacity = 1.0
                        rectAnchor = .trailing
                        withAnimation(.easeInOut){
                            rectScale = 0.0
                        }
                    }
                }
        }
        .onTapGesture {
            blockAnimation()
        }
    }
    
    func blockAnimation(){
        rectAnchor = .leading
        textOpacity = 0.0
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            withAnimation(.easeInOut) {
                rectScale = 1
            }
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            textOpacity = 1.0
            rectAnchor = .trailing
            withAnimation(.easeInOut){
                rectScale = 0.0
            }
        }
    }
}
```

# Blinking with Blur　ブラー×点滅のアニメーション
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/d12f4fab-77f7-8b11-172e-f700ecaa9d6e.gif" width="450">
ジェネラティブに文字が現れるアニメーション。
これも個人的に大好きです。

テキストを文字で切り分け時差を設けるところは先ほど通りで、そこにさらにランダム性を加えた結果、不思議な動きが生まれました。

ここではさらに文字の描写を袋文字→通常文字の二段構えにしています。

これによって、「文字が現れる」というだけの画面にずっと眺めていられるような魅力が生まれたような気がします。

技術的には、先ほどのBlock Revealingアニメーションよりずっとシンプルな作りになっています。
要は各文字の描写に時差を設けて、袋文字に通常文字を重ねただけです。

コードを下に掲載します。

```swift:BlinkingWithBlur.swift
struct BlinkingWithBlurSample: View {
    let textSize: Double = 30
    
    var body: some View {
        VStack{
            BlinkingWithBlur(text: "Demain, dès l’aube,",
                             fontName: "Didot",
                             textSize: textSize,
                             startTime: 0.1)
            BlinkingWithBlur(text: "à l'heure",
                             fontName: "Didot",
                             textSize: textSize,
                             startTime: 0.3)
            BlinkingWithBlur(text: "où blanchit la campagne",
                             fontName: "Didot",
                             textSize: textSize,
                             startTime: 0.5)
            BlinkingWithBlur(text: "Je partirai. Vois-tu,",
                             fontName: "Didot",
                             textSize: textSize,
                             startTime: 0.7)
            BlinkingWithBlur(text: "je sais que tu m'attends.",
                             fontName: "Didot",
                             textSize: textSize,
                             startTime: 0.9)
            
            BlinkingWithBlur(text: "Victor Hugo",
                             fontName: "Didot",
                             textSize: 24,
                             startTime: 2.5)
                .padding(.top,100)
        }
    }
}

struct BlinkingWithBlur: View {
    let characters: Array<String.Element>
    let fontName: String
    let baseTime: Double
    let textSize: Double
    
    @State var blurValue: Double = 10
    @State var opacity: Double = 0
    
    init(text:String, fontName: String, textSize: Double, startTime: Double) {
        self.characters = Array(text)
        self.fontName = fontName
        self.textSize = textSize
        self.baseTime = startTime
    }
    
    var body: some View {
        
        HStack(spacing: 0.5){
            ForEach(0..<characters.count) { num in
                ZStack{
                    Text(String(self.characters[num]))
                        .font(.custom(fontName, fixedSize: textSize * 1.0))
                        .blur(radius: blurValue)
                        .opacity(opacity)
                        .animation(.easeInOut.delay( baseTime + Double(num) * 0.5 * Double.random(in: 0.003...0.55)),
                                   value: blurValue)
                    
                    Text(String(self.characters[num]))
                        .font(.custom(fontName, fixedSize: textSize * 0.9))
                        .blur(radius: blurValue)
                        .foregroundColor(.white)
                        .opacity(opacity)
                        .animation(.easeInOut.delay( baseTime + Double(num) * 0.5 * Double.random(in: 0.003...0.55)),
                                   value: blurValue)
                    
                    Text(String(self.characters[num]))
                        .font(.custom(fontName, fixedSize: textSize*1.0))
                        .blur(radius: blurValue)
                        .opacity(opacity)
                        .animation(.easeInOut.delay( baseTime + Double(num) * 1 * Double.random(in: 0.10...0.3)),
                                   value: blurValue)
                }
            }
        }
        .onAppear{
            DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
                if blurValue == 0{
                    blurValue = 10
                    opacity = 0.01
                } else {
                    blurValue = 0
                    opacity = 1
                }
            }
        }
    }
}
```

# Roulette ルーレットテキスト
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/986ab506-9b19-cbeb-b241-c0f0b035f72b.gif" width="400">

文字がスピーディーに入れ替わりながら現れてくるアニメーションです。
引数として与えた文字列を配列化し、その配列からランダムに文字を取り出し表示することでSFチックな演出を作り出しています。

技術的にはTimerを使って一定時間ループさせている点が他と異なる特徴です。

こちらも少し長いですがサンプルコードを掲載します。

```swift:Roulette.swift
struct RouletteText: View {
    var body: some View {
        VStack(alignment:.leading){
            RouletteCharacters(text: "To be or", delay: 0.3)
            RouletteCharacters(text: "not to be,", delay: 1.3)
            RouletteCharacters(text: "that is the", delay: 1.8)
            RouletteCharacters(text: "question.", delay: 2.3)
        }
        .padding()
    }
}

struct RouletteCharacters: View {
    let characters: Array<String.Element>
    let fontSize: Float = 78
    var delay: Double
    
    init(text: String, delay: Double){
        self.characters = Array(text)
        self.delay = delay
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            HStack(spacing:0){
                ForEach(0..<characters.count) { num in
                    RouletteCharacter(characters: String(characters),
                                      finalCharacter: String(characters[num]),
                                      number: num,
                                      fontSize: fontSize,
                                      baseTime: delay)
                }
                Spacer()
            }
        }
    }
}

struct RouletteCharacter: View {
    @State var characters: Array<String.Element>
    @State var finalCharacter: String
    @State var separatedCharacter: String
    @State var timer: Timer!
    @State var number: Int
    @State var fontSize: Float
    @State var baseTime: Double
    @State var opacity: Double = 0
    var speedDuration: Double = 0.2
    
    init(characters: String, finalCharacter: String, number: Int, fontSize: Float, baseTime: Double){
        self.characters = Array(characters)
        self.finalCharacter = finalCharacter
        self.separatedCharacter = finalCharacter
        self.number = number
        self.fontSize = fontSize
        self.baseTime = baseTime
    }
    
    var body: some View {
        Text(String(separatedCharacter))
            .font(.custom("Arial", size: CGFloat(fontSize)))
            .fontWeight(.heavy)
            .foregroundColor(Color(CGColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1)))
            .opacity(opacity)
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline: .now() + (baseTime / 4) + speedDuration * Double(1 + number)){
                    withAnimation(.linear(duration: 0.2)) {
                        opacity = 1
                    }
                }
                startTimer()
                DispatchQueue.main.asyncAfter(deadline: .now() + (baseTime + speedDuration * Double(1 + number))) {
                    stopTimer()
                    separatedCharacter = finalCharacter
                }
        }
    }
    
    func startTimer() {
        self.timer = Timer.scheduledTimer(withTimeInterval: 0.05,
                                          repeats: true, block: { _ in
            // shuffle and set characters.
            separatedCharacter = String(characters[Int.random(in: 0..<characters.count)])
        })
    }
    
    func stopTimer() {
        self.timer?.invalidate()
        self.timer = nil
    }
}
```

# 最後に

SwiftUI Text Animation Libraryのレシピは今後も追加予定です。
新しく良い感じのアニメーションができたら本エントリーも随時更新していきたいと思ってます。

ここまでご覧いただきありがとうございます。