🪬

SwiftUIでハートボタンタップ時のアニメーションつくってみた

2024/09/13に公開

はじめに

こういうハートのエフェクトを作りたくなりました。

できるもの

こういうのができます。

サンプルリポジトリ
https://github.com/kabikira/HeartAnimation

SwiftUIを使って、以下の流れで実装していきました。

1.ハートボタンを作成する(最低限の実装)
2.ハートボタンをタップしたら、ハートのアニメーションが1つ出るようにする
3.ハートのアニメーションがタップした数だけ出るようにする

1. ハートボタンを作成する

import SwiftUI

struct ContentView: View {
    var body: some View {
        // タップ可能なハートボタン
        Button(action: {
            // ボタンをタップしたときのアクション(現時点では何もしない)
        }) {
            Image(systemName: "heart.fill")
                .resizable()
                .foregroundColor(.red)
                .frame(width: 50, height: 50)
        }
    }
}

#Preview {
    ContentView()
}

タップできるハートボタンができました!

2.ハートボタンをタップしたら、ハートのアニメーションが1つ出るようにする

ボタンをタップしたときにハートが上に移動しながらフェードアウトするアニメーションを追加します。

import SwiftUI

struct ContentView: View {
    @State private var showHeart = false

    var body: some View {
        ZStack {
            // タップ可能なハートボタン
            Button(action: {
                showHeart = true
            }) {
                Image(systemName: "heart.fill")
                    .resizable()
                    .foregroundColor(.red)
                    .frame(width: 50, height: 50)
            }

            // ハートのアニメーション
            if showHeart {
                HeartAnimationView {
                    // アニメーションが終わったらフラグをオフにする
                    showHeart = false
                }
            }
        }
    }
}

struct HeartAnimationView: View {
    @State private var yOffset: CGFloat = 0
    @State private var opacity: Double = 1

    var onAnimationEnd: () -> Void

    var body: some View {
        Image(systemName: "heart.fill")
            .resizable()
            .foregroundColor(.pink)
            .frame(width: 30, height: 30)
            .offset(y: yOffset)
            .opacity(opacity)
            .onAppear {
                startAnimation()
            }
            .onDisappear {
                onAnimationEnd()
            }
    }

    func startAnimation() {
        // ハートが上に移動しながらフェードアウトするアニメーション
        withAnimation(.easeOut(duration: 2)) {
            yOffset = -200
            opacity = 0
        }
    }
}

#Preview {
    ContentView()
}


3.ハートのアニメーションがタップした数だけ出るようにする

UUIDの配列を@Stateプロパティとして追加

@State private var heartAnimations = [UUID]()

addHeartAnimation()関数を作成し、ボタンをタップするたびに新しいUUIDをheartAnimations配列に追加

func addHeartAnimation() {
    heartAnimations.append(UUID())
}

removeHeartAnimation(id: UUID)関数を作成し、アニメーションが終了したハートを配列から削除

func removeHeartAnimation(id: UUID) {
    if let index = heartAnimations.firstIndex(of: id) {
        heartAnimations.remove(at: index)
    }
}

ForEachを使用して、heartAnimations配列内の各UUIDに対してHeartAnimationViewを生成するように追加したコード

import SwiftUI

struct ContentView: View {
    @State private var heartAnimations = [UUID]()

    var body: some View {
        ZStack {
            // タップ可能なハートボタン
            Button(action: {
                addHeartAnimation()
            }) {
                Image(systemName: "heart.fill")
                    .resizable()
                    .foregroundColor(.red)
                    .frame(width: 50, height: 50)
            }

            // ハートアニメーション
            ForEach(heartAnimations, id: \.self) { id in
                HeartAnimationView {
                    removeHeartAnimation(id: id)
                }
            }
        }
    }

    func addHeartAnimation() {
        heartAnimations.append(UUID())
    }

    func removeHeartAnimation(id: UUID) {
        if let index = heartAnimations.firstIndex(of: id) {
            heartAnimations.remove(at: index)
        }
    }
}

struct HeartAnimationView: View {
    @State private var yOffset: CGFloat = 0
    @State private var opacity: Double = 1

    var onAnimationEnd: () -> Void

    var body: some View {
        Image(systemName: "heart.fill")
            .resizable()
            .foregroundColor(.pink)
            .frame(width: 30, height: 30)
            .offset(y: yOffset)
            .opacity(opacity)
            .onAppear {
                startAnimation()
            }
            .onDisappear {
                onAnimationEnd()
            }
    }

    func startAnimation() {
        withAnimation(.easeOut(duration: 2)) {
            yOffset = -200
            opacity = 0
        }
    }
}

#Preview {
    ContentView()
}

連続でタップできます!

完成したコード

あとはxOffsetとrotationを追加して、ハートがランダムな横方向の位置と回転角度でアニメーションするようにしたり、sizeとcolorをランダムに設定し、ハートの大きさと色に変化を持たせたりした。


import SwiftUI

struct ContentView: View {
    @State private var heartAnimations = [UUID]()

    var body: some View {
        ZStack {
            // タップ可能なハートボタン
            Button(action: {
                addHeartAnimation()
            }) {
                Image(systemName: "heart.fill")
                    .resizable()
                    .foregroundColor(.red)
                    .frame(width: 50, height: 50)
            }

            // ハートのアニメーション
            ForEach(heartAnimations, id: \.self) { id in
                HeartAnimationView {
                    // アニメーションが終わったらUUIDを削除
                    removeHeartAnimation(id: id)
                }
            }
        }
    }

    func addHeartAnimation() {
        heartAnimations.append(UUID())
    }

    func removeHeartAnimation(id: UUID) {
        if let index = heartAnimations.firstIndex(of: id) {
            heartAnimations.remove(at: index)
        }
    }
}

struct HeartAnimationView: View {
    @State private var xOffset: CGFloat = 0
    @State private var yOffset: CGFloat = 0
    @State private var opacity: Double = 1
    @State private var scale: CGFloat = 1
    @State private var rotation: Double = 0
    @State private var size: CGFloat = CGFloat.random(in: 20...40)
    @State private var color: Color = [.yellow, .pink, .orange].randomElement()!

    var onAnimationEnd: () -> Void

    var body: some View {
        Image(systemName: "heart.fill")
            .resizable()
            .foregroundColor(color)
            .frame(width: size, height: size)
            .offset(x: xOffset, y: yOffset)
            .opacity(opacity)
            .scaleEffect(scale)
            .rotationEffect(Angle(degrees: rotation))
            .onAppear {
                startAnimation()
            }
            .onDisappear {
                onAnimationEnd()
            }
    }

    func startAnimation() {
        let animationDuration: Double = 2
        xOffset = CGFloat.random(in: -100...100)
        withAnimation(.easeOut(duration: animationDuration)) {
            yOffset = -300
            opacity = 0
            scale = 1.5
            rotation = Double.random(in: -45...45)
        }
    }
}


#Preview {
    ContentView()
}

参考

https://developer.apple.com/documentation/swiftui/animation

https://developer.apple.com/documentation/swiftui/animations

https://developer.apple.com/documentation/swiftui/button

https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-basic-animations

https://www.hackingwithswift.com/books/ios-swiftui/customizing-animations-in-swiftui

https://zenn.dev/akitomo1126swif/articles/7fce0db37e34ff

https://zenn.dev/tomo_devl/articles/65f1d1fd518bf5

https://developer.apple.com/documentation/swiftui/view/ondisappear(perform:)

https://developer.apple.com/documentation/SwiftUI/View/onAppear(perform:)

Discussion