SwiftUI で Toast を作った

こんにちは、亀山です。feather for Mastodon の開発はフル SwiftUI で行っています。今回は、エラー等の表示を行う Toast を自前で実装したのでご紹介します。

Package として切り出したものがこちらです。

https://github.com/ryohey/Toaster

Swift Package Index にも載せました。 https://swiftpackageindex.com/ryohey/Toaster

標準の Alert は次のように表示するかどうかの State の Binding を渡す設計になっており、既存のライブラリの多くもこのようなスタイルになっていました。

.alert(isPresented: $isAlertPresented)

このようなスタイルでは Toast を表示したい View が State を管理することになりますが、Toast はエラー表示など一度だけ行いたいことが多く、手続き的な書き方だと便利になります。

そこで NavigationStack のように、共有した State を介して親 View で定義した遷移を行えるように、次のようなインターフェースとしました。

Button("Show Toast") {
    toast.present(.success(text: "Success!"))
}

toast オブジェクトは親 View から environmentObject で渡すことができます。

struct ContentView: View {
    @StateObject var toast = ToastMessages()

    var body: some View {
        VStack {
            ChildView()
                .environmentObject(toast)
        }
        .padding()
        .toast(
            toast,
            layout: .init(padding: .init(
                top: 0,
                leading: 16,
                bottom: 16,
                trailing: 16
            ))
        )
    }
}

Toast の表示位置は画面下部としていますが、画面によって下のマージンを変えたいことも多いので、親 View の .toast 内で指定できるようにしています。次のように GeometryReader と組み合わせ、タブなどを避けるように padding を適宜指定することができます。

GeometryReader { proxy in
    VStack {
        ...
    }
    .toast(
        toastMessages,
        layout: .init(padding:
            .init(
                top: 0,
                leading: 16,
                bottom: proxy.safeAreaInsets.bottom + 16,
                trailing: 16
            )
        )
    )
}

ちなみに、親 View から共有される ToastMessages オブジェクトは次のような非常にシンプルなクラスです。

import Foundation

class ToastMessages: ObservableObject {
    @Published var messages = [Toast.Message]()

    func present(_ message: Toast.Message) {
        messages.append(message)
    }
}

[Toast.Message] の Binding を直接利用してもいいのですが、toast.present(...) のような直感的な書き味を実現するために ObservableObject としています。feather の NavigationStack の path も同じような設計となっています。

Toast 本体の実装は次のようになります。

import SwiftUI

struct Toast: View {
    let message: Message
    let layout: Layout
    let onClose: () -> Void
    @State private var isShowing: Bool = false

    var body: some View {
        VStack {
            Spacer()
            if isShowing {
                message.content
                    .onTapGesture {
                        isShowing = false
                    }
                    .task {
                        try? await Task.sleep(for: message.config.duration)
                        isShowing = false
                        // アニメーションが終わるのを適当な時間待つ
                        // (animation から duration が取得できないので)
                        try? await Task.sleep(for: .seconds(1))
                        onClose()
                    }
            }
        }
        .padding(layout.padding)
        .animation(message.config.animation, value: isShowing)
        .transition(message.config.transition)
        .onAppear {
            isShowing = true
        }
    }

    struct Message: Identifiable {
        let id = UUID()
        let content: AnyView
        let config: Config
    }

    struct Config {
        let duration: Duration
        let transition: AnyTransition
        let animation: Animation
    }

    struct Layout {
        let padding: EdgeInsets
    }
}

見た目に関する実装を Toast から分離するため、View は present で渡す Toast.Message 内に含むようにしています。エラーや成功などの Toast の種類を先に定義しておいて、対応する enum を present に指定するという設計がストレートだと思いますが、Toast 本体の実装から分離しつつも、表示できる Toast の見た目の種類を自由に増やしたりできる設計を目指しこのようになりました。

この設計により、新しい Toast を増やすときは次のように実装できます。

import SwiftUI

private struct HelloToast: View {
    let message: String

    var body: some View {
        Text(message)
    }
}

extension Toast.Message {
    static func hello(_ message: String) -> Self {
        Self(
            content: AnyView(HelloToast(message: message)),
            config: .init(
                duration: .seconds(3),
                transition: .opacity,
                animation: .easeInOut(duration: 0.5)
            )
        )
    }
}

下記のようにシンプルな呼び出し方にしつつも、アプリ全体で見た目を統一することができます。

toast.present(.hello("こんにちわ!"))

Toaster パッケージには .defaultSuccess(text:), .defaultError(text:), .defaultInfo(text:) を用意していますが、実際に利用する際にはそれぞれのアプリ内で自身で View を実装し .success(message:).error(error:) として Toast.Message の extension を追加して使ってもらう想定です。

以上、Toast を NavigationStack のように共有された State を経由して手続き的に呼び出す書き方で実装してみました。