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

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

Сначала выберите правило проверки попадания, затем меняйте обработку событий

В документации Apple описаны два подходящих средства SwiftUI. allowsHitTesting(_:) определяет, участвует ли представление в проверке попадания вообще. contentShape(_:eoFill:) задаёт форму, используемую для проверки попадания. Первое подходит для декора, который никогда не должен получать щелчки; второе — для интерактивного перекрывающего слоя с отверстиями. В документации Apple об участии в проверке попадания и документации о форме содержимого описаны эти отдельные функции.

Требование к верхнему слою Вариант реализации
Декор должен игнорировать любые щелчки Примените к этому декору .allowsHitTesting(false)
Круг должен реагировать на щелчки внутри контура Задайте интерактивному представлению круглую форму содержимого
Кольцо должно реагировать на щелчки вокруг прозрачного центра Используйте составную форму и правило чётности для рисования и проверки попадания
Для принадлежащей вам иерархии AppKit нужно выборочное участие Переопределите точку расширения hitTest(_:) и отклоняйте точки вне области взаимодействия

Эти API описывают участие и геометрию; они не задают автоматическую проверку альфа-канала итогового отрисованного пикселя. Для синтетических форм предусмотрен поддерживаемый способ: явно описать желаемую область взаимодействия. Поэтому одной лишь прозрачности недостаточно, чтобы определить поведение при взаимодействии. Это различие следует из документированных интерфейсов на основе логического значения и формы, а не из обещания, что так будут работать все модификаторы отрисовки.

Полный пример SwiftUI: кольцо поверх кнопки

Создайте в Xcode приложение для macOS на SwiftUI и установите целевую версию macOS 12 или новее. Замените сгенерированное объявление приложения и начальное представление приведённой ниже полной программой, оставив в целевом объекте только одно объявление @main. В примере используется .contentShape(.interaction, …): этот тип взаимодействия доступен начиная с macOS 12 и явно управляет проверкой попадания. В документации Apple о взаимодействии описаны его поведение и доступность.

Кольцо состоит из двух концентрических эллиптических контуров. Для рисования и взаимодействия используется правило чётности, поэтому внутренний эллипс образует отверстие. В документации Apple сказано, что Shape.path(in:) задаёт геометрию внутри предоставленного прямоугольника, а FillStyle — параметры отрисовки, включая правило чётности. Основные контракты описаны в документации о геометрии фигур и FillStyle.

import SwiftUI

struct RingShape: Shape {
    func path(in rect: CGRect) -> Path {
        let side = min(rect.width, rect.height)
        let outer = CGRect(
            x: rect.midX - side / 2,
            y: rect.midY - side / 2,
            width: side,
            height: side
        )
        let inner = outer.insetBy(
            dx: side * 0.30,
            dy: side * 0.30
        )

        var path = Path()
        path.addEllipse(in: outer)
        path.addEllipse(in: inner)
        return path
    }
}

struct RoutingDemo: View {
    @State private var lowerClicks = 0
    @State private var ringClicks = 0

    var body: some View {
        VStack(spacing: 18) {
            Text("Click the center or the orange ring")

            ZStack {
                Button {
                    lowerClicks += 1
                } label: {
                    Rectangle()
                        .fill(Color.blue)
                        .frame(width: 240, height: 240)
                        .overlay {
                            Text("Below")
                                .foregroundColor(.white)
                        }
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)

                RingShape()
                    .fill(Color.orange, style: FillStyle(eoFill: true))
                    .frame(width: 240, height: 240)
                    .contentShape(.interaction, RingShape(), eoFill: true)
                    .onTapGesture {
                        ringClicks += 1
                    }

                Rectangle()
                    .stroke(Color.gray, lineWidth: 1)
                    .frame(width: 240, height: 240)
                    .allowsHitTesting(false)
            }
            .frame(width: 240, height: 240)

            Text("Below: \(lowerClicks) | Ring: \(ringClicks)")
        }
        .padding(24)
    }
}

@main
struct TransparentRoutingApp: App {
    var body: some Scene {
        WindowGroup {
            RoutingDemo()
        }
    }
}

Нижняя кнопка сама выполняет своё действие. Жест кольца увеличивает другой счётчик. Эти замыкающие замыкания напрямую задают оба действия; в руководстве по языку Swift замыкания описаны как функциональность, способная захватывать окружающие значения. В разделе The Swift Programming Language: Closures объясняются синтаксис и поведение захвата значений.

Серый контур — это декор, поэтому для него отдельно отключена проверка попадания. Оставьте этот модификатор на контуре: если применить его к контейнеру с интерактивными слоями, контейнер получит неподходящую политику участия. Само кольцо должно оставаться интерактивным в области оранжевой полосы.

Почему одно и то же правило заливки важно применять дважды

Вызов рисования и модификатор формы содержимого оба получают параметр eoFill: true. Если задать его только для рисования, видимое отверстие будет описано, но правило для проверки попадания не будет задано явно. Apple документирует параметр формы содержимого отдельно от отрисовки, поэтому в этом примере нужны оба объявления. Модификатор формы содержимого задаёт форму взаимодействия и правило её обхода.

Также согласуйте размеры и преобразования. Здесь для рисования кольца и его формы содержимого используется один квадрат размером 240 пунктов. Если позже вы переместите или масштабируете всё представление кольца, выполняйте эти операции вместе. Если же вы измените путь рисования только внутри большего контейнера, обновите геометрию взаимодействия, чтобы она описывала ту же нужную область.

Проверьте маршрутизацию тремя конкретными щелчками

Активируйте окно приложения и щёлкайте вдали от границ фигур. Ниже приведены примерные локальные координаты внутри квадрата размером 240 пунктов, отсчитываемые от его верхнего левого угла. Они позволяют проверить центр отверстия, оранжевую полосу и прозрачный угол без опоры на пиксели сглаженных краёв.

Точка Геометрия верхнего слоя в этой точке Ожидаемое изменение счётчика
(120, 120) Центральное отверстие Below увеличивается на единицу
(210, 120) Оранжевая полоса Ring увеличивается на единицу
(10, 10) За пределами внешнего круга кольца Below увеличивается на единицу

Внешний радиус равен 120 пунктам. Внутренний отступ составляет 72 пункта с каждой стороны, поэтому внутренний радиус равен 48 пунктам. Три тестовые точки находятся на расстоянии соответственно 0, 90 и примерно 155,6 пункта от центра. Этот детерминированный геометрический расчёт я проверил с помощью Python; код приложения SwiftUI был изучен, но скомпилировать или запустить его в этой среде не удалось, поскольку инструментарий Swift недоступен.

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

Для непосредственной проверки геометрии в собственном коде подходит Path.contains(_:eoFill:) с тем же выбором правила чётности. Передайте точку в системе координат созданного вами пути. Так геометрическая проверка может задать тот же вопрос о том, находится ли точка внутри фигуры, не считая снимок экрана моделью взаимодействия. Операция описана в документации Apple о проверке принадлежности пути.

Используйте проверку попадания AppKit только на принадлежащей вам границе

Если перекрывающее представление уже реализовано в AppKit, документированной точкой настройки служит NSView.hitTest(_:). Она возвращает дочернее представление, выбранное для точки, и в основном используется для выбора получателя нажатия кнопки мыши. Входная точка задана в координатах супервью, поэтому перед сравнением с локальной геометрией преобразуйте её. Эти детали указаны в документации Apple о проверке попадания NSView.

В принадлежащем вам пользовательском представлении переопределение может отклонять точки вне области взаимодействия и передавать принятые точки в super.hitTest(_:). Ниже приведён фрагмент метода для существующего подкласса NSView, а не второе полное приложение. В нём используется определённая выше RingShape; рядом с import SwiftUI потребуется import AppKit.

override func hitTest(_ point: NSPoint) -> NSView? {
    guard let parent = superview else {
        return super.hitTest(point)
    }

    let localPoint = convert(point, from: parent)
    let region = RingShape().path(in: bounds)

    guard region.contains(localPoint, eoFill: true) else {
        return nil
    }

    return super.hitTest(point)
}

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

Встраивая это принадлежащее вам представление AppKit в SwiftUI, используйте NSViewRepresentable для его создания и обновления конфигурации. Синхронизируйте состояние элементов и геометрию взаимодействия в методе обновления представления, реализованного через representable; при передаче сообщений делегату или target-action используйте координатор. Apple также указывает, что управление frame и bounds корневого представления, предоставляемого через representable, следует оставить SwiftUI. Эти границы жизненного цикла и владения описаны в документации Apple о NSViewRepresentable.

Сохраняйте исходное событие и владельца жеста

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

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

Для этой настольной задачи используйте путь кольца как общее описание видимой полосы и области, реагирующей на щелчки. Тогда прозрачный центр имеет точный смысл: верхний слой отклоняет точку, а нижний элемент управления SwiftUI по-прежнему отвечает за реакцию через обычную систему взаимодействия.

Источники