【Xcode/SwiftUI】トーストビューを作ってみる

職場のプロジェクトで目にする機会があったので備忘録として、、(情報漏洩はよろしくないのでUIとか実装はしっかりアレンジしています)

実装 (全部コピペでOK)

Modelとロジック側

import Foundation
import SwiftUI

struct FancyToast: Equatable {
    var type: FancyToastStyle
    var title: String
    var message: String
    var duration: Double = 10
}

enum FancyToastStyle {
    case error
    case warning
    case success
    case info
}

extension FancyToastStyle {
    var themeColor: Color {
        switch self {
        case .error: return Color.red
        case .warning: return Color.orange
        case .info: return Color.blue
        case .success: return Color.green
        }
    }

    var iconFileName: String {
        switch self {
        case .info: return "info.circle.fill"
        case .warning: return "exclamationmark.triangle.fill"
        case .success: return "checkmark.circle.fill"
        case .error: return "xmark.circle.fill"
        }
    }
}
import Foundation
import SwiftUI

struct FancyToastModifier: ViewModifier {
    @Binding var toast: FancyToast?
    @State private var workItem: DispatchWorkItem?

    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay(
                ZStack {
                    mainToastView()
                        .offset(y: -30)
                }.animation(.spring(), value: toast)
            )
            .onChange(of: toast) { value in
                showToast()
            }
    }

    @ViewBuilder func mainToastView() -> some View {
        if let toast = toast {
            VStack {
                Spacer()
                FancyToastView(
                    type: toast.type,
                    title: toast.title,
                    message: toast.message) {
                        dismissToast()
                    }
            }
            .transition(.move(edge: .bottom))
        }
    }

    private func showToast() {
        guard let toast = toast else { return }

        UIImpactFeedbackGenerator(style: .light).impactOccurred()

        // Durationが0以下の場合は、手動で閉じる
        if toast.duration > 0 {
            workItem?.cancel()

            let task = DispatchWorkItem {
                dismissToast()
            }

            workItem = task
            DispatchQueue.main.asyncAfter(deadline: .now() + toast.duration, execute: task)
        }
    }

    private func dismissToast() {
        withAnimation {
            toast = nil
        }
        workItem?.cancel()
        workItem = nil
    }
}

extension View {
    func toastView(toast: Binding<FancyToast?>) -> some View {
        self.modifier(FancyToastModifier(toast: toast))
    }
}
import SwiftUI

class HomeViewModel: ObservableObject {
    @Published var toast: FancyToast?
    @Published var toastTitleArray = ["Error", "Warning", "Success", "Info"]
    @Published var toastColorArray: [Color] = [.red, .yellow, .green, .blue]
    @Published var toastArray = [
        FancyToast(type: .error, title: "Toast Error", message: "Toast message"),
        FancyToast(type: .warning, title: "Toast Warning", message: "Toast message"),
        FancyToast(type: .success, title: "Toast Success", message: "Toast message"),
        FancyToast(type: .info, title: "Toast Info", message: "Toast message")
    ]
}

View側

import SwiftUI

struct FancyToastView: View {
    var type: FancyToastStyle
    var title: String
    var message: String
    var onCancelTapped: (() -> Void)
    var body: some View {
        VStack(alignment: .leading) {
            HStack(alignment: .top) {
                Image(systemName: type.iconFileName)
                    .foregroundColor(type.themeColor)

                VStack(alignment: .leading) {
                    Text(title)
                        .font(.system(size: 14, weight: .semibold))

                    Text(message)
                        .font(.system(size: 12))
                        .foregroundColor(Color.black.opacity(0.6))
                }

                Spacer(minLength: 10)

                Button {
                    onCancelTapped()
                } label: {
                    Image(systemName: "xmark")
                        .foregroundColor(Color.black)
                }
            }
            .padding()
        }
        .background(Color.white)
        .overlay(
            Rectangle()
                .fill(type.themeColor)
                .frame(width: 6)
                .clipped()
            , alignment: .leading
        )
        .frame(minWidth: 0, maxWidth: .infinity)
        .cornerRadius(8)
        .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 1)
        .padding(.horizontal, 16)
    }
}

struct FancyToastView_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            FancyToastView(
                type: .error,
                title: "Error",
                message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") {}

            FancyToastView(
                type: .warning,
                title: "Warning",
                message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") {}

            FancyToastView(
                type: .success,
                title: "Success",
                message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") {}

            FancyToastView(
                type: .info,
                title: "Info",
                message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") {}
        }
    }
}
import SwiftUI

struct HomeView: View {
    @ObservedObject private var homeViewModel = HomeViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            ForEach(0..<homeViewModel.toastArray.count, id: \.self) { index in
                Button {
                    homeViewModel.toast = homeViewModel.toastArray[index]
                } label: {
                    Text("\(homeViewModel.toastTitleArray[index])")
                        .font(.largeTitle)
                        .foregroundColor(.white)
                }
                .padding()
                .frame(width: 300, height: 80)
                .background(homeViewModel.toastColorArray[index])
                .cornerRadius(16)
            }
        }
        .toastView(toast: $homeViewModel.toast)
    }
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        HomeView()
    }
}