🐕

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

2021/10/14に公開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
    }
}