November 18, 2023

41. Делаем тосты и показываем их разными способами

Пока iOS не предоставляет нам из коробки возможность показать красивые тосты (всплывашки) в верхней части экрана, делаем свою реализацию на SwiftUI.

Воспользуемся наработками из статьи 39 - возьмем оттуда safeAreaInsets для адаптации тостов к "челке" и сделаем такой экран:

Демо тоста на экране с "челкой"
Демо тоста на экране без "челки" (с кнопкой "home")

Рассмотрим несколько вариантов показа тостов:

  1. Через EnvironmentKey
  2. Через @Binding-свойство isPresented
  3. Через @Binding-свойство с моделью тоста

Добавим новый EnvironmentKey:

struct ToastEnvironmentKey: EnvironmentKey {
  static var defaultValue: (_ model: ToastViewModifier.Model) -> Void = { _ in }
}

extension EnvironmentValues {
  var toastInfo: (_ model: ToastViewModifier.Model) -> Void {
    get { self[ToastEnvironmentKey.self] }
    set { self[ToastEnvironmentKey.self] = newValue }
  }
}

Сделаем основные модели для тоста

/// Модель с основными свойствами тоста
struct Model: Equatable {
    let title: String
    var subtitle: String?
    var duration: Duration = .short
}

/// Длительность отображения тоста в секундах
enum Duration: Double {
    case short = 1.5
    case medium = 3
    case long = 4.5
}

Сверстаем тост и добавим инициализаторы для способов представления 2 и 3:

struct ToastViewModifier: ViewModifier {
  struct Model: Equatable {...}
  enum Duration: Double {...}
  @Environment(\.safeAreaInsets) private var safeAreaInsets
  @Binding private var model: Model?
  @State private var timer: Timer?
   
  /// Инициализатор для управления через `isPresented`
  init(
    isPresented: Binding<Bool>,
    title: String,
    subtitle: String? = nil,
    duration: Duration = .short
  ) {
    self._model = .init(
      get: {
        isPresented.wrappedValue
        ? Model(title: title, subtitle: subtitle, duration: duration)
        : nil
      },
      set: { newValue in
        if newValue == nil {
          isPresented.wrappedValue = false
        }
      }
    )
  }
   
  /// Инициализатор для управления через модель
  init(model: Binding<Model?>) {
    self._model = .init(
      get: { model.wrappedValue == nil ? nil : model.wrappedValue },
      set: { newValue in
        if newValue == nil {
          model.wrappedValue = nil
        }
      }
    )
  }
   
  func body(content: Content) -> some View {
    content
      .overlay(overlayContent, alignment: .top)
      .animation(.easeInOut(duration: 0.25), value: model)
  }
   
  private var overlayContent: some View {
    ZStack(alignment: .top) {
      if let model {
        VStack(alignment: .leading, spacing: 4) {
          Text(model.title).bold()
          if let subtitle = model.subtitle, !subtitle.isEmpty {
            Text(subtitle)
          }
        }
        .foregroundStyle(.white)
        .padding(.top, safeAreaInsets.bottom > 0 ? 44 : 0) // паддинг для девайсов с чёлкой
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.red)
        .onTapGesture { self.model = nil } // скрываем тост при нажатии на него
        .transition(.move(edge: .top).combined(with: .opacity))
        .onAppear {
          // при появлении тоста запускаем таймер
          // по окончании таймера закрываем тост
          timer = Timer.scheduledTimer(
            withTimeInterval: model.duration.rawValue,
            repeats: false
          ) { _ in self.model = nil }
        }
        .onDisappear {
          timer?.invalidate()
          timer = nil
        }
      }
    }
    .ignoresSafeArea()
  }
}

Сделаем расширения для View, чтобы удобно пользоваться способами показа 2 и 3:

extension View {
  func toast(
    isPresented: Binding<Bool>,
    title: String,
    subtitle: String? = nil,
    duration: ToastViewModifier.Duration = .short
  ) -> some View {
    modifier(
      ToastViewModifier(
        isPresented: isPresented,
        title: title,
        subtitle: subtitle,
        duration: duration
      )
    )
  }
   
  func toast(item: Binding<ToastViewModifier.Model?>) -> some View {
    modifier(ToastViewModifier(model: item))
  }
}

Сверстаем демо-экран:

struct ToastViewExample: View {
  @Environment(\.toastInfo) private var toastInfo // Вариант 1
  @State private var showToast = false // Вариант 2
  @State private var toastModel: ToastViewModifier.Model? // Вариант 3
   
  var body: some View {
    NavigationView {
      Button("Показать тост") {
        // Вариант 1
        toastInfo(
          .init(
            title: "Тост № 1",
            subtitle: "Краткое описание 1"
          )
        )
        // Вариант 2
//        showToast = true
        // Вариант 3
//        toastModel = .init(title: "Тост № 3", subtitle: "Краткое описание 3")
      }
      .buttonStyle(.borderedProminent)
      .navigationTitle("Тестируем тосты")
    }
    // Вариант 2
//    .toast(isPresented: $showToast, title: "Тост № 2", subtitle: "Краткое описание 2")
    // Вариант 3
//    .toast(item: $toastModel)
  }
}

Так выглядит входная точка приложения для демонстрации варианта 1:

@main
struct Shared_SwiftUI_ContentApp: App {
  @State private var toastModel: ToastViewModifier.Model?
   
  var body: some Scene {
    WindowGroup {
      ToastViewExample() // <- внутри настраивается тост через обращение к environmentKey
        .environment(\.toastInfo) { model in
          toastModel = model
        }
        .toast(item: $toastModel)
    }
  }
}

Код можно найти в гитхабе по ссылке, а другие статьи - тут.