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

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

Почему наложения смещаются относительно вписанной с сохранением пропорций диаграммы?

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

Смещение возникает, когда для изображения и его маркеров используются разные прямоугольники. Например, изображение может занимать прямоугольник размером 600 на 375 пунктов по центру контейнера размером 600 на 700 пунктов. Если задать положение маркера как долю высоты контейнера, окружающее пустое пространство тоже будет учитываться в расчёте.

Порядок модификаторов определяет, какой прямоугольник вы измеряете. Apple указывает, что модификаторы оборачивают полученное представление, а фрейм размещает дочернее представление внутри другого прямоугольника. Поэтому наложение, добавленное после расширяющего фрейма, измеряет границу макета, отличную от границы, которую измеряет наложение, прикреплённое непосредственно к вписанному изображению. См. документацию Apple: View и гибкие фреймы.

Для этой задачи используйте следующий порядок:

  1. Нарисуйте гибкое изображение.
  2. Задайте ему явное соотношение сторон с режимом .fit.
  3. Добавьте наложение с GeometryReader.
  4. Разместите маркеры, используя размер, полученный этим считывателем.
  5. Добавьте внешний фрейм и окружающие отступы.

Apple рекомендует использовать наложение, когда модифицированное представление должно определять макет. Поэтому диаграмма подходит на роль главного элемента макета, поверх которого располагаются аннотации. Соседнее расположение элементов в ZStack ведёт себя иначе и требует внимательнее следить за тем, какое представление определяет размер. См. overlay(alignment:content:).

Полный пример для macOS

Создайте в Xcode проект приложения macOS с интерфейсом SwiftUI. Установите целевую версию macOS 12 или новее, поскольку используемый здесь вариант API наложения с замыканием доступен начиная с macOS 12. Замените сгенерированный файл точки входа приложения приведённой ниже полной программой и оставьте в цели только одно объявление приложения @main. Доступность API документирована в разделе overlay(alignment:content:).

Синтетическое изображение представляет дизайн-пространство размером 800 на 500, то есть с соотношением сторон 1,6. Координаты маркеров — доли от этого пространства: ноль по горизонтали соответствует левому краю, ноль по вертикали — верхнему краю, а единица — противоположному краю. Файлы ресурсов и внешние пакеты не нужны.

import SwiftUI

@main
struct DiagramAlignmentApp: App {
    var body: some Scene {
        WindowGroup {
            DiagramExample()
                .frame(minWidth: 360, minHeight: 260)
        }
    }
}

struct DiagramMarker: Identifiable {
    let id: Int
    let label: String
    let x: CGFloat
    let y: CGFloat
}

struct DiagramExample: View {
    let markers = [
        DiagramMarker(id: 1, label: "1", x: 0.25, y: 0.40),
        DiagramMarker(id: 2, label: "2", x: 0.75, y: 0.60)
    ]

    var body: some View {
        Rectangle()
            .fill(Color.white)
            .overlay {
                DiagramArtwork()
                    .stroke(Color.gray, lineWidth: 2)
            }
            .aspectRatio(800.0 / 500.0, contentMode: .fit)
            .overlay {
                GeometryReader { geometry in
                    ForEach(markers) { marker in
                        Circle()
                            .fill(Color.blue)
                            .frame(width: 28, height: 28)
                            .overlay {
                                Text(marker.label)
                                    .font(.caption.bold())
                                    .foregroundColor(.white)
                            }
                            .position(
                                x: marker.x * geometry.size.width,
                                y: marker.y * geometry.size.height
                            )
                    }
                }
            }
            .border(Color.gray)
            .frame(
                maxWidth: .infinity,
                maxHeight: .infinity,
                alignment: .center
            )
            .padding(24)
            .background(Color.gray.opacity(0.12))
    }
}

struct DiagramArtwork: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        path.addRect(CGRect(
            x: rect.minX + 0.10 * rect.width,
            y: rect.minY + 0.20 * rect.height,
            width: 0.30 * rect.width,
            height: 0.40 * rect.height
        ))

        path.addRect(CGRect(
            x: rect.minX + 0.60 * rect.width,
            y: rect.minY + 0.40 * rect.height,
            width: 0.30 * rect.width,
            height: 0.40 * rect.height
        ))

        path.move(to: CGPoint(
            x: rect.minX + 0.25 * rect.width,
            y: rect.minY + 0.40 * rect.height
        ))
        path.addLine(to: CGPoint(
            x: rect.minX + 0.75 * rect.width,
            y: rect.minY + 0.60 * rect.height
        ))

        return path
    }
}

Запустите приложение и изменяйте размер окна по горизонтали и вертикали. Маркер 1 должен оставаться в центре первого прямоугольника, а маркер 2 — в центре второго. Тонкая рамка обозначает границу вписанной диаграммы; окружающая серая область относится к внешнему макету.

Почему этот порядок модификаторов работает

Базовый прямоугольник задаёт гибкое изображение, а .aspectRatio(800.0 / 500.0, contentMode: .fit) ограничивает его пропорции. Apple определяет аргумент соотношения сторон как ширину, делённую на высоту, и указывает, что результат вписывания применяется к изменяемому размеру содержимому. См. aspectRatio(_:contentMode:).

Измеряющее наложение следует сразу за этим ограничением. GeometryReader задаёт содержимое на основе собственного размера и системы координат, а geometry.size сообщает размер контейнера. Здесь этот контейнер охватывает вписанный прямоугольник. Поэтому умножение нормализованных координат на его ширину и высоту даёт локальные позиции маркеров. См. GeometryReader и GeometryProxy.size.

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

Сохраняйте правила рисования и позиционирования

DiagramArtwork соответствует протоколу Shape, а его метод path(in:) возвращает Path, заданный в переданном прямоугольнике. Он не возвращает представление и не изменяет макет. Это соответствует документированному Apple контракту path(in:).

Метод .position(x:y:) каждого маркера помещает его центр в вычисленную точку. В этой точке не размещается верхний левый угол маркера. В примере центр каждого пронумерованного круга намеренно привязан к элементу диаграммы, что соответствует определению position(x:y:) от Apple.

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

Проверьте выравнивание на примере расчёта координат

Числовая проверка помогает отличить ошибку координат от проблемы оформления. Для центрированного вписывания с сохранением пропорций пусть положительные размеры исходного изображения равны sourceWidth и sourceHeight, а конечные доступные размеры — containerWidth и containerHeight. Ниже приведён математический псевдокод, а не отдельная программа на Swift:

scale = min(containerWidth / sourceWidth,
            containerHeight / sourceHeight)

fittedWidth = sourceWidth * scale
fittedHeight = sourceHeight * scale

originX = (containerWidth - fittedWidth) / 2
originY = (containerHeight - fittedHeight) / 2

containerX = originX + normalizedX * fittedWidth
containerY = originY + normalizedY * fittedHeight

Эти уравнения вычисляют равномерное вписывание и центрирование на основе документированного поведения при вписывании. Для показанного исходного изображения размером 800 на 500 и маркера 1 в точке (0.25, 0.40) получаются следующие результаты. Размеры контейнера здесь указаны для области, доступной после окружающих отступов.

Доступный контейнер Вписанная диаграмма Начало диаграммы в контейнере Центр маркера в контейнере
1000 × 700 1000 × 625 (0, 37.5) (250, 287.5)
600 × 700 600 × 375 (0, 162.5) (150, 312.5)
1000 × 400 640 × 400 (180, 0) (340, 160)

В средней строке умножение полной высоты контейнера на 0.40 даёт 280, что не совпадает с верной вертикальной позицией 312,5. В локальном наложении примера вертикальная координата просто равна 0.40 × 375 = 150; внешний макет добавляет отступ в 162,5 пункта.

Арифметика в таблице проверена с помощью небольшого детерминированного расчёта на Python. Программа SwiftUI была проверена, но в этой среде не компилировалась; проверьте её отображение в Xcode на целевом Mac.

Примените этот подход к существующей диаграмме

Для диаграммы на основе изображения задайте Image изменяемый размер до применения модификаторов вписывания с сохранением пропорций и наложения. В руководстве Apple по вписыванию изображений объясняется, что иначе изображение отображается в исходном размере, а одного .resizable() может быть достаточно для независимого масштабирования по осям. Сохраните фактическое соотношение сторон изображения и добавьте наложение с маркерами до внешнего фрейма. См. Вписывание изображений в доступное пространство.

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

Оставляйте специфичные для изображения модификаторы на Image, прежде чем общие модификаторы представлений обернут его. Аналогично, для составного представления сохраняйте var body: some View, а для метода рисования фигуры — возвращаемый тип Path. Apple описывает создание пользовательских представлений и оборачивание модификаторами в View.

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

Диагностика оставшихся проблем с выравниванием

Проверяйте следующие условия по порядку:

  1. Граница измерения: оставьте наложение с маркерами перед внешним расширяющим фреймом. Его рамка должна повторять границу изображения, а не окружающей пустой области.
  2. Границы координат: убедитесь, что нормализованные координаты отсчитываются от всего исходного прямоугольника с началом в левом верхнем углу. В этом примере допустимые значения должны находиться в диапазоне от нуля до единицы; неожиданные значения следует отклонять, а не незаметно ограничивать этим диапазоном.
  3. Выбор точки привязки: помните, что .position(x:y:) задаёт привязку к центру маркера. Если угол подписи должен касаться элемента, потребуется явное дополнительное смещение или другой способ привязки.
  4. Небольшие размеры: маркер может оставаться точно по центру и при этом перекрываться с другим маркером. Для маленьких размеров выберите отдельное правило скрытия или перестановки подписей.
  5. Поведение у края: маркер, центрированный на краю изображения, выступает за его границу. Прежде чем добавлять обрезку, решите, допустимо ли такое выступание.

Вычисляйте позиции по текущей геометрии, а не сохраняйте первый измеренный размер в .onAppear. Apple описывает этот обработчик как связанный с появлением представления; момент его вызова зависит от типа представления. Это не обработчик изменения размера окна. Прямое чтение геометрии связывает вычисление позиций в этом примере с текущим макетом. См. onAppear(perform:).

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

Источники