Для приложения Mac с горизонтальными полками внутри вертикального списка назначьте каждому жесту понятное место назначения. Начните с настройки осей SwiftUI; если нужна явная маршрутизация, управляйте вложенными прокручиваемыми представлениями AppKit через мост SwiftUI.
Конкретная задача — просмотр синтетических карточек: вертикальная прокрутка над любой полкой должна перемещать весь список, а горизонтальная — независимо перемещать эту полку. Для этого у каждой строки должно быть собственное горизонтальное смещение. Это отличается от единого холста, который одновременно перемещается по горизонтали и вертикали.
Выберите структуру прокрутки, соответствующую задаче
Apple указывает, что ScrollView в SwiftUI может прокручиваться по горизонтали, по вертикали или по обеим осям. Для одного большого документа используйте ScrollView([.horizontal, .vertical]) и задайте его содержимому размеры, превышающие область просмотра. Это самая простая структура, когда у всего содержимого одна общая горизонтальная и вертикальная позиция. ScrollView
Для независимых полок нужны отдельные контейнеры прокрутки. Естественная структура SwiftUI — вертикальный ScrollView с вложенными горизонтальными экземплярами ScrollView, каждый из которых имеет ограниченную высоту. Однако настройка этих осей сама по себе не определяет, как событие, полученное внутренним контейнером, должно передаваться внешнему. В документации Apple по ScrollView описаны поддерживаемые оси, но не установлен общий контракт маршрутизации событий для этого случая вложения. ScrollView
| Требуемое поведение | Подходящий начальный вариант |
|---|---|
| Один документ перемещается в обоих направлениях | Один двухосевой SwiftUI ScrollView |
| Каждая полка сохраняет собственную горизонтальную позицию | Вложенные горизонтальные полки внутри вертикального контейнера |
| Для вертикальных жестов над полками нужно явно указать место назначения | Подклассы NSScrollView, принадлежащие приложению и предоставленные через NSViewRepresentable |
| Диагональный жест должен свободно перемещать весь документ | Один двухосевой контейнер; AppKit также предоставляет usesPredominantAxisScrolling |
AppKit различает содержимое, которое преимущественно прокручивается по одной оси за раз, и содержимое, например холст, которое должно свободно перемещаться по обеим осям. Это различие выражает свойство usesPredominantAxisScrolling; согласно документации, его значение по умолчанию — true. Это свойство относится к поведению прокручиваемого представления, а не к выбору другого вложенного прокручиваемого представления в качестве места назначения события. usesPredominantAxisScrolling
Разберитесь в документированных границах передачи событий
Название wantsForwardedScrollEvents(for:) может навести на мысль об универсальном исправлении вложенной прокрутки. Apple описывает более узкое условие: представления, выполняющие эластичную прокрутку, могут передавать события жестов, если в начале жеста содержимое уже находится у соответствующего края. В документации не утверждается, что обычные события колеса мыши или произвольное разделение по осям передаются безусловно. wantsForwardedScrollEvents(for:)
Аналогично, реализация NSResponder.scrollWheel(with:) по умолчанию передаёт сообщение следующему ответчику, а NSScrollView реализует этот метод для прокрутки. Поэтому важно различать передачу не обработанного ответчиком события дальше и его обработку контейнером прокрутки. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)
Для интерфейса, где нужен явный контроль, доступны такие средства: NSViewRepresentable встраивает представление AppKit в SwiftUI, а NSHostingView встраивает содержимое SwiftUI в AppKit. В приведённой ниже реализации эти мосты используются для управления обоими контейнерами прокрутки, а карточки остаются в SwiftUI. Политика маршрутизации реализована в коде приложения на основе документированных API, а не взята из готового рецепта Apple для вложенной прокрутки. NSViewRepresentable, NSHostingView
Создайте пример вложенной прокрутки под управлением приложения
Создайте в Xcode проект приложения для macOS на SwiftUI и Swift. Установите целевую версию macOS 11 или новее. Замените сгенерированную точку входа приложения первым файлом ниже, затем добавьте второй файл в ту же цель. Вместе эти два файла составляют полноценную демонстрационную программу с синтетическими подписями и без дополнительных ресурсов.
В примере используется жизненный цикл SwiftUI App, доступный начиная с macOS 11. В цели должна быть ровно одна декларация @main: документация Apple по жизненному циклу и справочник по языку Swift требуют наличия одной точки входа программы. App, Attributes
Создайте внешний список и карточки SwiftUI
Первый файл создаёт восемь полок. На каждой полке по шестнадцать карточек и собственный горизонтальный документ. Внешний документ отслеживает ширину области просмотра и имеет фиксированную демонстрационную высоту, поэтому вертикальная прокрутка нужна только внешнему контейнеру.
SwiftUI управляет рамкой представленного внешнего представления. Пользовательская компоновка изменяет только документ AppKit и рамки полок внутри него, соблюдая предупреждение Apple о том, что не следует менять свойства компоновки представления, которым напрямую управляет NSViewRepresentable. NSViewRepresentable
Сохраните это в файле NestedScrollDemoApp.swift:
import SwiftUI
import AppKit
@main
struct NestedScrollDemoApp: App {
var body: some Scene {
WindowGroup {
ShelfBoard()
.frame(minWidth: 420, minHeight: 320)
}
}
}
struct ShelfBoard: NSViewRepresentable {
func makeNSView(context: Context) -> BoardScrollView {
BoardScrollView(frame: .zero)
}
func updateNSView(_ nsView: BoardScrollView, context: Context) {
// The demonstration content is static.
}
}
struct ShelfCards: View {
let row: Int
var body: some View {
HStack(spacing: 12) {
ForEach(1...16, id: \.self) { column in
Text("Shelf \(row) · Card \(column)")
.frame(width: 140, height: 96)
.background(Color.gray.opacity(0.15))
.cornerRadius(8)
}
}
.padding(12)
.frame(width: 2444, height: 120)
}
}
final class TopDocument: NSView {
override var isFlipped: Bool { true }
}
final class BoardScrollView: NSScrollView {
private let board = TopDocument(frame: .zero)
private var shelves: [ShelfScrollView] = []
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
hasVerticalScroller = true
hasHorizontalScroller = false
autohidesScrollers = true
horizontalScrollElasticity = .none
documentView = board
for row in 1...8 {
let shelf = ShelfScrollView(frame: .zero)
shelf.verticalOwner = self
shelf.hasHorizontalScroller = true
shelf.hasVerticalScroller = false
shelf.autohidesScrollers = true
shelf.verticalScrollElasticity = .none
let cards = NSHostingView(rootView: ShelfCards(row: row))
cards.frame = NSRect(x: 0, y: 0, width: 2444, height: 120)
shelf.documentView = cards
board.addSubview(shelf)
shelves.append(shelf)
}
}
required init?(coder: NSCoder) {
fatalError("Use init(frame:)")
}
override func layout() {
super.layout()
let width = contentSize.width
board.frame = NSRect(x: 0, y: 0, width: width, height: 1184)
for (index, shelf) in shelves.enumerated() {
shelf.frame = NSRect(
x: 0, y: 8 + CGFloat(index) * 146,
width: width, height: 140
)
}
}
}
NSScrollView координирует представление документа, область отсечения и полосы прокрутки. Здесь внешний документ содержит прокручиваемые представления полок; документ каждой полки — это NSHostingView, содержащий карточки SwiftUI. Ширина 2444 точек — иллюстративный расчёт компоновки: шестнадцать карточек по 140, пятнадцать промежутков по 12 и 24 точек горизонтальных отступов. NSScrollView, NSHostingView
Маршрутизируйте жест один раз и сохраняйте его назначение
Во втором файле задаётся политика маршрутизации. Жест, первое ненулевое перемещение которого преимущественно вертикальное, направляется внешнему списку. В противном случае он остаётся на полке. При равенстве направлений жест назначается полке. Это явные проектные решения для данного примера.
Apple предоставляет отдельные свойства phase и momentumPhase. Устаревшие события колеса не имеют ни одной из фаз, а события инерционной прокрутки не имеют обычной фазы жеста. Поэтому пример обрабатывает событие колеса без фазы отдельно, а назначение жеста с фазами сохраняет на протяжении последовательности инерционной прокрутки. phase, momentumPhase
Сохраните это в файле ShelfScrollView.swift:
import AppKit
final class ShelfScrollView: NSScrollView {
weak var verticalOwner: NSScrollView?
private var routesVertically: Bool?
private var pending: [NSEvent] = []
override func scrollWheel(with event: NSEvent) {
let phase = event.phase
let momentum = event.momentumPhase
let x = event.scrollingDeltaX
let y = event.scrollingDeltaY
// A legacy wheel event has no gesture or momentum phase.
if phase.isEmpty && momentum.isEmpty {
routesVertically = nil
pending.removeAll()
deliver(event, vertically: abs(y) > abs(x))
return
}
if phase.contains(.mayBegin) {
routesVertically = nil
pending = [event]
return
}
if phase.contains(.began) {
routesVertically = nil
if pending.last?.phase.contains(.mayBegin) != true {
pending.removeAll()
}
}
let finishes = phase.contains(.cancelled)
|| momentum.contains(.ended)
|| momentum.contains(.cancelled)
if let vertical = routesVertically {
deliver(event, vertically: vertical)
} else {
// Keep zero-delta opening events until direction is known.
pending.append(event)
if x != 0 || y != 0 {
let vertical = abs(y) > abs(x)
routesVertically = vertical
let openingEvents = pending
pending.removeAll()
for openingEvent in openingEvents {
deliver(openingEvent, vertically: vertical)
}
} else if phase.contains(.ended) || finishes {
pending.removeAll()
}
}
if finishes {
routesVertically = nil
pending.removeAll()
}
// Retain direction after phase.ended for possible momentum.
}
private func deliver(_ event: NSEvent, vertically: Bool) {
if vertically, let owner = verticalOwner {
owner.scrollWheel(with: event)
} else {
super.scrollWheel(with: event)
}
}
}
Код читает scrollingDeltaX и scrollingDeltaY только для выбора места назначения. Исходное событие передаётся одному прокручиваемому представлению, чтобы прокруткой занимался AppKit. Приращение не переинтерпретируется как смещение в точках. Это важно, поскольку Apple описывает различную обработку точных и неточных приращений; например, при самостоятельной реализации перемещения приложением неточные вертикальные приращения требуют масштабирования с учётом высоты строки. scrollingDeltaX, scrollingDeltaY
Слабая ссылка verticalOwner указывает непосредственно на внешнее прокручиваемое представление, созданное в этом примере. Это позволяет не зависеть от внутренней иерархии представлений SwiftUI. В примере не добавляется монитор событий; переопределённый метод обрабатывает события, доставленные представлениям полок приложения.
Проверьте оба направления, инерцию и изменение размера
Этот пример проверен по полученной документации API, но не компилировался и не запускался: в исследовательской среде нет компилятора Swift и среды выполнения macOS. Перед использованием этой политики маршрутизации в существующем интерфейсе соберите проект в Xcode и выполните следующие проверки.
| Проверка | Ожидаемый результат |
|---|---|
| Прокрутка по вертикали при указателе над карточкой | Перемещается внешний список |
| Прокрутка по горизонтали над первой полкой | По горизонтали перемещается только эта полка |
| Горизонтальная прокрутка другой полки | Её смещение изменяется независимо |
| Начать вертикальное движение, затем добавить диагональное | Жест остаётся назначенным внешнему списку |
| Начать горизонтальное движение, затем добавить диагональное | Жест остаётся назначенным полке |
| Отпустить жест на трекпаде, чтобы началась инерция | Инерционная прокрутка сохраняет выбранное место назначения |
| Использовать обычное вертикальное колесо мыши | Каждое событие колеса направляется внешнему списку |
| Изменить размер окна | Полки подстраиваются под ширину области просмотра внешнего контейнера |
Также проверьте открытие жеста с нулевым приращением, отмену, быстрое изменение направления, а также накладные и всегда видимые полосы прокрутки. Буферизация в ShelfScrollView нужна, чтобы открытие жеста с нулевым приращением не приводило к преждевременному выбору горизонтальной прокрутки. Поведение на поддерживаемых вами устройствах и версиях macOS всё ещё требует проверки во время выполнения.
Политика для диагонального движения намеренно выбирает одно место назначения для жеста с фазами. Она не обещает одновременного перемещения внешнего списка и полки. При достижении конца горизонтальной полки пример также сохраняет её в качестве места назначения, а не передаёт жест вертикальному списку. Если задача требует непрерывного панорамирования по диагонали, используйте один двухосевой документ.
В примере все карточки создаются сразу, а размеры фиксированы. Считайте их демонстрационными значениями. Для изменяемого содержимого реализуйте updateNSView, чтобы обновлять существующие документ и представления-хосты, сохраняя позиции прокрутки; Apple указывает, что этот метод предназначен для применения нового состояния SwiftUI к представленному представлению AppKit. NSViewRepresentable
Исключите отключённую прокрутку и проблемы компоновки текста
Если ни одно направление не работает, проверьте модификаторы, прежде чем менять маршрутизацию. В macOS 13 и новее scrollDisabled(_:) передаёт своё значение через окружение SwiftUI и может влиять как на внешний прокручиваемый вид SwiftUI, так и на вложенные прокручиваемые представления. Это параметр включения прокрутки, а не механизм передачи событий между осями. scrollDisabled(_:)
Если в вашей будущей полке появится текстовое представление AppKit, отдельно проверьте геометрию текста. Свойство NSTextContainer.widthTracksTextView в TextKit определяет, следует ли ширине текстового контейнера за шириной текстового представления. Оно не выбирает, какое прокручиваемое представление получает жест колеса мыши. Изменение компоновки текста может повлиять на то, переполняет ли содержимое область по горизонтали, но его не следует представлять как исправление маршрутизации событий. В примере с карточками этой дополнительной переменной нет. widthTracksTextView
Для одного документа начните с единого двухосевого прокручиваемого представления SwiftUI. Для независимых полок, которым нужна явная вертикальная маршрутизация, мост под управлением приложения задаёт место назначения для каждого жеста и при этом позволяет оставить содержимое карточек в SwiftUI. Прежде чем расширять пример для работы с динамическим содержимым, проверьте политику жестов на моделях Mac и устройствах ввода, которые поддерживает ваше приложение.
Источники
- ScrollView — Apple
- usesPredominantAxisScrolling — Apple
- wantsForwardedScrollEvents(for:) — Apple
- scrollWheel(with:) — NSResponder — Apple
- scrollWheel(with:) — NSScrollView — Apple
- NSViewRepresentable — Apple
- NSHostingView — Apple
- NSScrollView — Apple
- App — Apple
- Attributes — Swift.org
- phase — Apple
- momentumPhase — Apple
- scrollingDeltaX — Apple
- scrollingDeltaY — Apple
- scrollDisabled(_:) — Apple
- widthTracksTextView — Apple