Connect with us

Разработка

PassThroughWindow в iOS 26: наложенное окно, которое не перехватывает жесты

PassThroughWindow решает эту проблему. Он позволяет вашему наложению быть видимым и интерактивным там, где это важно (само всплывающее уведомление), но все остальное передается вниз, в окна под ним.

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

/

     
     

Overlay окна удобны, когда вам нужен пользовательский интерфейс, который располагается поверх всего остального. Вспомните о глобальных всплывающих уведомлениях, сетевых баннерах, строках состояния вызова, плавающих панелях отладки или контекстном меню, которое вы хотите отображать независимо от основной иерархии представлений.

Недостаток очевиден: окно UIWindow, расположенное поверх вашего приложения, перехватывает все пространство для жестов. Даже если наложенное окно визуально «пустое» вокруг вашего всплывающего уведомления, система все равно может сначала перенаправлять касания в это верхнее окно. В результате приложение работает некорректно: прокрутка в окнах перестает работать, кнопки под наложением перестают реагировать, и пользовательский интерфейс кажется «заблокированным» без видимой причины.

PassThroughWindow решает эту проблему. Он позволяет вашему наложению быть видимым и интерактивным там, где это важно (само всплывающее уведомление), но все остальное передается вниз, в окна под ним.

Это особенно полезно, когда вы отображаете всплывающие уведомления в отдельном окне: всплывающему уведомлению нужны касания (закрытие, кнопка действия), но остальная часть экрана должна оставаться полностью работоспособной.

Основная идея: возвращать nil, чтобы пропускать касания.

Проверка попаданий в UIKit основана на простом контракте:

  • Если окно возвращает UIView из hitTest(_:with:), это окно обработает событие
  • Если оно возвращает nil, UIKit продолжит поиск обработчика в окнах ниже.

Это означает, что сквозное окно в основном сводится к одному: когда касание попадает на ваше «фоновое» представление, верните nil, чтобы UIKit продолжил проверку попаданий в нижних окнах приложения.

Фоновое представление — это корневое представление rootViewController окна. Когда происходит касание корневого представления, окно отключается.

else if hitView == rootView {
    return nil
}

Но версии iOS здесь непоследовательны, а современные визуальные эффекты (например, «Жидкое стекло») усложняют ситуацию. Однако мы можем справиться и с тем, и с другим.

Создание окна: минимальный подкласс

Компонент является подклассом UIWindow:

import UIKit

final class PassThroughWindow: UIWindow {
    private var handledEvents = Set<UIEvent>()

    override final func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        ...
    }
}

handledEvents — ключ к выживанию при изменениях поведения в новых версиях iOS.

Шаг 1: Выходите раньше, если окно не готово

Первый шаг — защитный: если нет корневого контроллера (или корневого представления), то нет ничего значимого для проверки нажатия.

guard let rootViewController, let rootView = rootViewController.view else { return nil }

Возвращение nil делает окно прозрачным для взаимодействий при неправильной настройке, что является гораздо лучшим вариантом обработки сбоев, чем игнорирование касаний.

Шаг 2: Обработка случая «Нет события»

UIKit иногда вызывает hitTest с событием nil. В этом случае самым безопасным вариантом является возврат к базовой реализации.

guard let event else {
    return super.hitTest(point, with: nil)
}

Шаг 3: Сначала спросите UIKit

Мы можем делегировать проверку касаний встроенной в UIKit функцией проверки:

guard let hitView = super.hitTest(point, with: event) else {
    handledEvents.removeAll()
    return nil
}

Если UIKit возвращает nil, то в окне в этой точке нет интерактивного представления. Очистка handledEvents — это защитный шаг. Логика с «двойным хитом» основана на эмпирических наблюдениях, поэтому код старается не допустить устаревания кэша и держать его в актуальном состоянии.

Шаг 4: Обработка двойного попадания (реальность iOS 18+)

Если UIWindow возвращает представление при первом hitTest, iOS может выполнить второй hitTest для того же UIEvent. Если хотя бы одно из них не удается, событие может быть отклонено для этого окна.

Таким образом, код отслеживает, было ли это UIEvent уже обработано:

if handledEvents.contains(event) {
    handledEvents.removeAll()
    return hitView
}

При втором hitTest окно всегда возвращает результат UIKit (hitView) и не применяет логику pass-through. Это обеспечивает согласованность между двумя вызовами hitTest.

Особенно это критично на iOS 18, если корневой контроллер — UIHostingController: второй hitTest может вернуть root view вместо фактического сабвью, по которому изначально пришёл тап. Наивная реализация pass-through воспримет этот второй вызов как попадание «в фон» и вернёт nil, что полностью ломает обработку события.

Решение простое: при втором вызове всегда доверяем результату UIKit.

Шаг 5: Логика pass-through для iOS 17 и ниже

После обработки кейса «событие уже встречалось» включается классическая логика.

Если hitView — это root view, то тап должен быть пропущен дальше, в нижележащие окна:

else if hitView == rootView {
    return nil
}

Если элемент, на который нажал пользователь, не является root элементом, это означает, что касание произошло на дочернем элементе (например, на содержимом всплывающего уведомления). В этом случае верните его, чтобы overlay обработал касание.

else {
    return hitView
}

Именно этого и ожидаешь от toast-окна: сам тост интерактивный, а всё остальное — прозрачное и пропускает события дальше.

Шаг 6: Специальная обработка первого вызова на iOS 18

Для iOS 18 добавляется ещё один шаг: событие помечается как «уже обработанное» (seen), чтобы при втором вызове сработало правило «всегда возвращаем UIKit».

else if #available(iOS 18, *) {
    handledEvents.insert(event)
    return hitView
}

Важный нюанс: это происходит только если hitView != rootView, то есть когда вы действительно попали в интерактивное сабвью.

Если же попадание пришлось в rootView, метод сразу возвращает nil и не кэширует событие.

Шаг 7: iOS 26+ и Liquid Glass

В iOS 26 появляется дополнительная сложность при использовании эффектов Liquid Glass. Определение Liquid Glass реализовано через анализ результата многоуровневого hit-теста и проверку имени слоя (layer.name):

let name = rootView.layer.hitTest(point)?.name

Затем:

  • Если name == nil, код работает нормально и возвращает hitView.
  • Если имя начинается с «@«, это рассматривается как указание на слои Liquid Glass, и код переключается на более явный путь разрешения попаданий.
if name?.starts(with: "@") == true { ... }

В этом случае вам не нужно, чтобы само Liquid Glass становилось целью для касания. Вам нужно, чтобы касание перешло к реальному интерактивному дочернему представлению или прошло сквозь него.

Поиск реального интерактивного представления: deepestHitTestView

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

private func deepestHitTestView(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 = deepestHitTestView(in: subview, at: pointInSubview, with: event) {
            return hit
        }
    }

    return root.point(inside: point, with: event) ? root : nil
}

Несколько деталей обеспечивают эффективную работу:

  • Он пропускает скрытые, прозрачные или неинтерактивные представления на ранних этапах.
  • Он выполняет итерацию в обратном порядке, поэтому визуально приоритет отдается представлениям, расположенным сверху.
  • Он корректно преобразует координаты по мере спуска.

И, наконец, ключевое правило:

  • Если самый глубокий элемент — это сам rootView, рассматривайте его как фон и возвращайте nil.
  • Если это дочерний элемент, верните его и позвольте наложению обработать касание.
if let realHit = deepestHitTestView(in: rootView, at: point, with: event) {
    if realHit === rootView {
        return nil
    } else {
        return realHit
    }
} else {
    return nil
}

Это восстанавливает задуманную семантику даже тогда, когда сложные эффекты слоев мешают проверке попадания.

Заключение

Выделенный наложенный UIWindow — мощный инструмент для глобального пользовательского интерфейса, но легко нарушить взаимодействие, если рассматривать его как обычное окно.

PassThroughWindow дает вам лучшее из обоих миров:

  • Ваше всплывающее сообщение (или наложенный UI) может располагаться «поверх всего»
  • Остальная часть приложения остается полностью интерактивной
  • Двойная проверка попадания в iOS 18+ и особенности Liquid Glass в iOS 26 обрабатываются явно

Если вы когда-либо использовали наложенный компонент, а затем обнаруживали, что прокрутка случайным образом перестала работать, то это тот тип компонента, который окупится для вас немедленно.

Исходный код: https://github.com/Livsy90/PassThroughWindow/tree/main

import UIKit

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 {
                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
    }
}

Источник

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

Популярное

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

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