November 18, 2023
41. Делаем тосты и показываем их разными способами
Пока iOS не предоставляет нам из коробки возможность показать красивые тосты (всплывашки) в верхней части экрана, делаем свою реализацию на SwiftUI.
Воспользуемся наработками из статьи 39 - возьмем оттуда safeAreaInsets для адаптации тостов к "челке" и сделаем такой экран:
Рассмотрим несколько вариантов показа тостов:
- Через
EnvironmentKey - Через
@Binding-свойство isPresented - Через
@Binding-свойство с моделью тоста
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)
}
}
}