Разработка
Создание тоста в SwiftUI за 5 шагов
В этой статье я расскажу о практическом способе реализации всплывающего уведомления в SwiftUI. Решение является лёгким, многоразовым и работает поверх всего пользовательского интерфейса приложения.
В SwiftUI до сих пор нет встроенного компонента для всплывающих уведомлений (toast ). Существуют оповещения (alert) и всплывающие окна (sheet), но они, по своей сути, являются модальными. Тост решает другую проблему: оно предоставляет кратковременную обратную связь, не прерывая рабочий процесс пользователя.
В этой статье я расскажу о практическом способе реализации всплывающего уведомления в SwiftUI. Решение является лёгким, многоразовым и работает поверх всего пользовательского интерфейса приложения.
Что такое тост (и чем он не является)
Всплывающее сообщение, тост — это кратковременное уведомление, которое:
- отображается на экране в течение короткого времени
- не блокирует взаимодействие с основным интерфейсом
- автоматически исчезает по истечении тайм-аута
- в ряде случаев может быть закрыто пользователем вручную (например, жестом смахивания)
Почему простого .overlay часто недостаточно
Наиболее распространенный подход в SwiftUI — это аттач тоста через .overlay где-либо в иерархии представлений. Это работает в простых случаях, но имеет ограничения:
- тост ограничен текущим деревом представлений
- он может появляться за панелями навигации, панелями вкладок или листами
- для надежной работы требуется «корневой контейнер»
Если вам нужно всплывающее сообщение, которое постоянно отображается над всем — навигацией, вкладками, модальными окнами (.sheet/.fullScreenCover) — вам нужен другой подход.
Основная идея: временное окно-наложение
В этой реализации используется кратковременное окно UIWindow, расположенное над главным окном приложения.
Идея проста:
- При запросе всплывающего уведомления создайте прозрачное окно над приложением
- Отобразите содержимое SwiftUI внутри этого окна с помощью
UIHostingController - Сделайте интерактивным только всплывающее уведомление; все остальные касания должны проходить мимо
- Анимируйте появление и исчезновение всплывающего уведомления
- Уничтожьте окно, когда всплывающее уведомление будет закрыто
Это позволяет отображать тост глобально, оставаясь при этом независимым от иерархии представлений самого приложения.
Публичный API
API намеренно минималистичен:
.toast(
isPresented: $isPresented,
message: "Saved successfully",
duration: 2,
edge: .top
)
Или, если вам необходим полный контроль над контентом:
.toast(
isPresented: $isPresented
) {
CustomToastView()
}
Модификатор применяется к любому представлению, но само всплывающее сообщение не отображается внутри этого представления. Оно находится в собственном оверлее.
Шаг 1: Точка входа для модификатора представления
Публичные модификаторы toast представляют собой тонкие обертки. Их единственная задача — передавать конфигурацию внутреннему модификатору ToastWindowModifier.
Это обеспечивает чистоту API, позволяя при этом реализации развиваться независимо.
private struct ToastWindowModifier<T: View>: ViewModifier {
@Binding var isPresented: Bool
let duration: TimeInterval?
let edge: VerticalEdge
let onDismiss: (() -> Void)?
let toastView: () -> T
@State private var overlay = OverlayWindow()
@State private var isToastPresented: Bool = false
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.onChange(of: isPresented) { _, newValue in
if newValue {
overlay.show {
Color.clear
.toastOverlay(
isPresented: $isToastPresented,
duration: duration,
edge: edge,
onDismiss: {
overlay.hide()
onDismiss?()
},
content: toastView
)
.preferredColorScheme(colorScheme)
}
withAnimation {
isToastPresented = true
}
} else {
isToastPresented = false
}
}
.onChange(of: isToastPresented) { _, newValue in
if !newValue { isPresented = false }
}
}
}
Шаг 2: Рендеринг Toast UI
Фактическое всплывающее уведомление находится в отдельном модификаторе, ToastOverlayModifier.
Этот слой отвечает за:
- позиционирование в верхней или нижней части экрана
- анимацию появления и исчезновения
- автоматическое закрытие с помощью
Task - жесты перетаскивания для закрытия с обработкой порогового значения
Всплывающее уведомление добавляется с помощью .overlay(alignment:) и анимируется с помощью направленного перехода, соответствующего выбранному краю.
Ручное закрытие ощущается естественно: при перетаскивании в нужном направлении toast постепенно смещается и становится прозрачным, пока не пересечёт заданный порог (threshold), после чего скрывается.
private struct ToastOverlayModifier<T: View>: ViewModifier {
@Binding var isPresented: Bool
let duration: TimeInterval?
let edge: VerticalEdge
let onDismiss: (() -> Void)?
let toastView: () -> T
private let animationDuration: TimeInterval = 0.3
@State private var dismissTask: Task<Void, Never>? = nil
@State private var dragOffsetY: CGFloat = 0
private var aligment: Alignment {
switch edge {
case .top: .top
case .bottom: .bottom
}
}
private var transitionEdge: Edge {
switch edge {
case .top: .top
case .bottom: .bottom
}
}
func body(content: Content) -> some View {
content
.onChange(of: isPresented) { _, newValue in
guard !newValue else { return }
cancelAutoDismiss()
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(animationDuration * 1_000_000_000))
onDismiss?()
}
}
.overlay(alignment: aligment) {
if isPresented {
toastView()
.offset(y: dragOffsetY)
.opacity(Double(max(CGFloat(0.5), 1 - abs(dragOffsetY) / 200)))
.gesture(
DragGesture(minimumDistance: 5, coordinateSpace: .local)
.onChanged { value in
cancelAutoDismiss()
// Only track vertical drag in the correct direction relative to edge
let dy = value.translation.height
switch edge {
case .bottom:
// Allow dragging down (positive dy); clamp upwards movement to zero to avoid jitter
dragOffsetY = max(0, dy)
case .top:
// Allow dragging up (negative dy); clamp downwards movement to zero
dragOffsetY = min(0, dy)
}
}
.onEnded { value in
let threshold: CGFloat = 30
let dy = value.translation.height
var shouldDismiss = false
switch edge {
case .bottom:
if dy > threshold { shouldDismiss = true }
case .top:
if dy < -threshold { shouldDismiss = true }
}
if shouldDismiss {
// Trigger dismiss and reset offset
withAnimation(.bouncy(duration: animationDuration)) {
isPresented = false
}
} else {
scheduleAutoDismiss()
// Snap back
withAnimation(.bouncy(duration: animationDuration)) {
dragOffsetY = 0
}
}
}
)
.transition(.move(edge: transitionEdge).combined(with: .blurReplace))
.onAppear {
scheduleAutoDismiss()
}
}
}
.animation(.bouncy, value: isPresented)
}
private func scheduleAutoDismiss() {
guard let duration, duration > 0 else { return }
cancelAutoDismiss()
dismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
if !Task.isCancelled && isPresented {
isPresented = false
}
}
}
private func cancelAutoDismiss() {
dismissTask?.cancel()
dismissTask = nil
}
}
Модификатор ToastWindowModifier отвечает за отображение и скрытие окна-оверлея.
Когда isPresented становится true:
- создается новое прозрачное окно
UIWindow UIHostingControllerотображает содержимое SwiftUI- окно размещается над приложением с использованием окна высокого уровня окна
- активируется оверлей тоста
Когда всплывающее окно-оверлей закрывается:
- анимируется закрытие оверлея
- окно скрывается и освобождается
- состояние синхронизируется с исходным биндингом
Такое разделение предотвращает сбои анимации и делает логику закрытия предсказуемой.
Шаг 4: Передача касаний через окно
Стандартное окно UIWindow перехватывает все касания, даже если оно прозрачное. Это блокирует взаимодействие с приложением под ним.
Для решения этой проблемы в реализации используется кастомный подкласс UIWindow, который переопределяет hitTest. Касания обрабатываются только в том случае, если они попадают на интерактивное дочернее представление (тост). Все остальное передается приложению ниже.
Сложность заключается в обработке разных версий iOS и нового модификатора .glassEffect. При применении .glassEffect имя слоя становится "@1", а .glassProminent — "@2". Поэтому мы можем проверить префикс, чтобы обнаружить эффект «жидкого стекла».
public final class PassThroughWindow: UIWindow {
private var handledEvents = Set<UIEvent>()
public override init(frame: CGRect) {
super.init(frame: frame)
}
public convenience init() {
self.init(frame: .zero)
}
@available(iOS 13.0, *)
public override init(windowScene: UIWindowScene) {
super.init(windowScene: windowScene)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override final func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let rootViewController, let rootView = rootViewController.view else { return nil }
guard let event else {
return super.hitTest(point, with: nil)
}
guard let hitView = super.hitTest(point, with: event) else {
handledEvents.removeAll()
return nil
}
if handledEvents.contains(event) {
handledEvents.removeAll()
return hitView
} else if #available(iOS 26, *) {
handledEvents.insert(event)
let name = rootView.layer.hitTest(point)?.name
if name == nil {
return hitView
} else if name?.starts(with: "@") == true { // Liquid Glass Detection
if let realHit = deepestHitView(in: rootView, at: point, with: event) {
if realHit === rootView {
return nil
} else {
return realHit
}
} else {
return nil
}
} else {
return nil
}
} else if #available(iOS 18, *) {
handledEvents.insert(event)
return hitView
} else {
return hitView
}
}
private func deepestHitView(in root: UIView, at point: CGPoint, with event: UIEvent?) -> UIView? {
guard !root.isHidden, root.alpha > 0.01, root.isUserInteractionEnabled else { return nil }
for subview in root.subviews.reversed() {
let pointInSubview = subview.convert(point, from: root)
if let hit = deepestHitView(in: subview, at: pointInSubview, with: event) {
return hit
}
}
return root.point(inside: point, with: event) ? root : nil
}
}
Подробнее об этом компоненте можно прочитать здесь.
Шаг 5: Безопасность сцены и фокусировки
Оверлейное окно привязывается к текущей активной сцене, вместо предположения, что приложение использует единственное окно.
При этом оно не становится key window, что позволяет избежать побочных эффектов, связанных с first responder, клавиатурой и управлением фокусом.
@MainActor
private final class OverlayWindow {
private var window: UIWindow?
func show<Content: View>(@ViewBuilder content: () -> Content) {
let scenes = UIApplication
.shared
.connectedScenes
.compactMap { $0 as? UIWindowScene }
let scene = scenes.first(where: { $0.activationState == .foregroundActive })
?? scenes.first(where: { $0.activationState == .foregroundInactive })
?? scenes.first
guard let scene else { return }
let window = PassThroughWindow(windowScene: scene)
let controller = UIHostingController(rootView: content())
controller.view.backgroundColor = .clear
window.windowLevel = .alert + 1
window.backgroundColor = .clear
window.rootViewController = controller
window.isHidden = false
self.window = window
}
func hide() {
window?.isHidden = true
window = nil
}
}
Пример
Наконец, давайте рассмотрим пример использования:
import SwiftUI
struct ToastDemo: View {
@State private var isToastPresented: Bool = false
var body: some View {
Button("Show Toast") {
isToastPresented.toggle()
}
.toast(
isPresented: $isToastPresented,
duration: 2,
edge: .top
) {
HStack(spacing: 12) {
Image(systemName: "bell.fill")
.foregroundStyle(.yellow)
Text("Custom content toast")
.font(.callout)
.fontWeight(.semibold)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
)
}
}
}
Заключение
Финальный компонент обладает необходимыми нам свойствами:
- работает в любом месте приложения
- остается поверх всех слоев пользовательского интерфейса
- не блокирует взаимодействие
- поддерживает жесты и автоматическое закрытие
- остается простым в использовании
Самое главное, он ведет себя так, как и должно вести себя всплывающее сообщение — информативно, ненавязчиво и временно.
Полная реализация доступна на GitHub.
-
Маркетинг и монетизация2 недели назад
Как ML-подход удвоил первые покупки при снижении CPI, CAC, ДРР: «Яндекс Маркет» и Bidease
-
Видео и подкасты для разработчиков3 недели назад
Видео droidcon London 2025
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2026.5
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2026.6
