Connect with us

Разработка

Создание тоста в SwiftUI за 5 шагов

В этой статье я расскажу о практическом способе реализации всплывающего уведомления в SwiftUI. Решение является лёгким, многоразовым и работает поверх всего пользовательского интерфейса приложения.

Опубликовано

/

     
     

В SwiftUI до сих пор нет встроенного компонента для всплывающих уведомлений (toast ). Существуют оповещения (alert) и всплывающие окна (sheet), но они, по своей сути, являются модальными. Тост решает другую проблему: оно предоставляет кратковременную обратную связь, не прерывая рабочий процесс пользователя.

В этой статье я расскажу о практическом способе реализации всплывающего уведомления в SwiftUI. Решение является лёгким, многоразовым и работает поверх всего пользовательского интерфейса приложения.

Что такое тост (и чем он не является)

Всплывающее сообщение, тост — это кратковременное уведомление, которое:

  • отображается на экране в течение короткого времени
  • не блокирует взаимодействие с основным интерфейсом
  • автоматически исчезает по истечении тайм-аута
  • в ряде случаев может быть закрыто пользователем вручную (например, жестом смахивания)

Почему простого .overlay часто недостаточно

Наиболее распространенный подход в SwiftUI — это аттач тоста через .overlay где-либо в иерархии представлений. Это работает в простых случаях, но имеет ограничения:

  • тост ограничен текущим деревом представлений
  • он может появляться за панелями навигации, панелями вкладок или листами
  • для надежной работы требуется «корневой контейнер»

Если вам нужно всплывающее сообщение, которое постоянно отображается над всем — навигацией, вкладками, модальными окнами (.sheet/.fullScreenCover) — вам нужен другой подход.

Основная идея: временное окно-наложение

В этой реализации используется кратковременное окно UIWindow, расположенное над главным окном приложения.

Идея проста:

  1. При запросе всплывающего уведомления создайте прозрачное окно над приложением
  2. Отобразите содержимое SwiftUI внутри этого окна с помощью UIHostingController
  3. Сделайте интерактивным только всплывающее уведомление; все остальные касания должны проходить мимо
  4. Анимируйте появление и исчезновение всплывающего уведомления
  5. Уничтожьте окно, когда всплывающее уведомление будет закрыто

Это позволяет отображать тост глобально, оставаясь при этом независимым от иерархии представлений самого приложения.

Публичный 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.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: