【SwiftUI】Toastの実装方法〜Queueを使用してより実用的なトーストを表示させる〜

プログラミング等

どうもこじらです。

最近少しだけSwiftに慣れてきました。

でもやっぱ、Swiftは癖が強い。

癖は強いですが、描画を伴う言語としてはかなり合理的にまとまってる感じがして書いてて楽しいです。

大体TypeScript + Vue.jsで書いてる時の脳の回路使って書いてます。

 

まぁそんな話は置いておいて、ネットを調べても私好みのToastの実装が見当たらなくて、結局自作する羽目になったので作ったToastを共有しておこうと思います。

(表示位置を画面上部にしてるので、どちらかというとバナーです。まぁ、これくらいの表示位置なんてどうにでもなりますよね。)

 

実装は以下のサイトを参考にしています。

レイアウト周りはほぼ丸コピと言っていいくらい真似てるので、このタイミングでリンク貼っておきます。

SwiftUIでToastっぽいバナーっぽいやつを実装してみる - Qiita
こんな感じにしたいPowerplayToastKit: A Simple Toast Library for iOSとか翻訳のポップアップとか実装-Successこんな感じで3秒経つと消…

 

コード

Toast.swift

import SwiftUI

struct Toast: View {
    @ObservedObject var toastQueue: ToastQueue
    
    @State var title: String = ""
    @State var content: String = ""
    @State var isShow: Bool = false
    
    var body: some View {
        VStack {
            if isShow {
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                        .resizable()
                        .scaledToFit()
                        .frame(height: 30)
                        .foregroundColor(Color.green)
                    Spacer()
                    VStack(alignment: .leading) {
                        if (!title.isEmpty) {
                            Text(title)
                                .font(.custom("RoundedMplus1c-Bold", size: 16))
                                .foregroundColor(Color.black)
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                        if (!content.isEmpty) {
                            Text(content)
                                .font(.custom("RoundedMplus1c-Regular", size: 14))
                                .foregroundColor(Color.black)
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                    }
                    Spacer()
                }
                .frame(width: 400 < UIScreen.main.bounds.width * 1 / 2 ? 400 : UIScreen.main.bounds.width * 1 / 2) 
                .padding(.all, 10) 
                .background(Color(red: 232/255, green: 242/255, blue: 228/255)) 
                .clipShape(RoundedRectangle(cornerRadius: 10)) Spacer() 
            } 
        } 
        .padding(.top, 50) 
        .onChange(of: toastQueue.queue) { oldValue, newValue in 
            // 追加された場合は正の数。削除された場合は負の数
            let incrOrDecr: Int = newValue.count - oldValue.count
            if ((incrOrDecr > 0 && oldValue.isEmpty) ||
                (incrOrDecr < 0 && !newValue.isEmpty)) { 

                isShow = true 

                // 1個目の追加、または最後の要素以外の削除の場合 
                title = toastQueue.queue.first!.title
                content = toastQueue.queue.first!.content
                DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) {
                    // 3秒後にToastを閉じ、queueの最初の要素を削除する。
                    toastQueue.queue.removeFirst()
                    withAnimation { 
                        isShow = false
                    } 

                    if (!toastQueue.queue.isEmpty) {
                        // まだqueueが存在している場合は、再度toastを表示する。
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 
                            isShow = true 
                        }
                    } 
                } 
            }
        } 
        .animation(Animation.easeOut(duration: 0.2), value: isShow)
    } 
} 

struct ToastElement: Equatable { 
    var title: String 
    var content: String 

    // Type Omitting 
    static func elem(_ title: String, _ content: String) -> ToastElement {
        return ToastElement(title: title, content: content)
    }
}

class ToastQueue: ObservableObject {
    // 配列は操作させない
    @Published fileprivate var queue: [ToastElement] = []
    func append (_ toastElement: ToastElement) -> Void {
        queue.append(toastElement)
    }
}

#Preview {
    Toast(toastQueue: ToastQueue())
}

 

ContentView.swift(使用例)

import SwiftUI

struct ContentView: View {
    @State var toastQueue = ToastQueue()
    var body: some View {
        ZStack {
            VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text("Hello, world!")
            }
            .padding()
            Toast(toastQueue: toastQueue)
        }
        .onAppear {
            toastQueue.append(.elem("タイトル", "本文"))
        }
    }
}

#Preview {
    ContentView()
}

 

解説

使い方

使い方は、Toast.swiftをプロジェクト上に配置し、トーストを表示させたいViewに追加すればOKです。

基本は以下2つ。

toastQueueのインスタンスを@Stateで定義した変数に代入し初期化してください。

@State var toastQueue = ToastQueue()

 

Toastにqueueを渡す際は、”$”は不要です。

理由は、toastQueueはToast.swiftにおいて@Bindingではなく、@ObservedObjectで定義してるからです。

(インスタンスを渡してる時点で参照渡しだからっていう説明でもギリマル?アウト?)

ZStack {
    /* 各々の実装。画面コンポーネント */
    Toast(toastQueue: toastQueue)
}

 

トーストを表示させたいタイミングで、表示させたい内容をQueueにappendします。

タイトルと本文は片方だけでも大丈夫です。表示させない場合は空文字で設定してください。

toastQueue.append(.elem("タイトル", "本文"))

 

 

Toast.swiftの解説

class ToastQueue

この実装の核は、Queueとして振る舞わせている配列です。

Queueにpush(append)したら、その要素が順番待ちになり、配列の先頭に来たら画面上に表示される仕様になっています。

画面上にトーストを表示させたいときはやはり、明示的にpush(append)するのが直感的で分かりやすいと思いますが、ContentView.swift側でpush以外の操作をされた場合、エラーになる気しかしないのでqueue自体はfileprivateで不可視にして、appendしかさせない仕様にしています。

class ToastQueue: ObservableObject {
    // 配列は操作させない
    @Published fileprivate var queue: [ToastElement] = []
    func append (_ toastElement: ToastElement) -> Void {
        queue.append(toastElement)
    }
}

 

.onChange(of: toastQueue.queue)

QueueはonChangeで監視しています。

Vue.jsでいうwatchですね。

Queueを監視することにより、Queueと画面上の表示の紐付けをおこなっています。

Queueにpush(append)された場合、pop(removeFirst)された場合、どちらも変更扱いになりますが、これとoldValueとnewValueがそれぞれ空かどうかによって、画面操作と状況を判定することができます。

 

Queueを監視して、画面上にトーストを表示させたいタイミングは、

  1. 初めてQueueにpushされたタイミング
  2. トーストの表示時間が経過して、トーストを閉じたタイミング

の2つです。

1の初めてQueueにpushされたタイミングはまぁいいとして、2はQueueの2要素目を表示させる発火点になります。

2要素目が存在しない場合はトーストは表示させないので、まぁこういう条件分岐になる訳です。

 

struct ToastElement: Equatable

Queueにおける1要素は、ToastElementという構造体を使用します。

titleとcontentを持ってるだけですね。

 

注意点はEquatableを継承させている点くらいですかね?

Queueを監視対象としている都合で継承させています。

 

あとは、Swift独自の書き方であるType Omittingをドヤ顔で使ってるくらいです。

static funcでその構造体と同じ型を返却値に持つ場合、構造体名を省略して書くことができるっていう、Swift独自の書き方を使うことができます。

ドヤ顔で。

 

なので、.elemで済んでいると。いやー面白い

toastQueue.append(.elem("タイトル", "本文"))

 

 

こんな感じです。

ぜひ使ってみてください。

 

こじらでした

じゃ

コメント

タイトルとURLをコピーしました