Создайте разветвлённую схему турнирной сетки в приложении для Mac: один раз вычислите центры узлов, разместите карточки с помощью собственного Layout в SwiftUI и нарисуйте соединительные линии позади них с помощью Canvas. Двухосевая прокрутка позволяет читать схему, даже если окно меньше её содержимого.
В этом руководстве создаётся синтетическая схема из семи узлов с нейтральными подписями, такими как Node 1. Ветви отходят влево от корневого узла справа. Задача — отобразить соединённые карточки с равномерными интервалами в изменяемом окне macOS, используя одну и ту же геометрию для размещения и рисования.
Выберите структуру макета, которая сохраняет выравнивание карточек и линий
Для этой реализации задайте целевую версию macOS 13 или новее. Согласно документации Apple, Layout доступен начиная с macOS 13. Для него требуются два метода: один сообщает размер контейнера, другой размещает его подвиды. Поэтому он подходит для размещения карточек в вычисленных позициях. Layout
Оставьте карточки узлов обычными представлениями SwiftUI. Используйте Canvas только в качестве фона для соединительных линий. Apple поясняет, что отдельные элементы, нарисованные на холсте, в том числе представления, переданные как символы холста, не получают собственной интерактивности или поддержки специальных возможностей. Отдельные карточки позволяют в дальнейшем без затруднений добавить обычные элементы управления. Canvas
Реализация состоит из трёх частей: дерева с описанием ветвей, расчёта геометрии, который возвращает центры узлов и рёбра, и представления, объединяющего карточки с отрисовкой соединительных линий. Оба слоя отрисовки получают одно и то же значение геометрии. Поэтому изменение дерева не может обновить карточки, оставив без изменений отдельно поддерживаемый массив соединительных линий.
Размеры заданы намеренно фиксированными: при изменении размера окна карточки остаются прежнего размера, а окружающая их область прокручивается. Согласно документации Apple, ScrollView поддерживает прокрутку по горизонтали, по вертикали или по обеим осям, но не масштабирование. ScrollView
Рассчитайте позиции ветвей, начиная с интервалов между листьями
Начните с листьев — узлов без дочерних элементов. Назначьте каждому листу следующую по порядку вертикальную позицию. Затем разместите каждого родителя посередине между первым и последним дочерними узлами. Горизонтальная позиция зависит от глубины: корневой узел находится в крайнем правом столбце, а каждый следующий уровень глубины смещается на один столбец влево.
В этом примере размер карточек — 132 на 44 пункта, горизонтальный интервал — 64 пункта, а вертикальный — 28 пунктов. Это примерные параметры дизайна. При трёх столбцах и четырёх листьях размер содержимого до добавления внешних отступов составляет 524 на 260 пунктов.
| Величина | Расчёт | Результат |
|---|---|---|
| Шаг между столбцами | Ширина карточки + горизонтальный интервал | 196 пунктов |
| Шаг между листьями | Высота карточки + вертикальный интервал | 72 пункта |
| Ширина содержимого | 3 × 132 + 2 × 64 | 524 пункта |
| Высота содержимого | 4 × 44 + 3 × 28 | 260 пунктов |
Таким образом, вертикальные координаты центров четырёх листьев равны 22, 94, 166 и 238. Их непосредственные родители располагаются на отметках 58 и 202, а корневой узел — на отметке 130. Эти координаты позволяют быстро проверить результат: родитель должен находиться посередине между крайними дочерними узлами, а каждая карточка должна помещаться в пределах указанного прямоугольника содержимого.
Приведённый ниже алгоритм также допускает ветви разной глубины. Лист с небольшой глубиной остаётся в своём столбце, а не перемещается автоматически в крайний левый столбец. Таково намеренное правило этого примера. Прежде чем менять формулу координат, решите, что лучше подходит для вашей схемы: выравнивание по глубине или по листьям.
Создайте полноценный пример для macOS
Создайте цель приложения SwiftUI для macOS в Xcode и задайте целевую версию macOS 13 или новее. Замените автоматически созданный файл точки входа приложения тремя блоками кода ниже, расположив их в указанном порядке в одном файле Swift. Вместе они образуют полноценную программу. Удалите старое объявление приложения, если оно находится в другом файле: по требованиям Apple должна быть ровно одна точка входа приложения. App
Опишите дерево и его геометрию
У каждого узла есть целочисленный идентификатор и массив дочерних узлов. Построитель геометрии обходит дочерние узлы и лишь затем вычисляет координату центра родителя, собирая позиции узлов и рёбра. При повторении идентификатора программа аварийно завершится из-за проверки предусловия, вместо того чтобы сформировать неоднозначную коллекцию представлений.
import SwiftUI
struct BranchNode {
let id: Int
let children: [BranchNode]
init(_ id: Int, _ children: [BranchNode] = []) {
self.id = id
self.children = children
}
var depth: Int {
(children.map { $0.depth + 1 }.max()) ?? 0
}
}
struct DiagramNode: Identifiable {
let id: Int
let center: CGPoint
}
struct DiagramEdge {
let child: CGPoint
let parent: CGPoint
}
struct DiagramGeometry {
static let cardSize = CGSize(width: 132, height: 44)
static let horizontalGap: CGFloat = 64
static let verticalGap: CGFloat = 28
let nodes: [DiagramNode]
let edges: [DiagramEdge]
let size: CGSize
static func make(root: BranchNode) -> DiagramGeometry {
let maximumDepth = root.depth
var nextLeaf = 0
var nodes: [DiagramNode] = []
var edges: [DiagramEdge] = []
var identifiers = Set<Int>()
func visit(_ node: BranchNode, depth: Int) -> CGPoint {
precondition(
identifiers.insert(node.id).inserted,
"Every node must have a unique identifier."
)
let x = cardSize.width / 2
+ CGFloat(maximumDepth - depth)
* (cardSize.width + horizontalGap)
let childCenters = node.children.map {
visit($0, depth: depth + 1)
}
let y: CGFloat
if let first = childCenters.first,
let last = childCenters.last {
y = (first.y + last.y) / 2
} else {
y = cardSize.height / 2
+ CGFloat(nextLeaf)
* (cardSize.height + verticalGap)
nextLeaf += 1
}
let center = CGPoint(x: x, y: y)
nodes.append(DiagramNode(id: node.id, center: center))
for child in childCenters {
edges.append(DiagramEdge(child: child, parent: center))
}
return center
}
_ = visit(root, depth: 0)
let size = CGSize(
width: CGFloat(maximumDepth + 1) * cardSize.width
+ CGFloat(maximumDepth) * horizontalGap,
height: CGFloat(nextLeaf) * cardSize.height
+ CGFloat(max(0, nextLeaf - 1)) * verticalGap
)
return DiagramGeometry(nodes: nodes, edges: edges, size: size)
}
}
В модели всегда есть корневой узел, поэтому даже в дереве без дочерних узлов будет одна карточка с ненулевым размером содержимого. Отдельного случая для пустой схемы в этой программе нет. Если ваше приложение допускает отсутствие корневого узла, обработайте это условие явно до построения геометрии.
Разместите карточки с помощью пользовательского значения макета
Передайте центр каждой карточки через LayoutValueKey. В документированном Apple шаблоне значение задаётся с помощью layoutValue(key:value:), а затем считывается из прокси подвида по этому ключу. Так позиция связывается с карточкой, и не нужно полагаться на параллельные индексы массивов. LayoutValueKey
struct DiagramCenterKey: LayoutValueKey {
static let defaultValue: CGPoint = .zero
}
struct BranchLayout: Layout {
let contentSize: CGSize
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
contentSize
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let cardProposal = ProposedViewSize(
width: DiagramGeometry.cardSize.width,
height: DiagramGeometry.cardSize.height
)
for subview in subviews {
let center = subview[DiagramCenterKey.self]
subview.place(
at: CGPoint(
x: bounds.minX + center.x,
y: bounds.minY + center.y
),
anchor: .center,
proposal: cardProposal
)
}
}
}
Метод макета возвращает один и тот же конечный размер при любом предложении размера, поскольку его содержимое негибкое. Apple прямо приводит пример негибкого макета, который может игнорировать предложение. Во время одного прохода макета SwiftUI может несколько раз вызывать метод определения размера с разными предложениями, поэтому этот метод просто возвращает геометрию и не обновляет модель. sizeThatFits(proposal:subviews:cache:)
При размещении к каждой локальной координате центра добавляются bounds.minX и bounds.minY. Переданные границы заданы в системе координат родителя, поэтому предположение о нулевой точке начала координат привело бы к ошибке, если бы контейнер переместился. Apple также требует, чтобы расчёты размера и размещения согласовывались по интервалам и размерам. placeSubviews(in:proposal:subviews:cache:)
Якорь .center означает, что заданная точка соответствует центру карточки. Каждый прокси размещается ровно один раз внутри метода размещения, как того требует API размещения подвидов. Явно заданные размеры карточек в следующем блоке гарантируют, что размеры отображаемых карточек совпадают с размерами в геометрии. place(at:anchor:proposal:)
Нарисуйте соединительные линии и настройте окно
Каждая соединительная линия начинается у правого края карточки дочернего узла и заканчивается у левого края карточки родителя. Две промежуточные точки задают прямоугольный маршрут через середину промежутка. Path.addLines(_:) создаёт соединённые отрезки между точками из массива. addLines(_:)
struct BranchDiagram: View {
let geometry: DiagramGeometry
var body: some View {
BranchLayout(contentSize: geometry.size) {
ForEach(geometry.nodes) { node in
Text("Node \(node.id)")
.font(.body)
.frame(
width: DiagramGeometry.cardSize.width,
height: DiagramGeometry.cardSize.height
)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.12))
)
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary, lineWidth: 1)
}
.layoutValue(
key: DiagramCenterKey.self,
value: node.center
)
}
}
.background {
Canvas { context, _ in
var path = Path()
for edge in geometry.edges {
let start = CGPoint(
x: edge.child.x
+ DiagramGeometry.cardSize.width / 2,
y: edge.child.y
)
let end = CGPoint(
x: edge.parent.x
- DiagramGeometry.cardSize.width / 2,
y: edge.parent.y
)
let middleX = (start.x + end.x) / 2
path.addLines([
start,
CGPoint(x: middleX, y: start.y),
CGPoint(x: middleX, y: end.y),
end
])
}
context.stroke(
path,
with: .color(.secondary),
lineWidth: 1.5
)
}
}
}
}
@main
struct BranchDiagramApp: App {
let geometry = DiagramGeometry.make(
root: BranchNode(1, [
BranchNode(2, [BranchNode(4), BranchNode(5)]),
BranchNode(3, [BranchNode(6), BranchNode(7)])
])
)
var body: some Scene {
WindowGroup {
ScrollView([.horizontal, .vertical]) {
BranchDiagram(geometry: geometry)
.padding(24)
}
.frame(minWidth: 360, minHeight: 240)
}
}
}
ForEach получает узлы с идентификаторами, как того требует Apple: элементы должны быть идентифицируемыми либо им нужно передать явный аргумент идентификатора. Если позже вы сделаете дерево редактируемым, сохраняйте идентификаторы в соответствующих логических узлах. ForEach
Холст добавляется непосредственно в качестве фона макета, поэтому его локальные координаты совпадают с геометрией карточек. Внешний отступ применяется после сборки схемы и одинаково смещает оба слоя. Общие участки соединительных линий соседних узлов перекрываются, образуя соединение в форме скобки.
WindowGroup задаёт иерархию представлений окна и стандартное поведение управления окнами в macOS. В структуре приложения примера хранится неизменяемая синтетическая геометрия; таймеры и обработчики жизненного цикла для перемещения представлений не используются. WindowGroup
Проверьте схему и устраните распространённые проблемы
Арифметику для этого примера проверили с помощью небольшого детерминированного расчёта на Python: получилось семь центров, шесть рёбер, прямоугольник содержимого размером 524 на 260 пунктов, а все карточки поместились внутри него. Код SwiftUI был проверен визуально, но его не удалось скомпилировать в исследовательской среде, поскольку там не было компилятора Swift.
После сборки приложения в Xcode проверьте следующие результаты:
- В крайнем левом столбце находятся четыре карточки, в среднем — две, в правом — одна.
- Каждая соединительная линия касается середины горизонтальной грани соответствующей карточки.
- Каждый родитель расположен по вертикали между крайними дочерними узлами.
- Изменение размера окна не меняет размеры карточек и при необходимости включает прокрутку.
- Замена корневого узла примера на
BranchNode(1)отображает одну карточку без соединительных линий.
Если карточки и линии смещены, сначала проверьте расположение модификаторов. Отступы или смещения, применённые только к карточкам, создают дополнительную поправку координат, которая не учитывается в геометрии соединительных линий. Как показано в примере, задавайте общие для всей схемы интервалы за пределами BranchDiagram.
Если карточка неожиданно появляется в начале координат макета, проверьте, что итоговому представлению присвоено значение DiagramCenterKey. По умолчанию ключ имеет значение .zero, поэтому при отсутствии присвоенного значения центр карточки окажется в начале координат. Если программа останавливается на проверке идентификаторов, перед отрисовкой назначьте каждому узлу уникальный идентификатор.
Фиксированные размеры карточек подходят для коротких синтетических подписей из этого примера. Для более длинного текста или крупных шрифтов добавьте этап измерения: запросите размеры у прокси подвидов, вычислите ширину столбцов и расстояния между поддеревьями на основе полученных размеров, а затем используйте те же результаты для координат концов соединительных линий. Одно лишь предложение размера не заставляет подвид принять такой размер. place(at:anchor:proposal:)
Этот алгоритм описывает дерево: у каждого отображаемого дочернего узла только один родитель. Он не прокладывает маршруты для произвольных перекрёстных связей и не позволяет одному общему узлу отображаться у нескольких родителей. Для таких требований нужна другая модель геометрии и явная политика построения маршрутов. Анимация также потребовала бы синхронного перемещения геометрии соединительных линий и карточек; в этом примере позиции статические.
Поручите одному расчёту геометрии определять размер содержимого, центры карточек и координаты концов рёбер. Так у вас будет конкретное место для настройки интервалов, проверки координат и расширения схемы с сохранением согласованности между макетом SwiftUI и рисованием.
Источники
- Layout — Apple
- Canvas — Apple
- ScrollView — Apple
- App — Apple
- LayoutValueKey — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple
- place(at:anchor:proposal:) — Apple
- addLines(_:) — Apple
- ForEach — Apple
- WindowGroup — Apple