🐕

SwiftUI でトースト・バナーを表示する方法

に公開1

実装するもの

トースト・バナーとは?

トーストとは、ユーザーの操作に対して完了した後に表示する UI コンポーネントのことです。
主に画面下部に表示され、ファイルのダウンロードが完了したときや送信完了したときに使います。
バナーもトーストと似たような使われ方をしますが、主に画面上部に表示されます。iOS だと、通知バナーなどがあります。

カスタム Modifier を定義する

トースト・バナーは、コンテンツの上に何かしらの View を表示しています。この処理を共通化するために ViewModifier を使います。
詳しい ViewModifier の説明は、公式ドキュメントに譲りますが、簡単に説明すると複数の modifier を一つにして新しい modifier を作るためのプロトコルです。

今回は、 Overlay という名前の Modifier を定義します。

// Overlay
struct Overlay<T: View>: ViewModifier {
    @Binding var isShown: Bool
    let overlayView: T

    func body(contentView: Content) -> some View {
        ZStack {
            contentView
            if isShown {
                overlayView
            }
        }
    }
}

contentViewOverlay の下に表示する View、 overlayView がこれから実装する、トーストやバナーが渡されます。
isShown の値が変わることで、 contentView の上に overlayView が表示されたり消えたりします。

トーストの実装方法

まず、最初にトーストの実装を紹介します。長くなってしまうので、細かい部分は消しているので、このままコピペしても動作はしないです。

struct Toast: View {
  let data: DataModel
  @Binding var isShown: Bool

  var body: some View {
    VStack {
      Spacer()
        HStack {
          Image(systemName: data.image)
          Text(data.title)
        }
        .font(.headline)
        .foregroundColor(.primary)
        .padding(20)
        .background(Color(UIColor.secondarySystemBackground))
        .clipShape(Capsule())
    }
    .frame(width: UIScreen.main.bounds.width / 1.25)
    .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity))
    .onTapGesture {
      withAnimation {
        self.isShown = false
      }
    }.onAppear {
       DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
         withAnimation {
           self.isShown = false
         }
       }
    }
  }
}

VStack の一番上で Spacer を置くことで下寄せになり、 transition で下から出てくるようになっている。
また、時間経過 or 触ったら消えるように asyncAfteronTapGesture を実装している。
トースト自体のデザインは VStack 内で実装しています。

バナーの実装方法

struct Banner: View {
  // DatModel を定義

  let data: DataModel?
  @Binding var isShown: Bool

  var body: some View {
    if let data = data {
       VStack {
         HStack {
           Image(systemName: data.type.sfSymbol)
           VStack {
             Text(data.title).bold()
             if data.detail != "" {
               Text(data.detail)
             }
           }
           Spacer()
         }
         .foregroundColor(Color.white)
         .padding(12)
         .background(data.type.tintColor)
         .cornerRadius(8)
         Spacer()
       }
       .padding()
       .animation(.easeInOut)
       .transition(AnyTransition.move(edge: .top).combined(with: .opacity))
       .onTapGesture {
          withAnimation {
            self.isShown = false
          }
       }.onAppear {
          DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            withAnimation {
              self.isShown = false
            }
          }
       }
    }
  }
}

トーストと違ってバナーは上部に出したいので、 VStack 内の一番下に Spacer を配置します。また、 transition.bottom ではなく .top を指定します。
他の部分はほとんど Toast に似ていて、バナーを消す処理やバナーのスタイルは自由に決められます。

使い方

今までに紹介した OverlayToast or Banner を組み合わせることで、最初に紹介した UI が実装できます。
実際の使い方になります。

// View
VStack(spacing: 12) {
  Button(action: { isShownBanner = true }) {
    Text("バナーを表示する")
  }

  Button(action: { isShownToast = true }) {
    Text("トーストを表示する")
  }
}
.modifier(Overlay(isShown: $isShownBanner, overlayView: Banner(data: Banner.DataModel.success, isShown: $isShownBanner)))
.modifier(Overlay(isShown: $isShownToast, overlayView: Toast(data: Toast.DataModel.fileDownload, isShown: $isShownToast)))

Discussion

sosuiiisosuiii

DispatchQueueじゃなくてwithAnimationのパラメータでdelay使って遅延した方がスマートそうです!

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    withAnimation {
        self.isShown = false
    }
}