Para mantener las superposiciones alineadas con un diagrama de SwiftUI ajustado a escala, mide el diagrama ajustado y coloca los marcadores en ese espacio de coordenadas local. Añade la superposición de medición antes de cualquier marco exterior que agregue espacio vacío.

Este tutorial crea una pequeña app para macOS con un diagrama sintético y dos marcadores numerados. El objetivo es mantener cada marcador centrado en el elemento correspondiente del diagrama mientras cambia la forma de la ventana. La implementación usa coordenadas normalizadas, de modo que las posiciones de las anotaciones no dependen de las dimensiones de la ventana.

¿Por qué se desplazan las superposiciones con respecto a un diagrama ajustado a escala?

El ajuste a escala conserva las proporciones de un diagrama mientras lo adapta al espacio disponible. Según las proporciones del contenedor, puede quedar espacio vacío encima y debajo del diagrama, o a sus lados. Apple ilustra este comportamiento en Ajustar imágenes al espacio disponible.

El desplazamiento ocurre cuando la ilustración y sus marcadores usan rectángulos distintos. Por ejemplo, la ilustración podría ocupar un rectángulo centrado de 600 por 375 puntos dentro de un contenedor de 600 por 700 puntos. Un marcador cuya posición se calcula como una fracción de la altura del contenedor incluye el espacio vacío circundante en el cálculo.

El orden de los modificadores determina qué rectángulo se mide. Apple documenta que los modificadores de vista envuelven la vista a la que se aplican y que un marco posiciona su vista secundaria dentro de otro rectángulo. Por tanto, una superposición añadida después de un marco que amplía la vista mide un límite de diseño distinto al de otra añadida directamente a la ilustración ajustada. Consulta la documentación de View de Apple y la documentación sobre marcos flexibles.

Para esta tarea, usa el siguiente orden:

  1. Dibuja una ilustración flexible.
  2. Aplica su relación de aspecto explícita con .fit.
  3. Añade una superposición que contenga GeometryReader.
  4. Posiciona los marcadores con el tamaño de ese lector.
  5. Añade el marco exterior y el relleno circundante.

Apple recomienda usar una superposición cuando la vista modificada debe dominar el diseño. Así, el diagrama puede ser el elemento que determina el diseño, con las anotaciones superpuestas. Una disposición de vistas hermanas en un ZStack tiene un comportamiento de diseño distinto y exige prestar más atención a qué vista determina el tamaño. Consulta overlay(alignment:content:).

Crea un ejemplo completo para macOS

Crea un proyecto de app para macOS en Xcode con la interfaz SwiftUI. Establece macOS 12 o posterior como destino de implementación, porque la API de superposición basada en cierres que se usa aquí está disponible desde macOS 12. Sustituye el archivo de punto de entrada de la app generado por el siguiente programa completo y conserva una sola declaración de app @main en el destino. La disponibilidad de la API está documentada en overlay(alignment:content:).

La ilustración sintética representa un espacio de diseño de 800 por 500, con una relación de aspecto de 1.6. Las coordenadas de los marcadores son fracciones de ese espacio: cero horizontal es el borde izquierdo, cero vertical es el borde superior y uno representa el borde opuesto. No se necesitan archivos de recursos ni paquetes externos.

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

Ejecuta la app y cambia el tamaño de la ventana tanto horizontal como verticalmente. El marcador 1 debe permanecer en el centro del primer rectángulo, y el marcador 2, en el centro del segundo. El borde fino indica el límite del diagrama ajustado; el área gris circundante pertenece al diseño exterior.

Por qué funciona este orden de modificadores

El rectángulo base proporciona una ilustración flexible y .aspectRatio(800.0 / 500.0, contentMode: .fit) limita sus proporciones. Apple define el argumento de relación de aspecto como el ancho dividido por la altura y especifica que el resultado ajustado se aplica al contenido redimensionable. Consulta aspectRatio(_:contentMode:).

La superposición de medición va inmediatamente después de esa restricción. GeometryReader define su contenido a partir de su propio tamaño y espacio de coordenadas, mientras que geometry.size informa del tamaño del contenedor. En este caso, ese contenedor abarca el rectángulo ajustado. Por tanto, multiplicar las coordenadas normalizadas por su ancho y alto produce posiciones locales para los marcadores. Consulta GeometryReader y GeometryProxy.size.

El marco expansible final centra la composición completa del diagrama y sus marcadores en el área disponible. Como los marcadores ya están añadidos al rectángulo interior, no necesitan calcular los márgenes exteriores.

Conserva los contratos de dibujo y posicionamiento

DiagramArtwork cumple el protocolo Shape, y su método path(in:) devuelve un Path expresado dentro del rectángulo proporcionado. No devuelve una vista ni realiza cambios en el diseño. Esto sigue el contrato documentado de path(in:) de Apple.

El método .position(x:y:) de cada marcador coloca su centro en el punto calculado. No coloca allí la esquina superior izquierda. El ejemplo ancla deliberadamente el centro de cada círculo numerado a un elemento, tal como establece la definición de position(x:y:) de Apple.

Los círculos mantienen un diámetro de 28 puntos al redimensionar la ventana, mientras sus centros siguen la ilustración. Esta es una decisión deliberada para que las anotaciones sean legibles. Si las dimensiones de los marcadores representan distancias dentro del dibujo, calcula también esas dimensiones a partir del tamaño ajustado.

Comprueba la alineación con un cálculo de coordenadas

Una comprobación numérica ayuda a distinguir un error de coordenadas de un problema de estilo. Para un ajuste a escala centrado, sean sourceWidth y sourceHeight las dimensiones positivas de origen, y containerWidth y containerHeight las dimensiones finitas disponibles. Lo siguiente es pseudocódigo matemático, no un programa Swift independiente:

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

Estas ecuaciones derivan un ajuste uniforme y una colocación centrada del comportamiento de ajuste documentado. Al usar la ilustración de ejemplo de 800 por 500 y colocar el marcador 1 en (0.25, 0.40), se obtienen estos resultados. Las dimensiones del contenedor indicadas aquí se refieren al área disponible después del relleno circundante.

Contenedor disponible Diagrama ajustado Origen del diagrama en el contenedor Centro del marcador en el contenedor
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)

En la fila del medio, multiplicar la altura completa del contenedor por 0.40 da 280, lo que no coincide con la posición vertical correcta de 312.5. En la superposición local del ejemplo, la coordenada vertical es simplemente 0.40 × 375 = 150; el diseño contenedor aporta el margen de 162.5 puntos.

La aritmética de la tabla se comprobó con un pequeño cálculo determinista en Python. Se inspeccionó el programa de SwiftUI, pero no se compiló en este entorno; verifica su representación en Xcode en el Mac de destino.

Adapta el patrón a un diagrama existente

Para un diagrama basado en una imagen, haz que Image sea redimensionable antes de aplicar los modificadores de ajuste a escala y superposición. La guía de Apple sobre el ajuste de imágenes explica que, de lo contrario, una imagen se muestra en su tamaño original y que .resizable() por sí solo puede escalar sus ejes de forma independiente. Conserva la relación de aspecto real de la imagen y luego añade la superposición de marcadores antes del marco exterior. Consulta Ajustar imágenes al espacio disponible.

Si las anotaciones parten de coordenadas de origen, normalízalas con respecto a los mismos límites de la ilustración completa: divide la coordenada horizontal de origen por el ancho de origen y la coordenada vertical de origen por el alto de origen. Los márgenes transparentes de una imagen siguen formando parte de esos límites. Si las coordenadas se midieron con respecto a una región recortada, conviértelas al sistema de coordenadas de la imagen completa antes de usar este patrón.

Mantén los modificadores específicos de imagen en Image antes de que los modificadores generales de vista la envuelvan. De igual modo, conserva var body: some View para la vista compuesta y el tipo de retorno Path para el método de dibujo de la forma. Apple documenta la composición de vistas personalizadas y el envoltorio de modificadores en View.

El cálculo supone un ajuste uniforme sin recorte, rotación ni transformación independiente de desplazamiento y zoom. Si añades esas operaciones, aplica una transformación compartida a la ilustración y las anotaciones, o amplía explícitamente la correspondencia de coordenadas. Cambiar a .fill modifica las condiciones de ajuste y puede representar contenido fuera del marco; Apple documenta el recorte como una operación aparte en Ajustar imágenes al espacio disponible.

Diagnostica los problemas de alineación que persistan

Comprueba estas condiciones en orden:

  1. Límite de medición: Mantén la superposición de marcadores antes del marco exterior expansible. Su borde debe seguir la ilustración, no el área vacía circundante.
  2. Límites de coordenadas: Confirma que las coordenadas normalizadas usan el rectángulo de origen completo y el origen previsto en la esquina superior izquierda. En este ejemplo, los valores previstos dentro de los límites van de cero a uno; rechaza los valores inesperados en lugar de limitarlos silenciosamente.
  3. Elección del punto de anclaje: Recuerda que .position(x:y:) ancla el centro del marcador. Si una esquina de la etiqueta debe tocar un elemento, hace falta un desplazamiento adicional explícito u otra estrategia de anclaje.
  4. Diseños pequeños: Un marcador puede seguir correctamente centrado y, aun así, superponerse a otro marcador. Elige una regla independiente para ocultar o reorganizar las etiquetas en tamaños pequeños.
  5. Comportamiento en los bordes: Un marcador centrado en el borde de una ilustración se extiende más allá de ese borde. Decide si ese saliente es aceptable antes de añadir un recorte.

Mantén las posiciones vinculadas a la geometría actual en lugar de guardar el primer tamaño medido en .onAppear. Apple define esa llamada en torno a la aparición, cuyo momento depende del tipo de vista; no es una llamada que se ejecute al redimensionar la ventana. Leer la geometría directamente mantiene el cálculo de posiciones de este ejemplo vinculado al diseño actual. Consulta onAppear(perform:).

Para un diagrama ajustado a escala en una ventana redimensionable de Mac, el punto de partida fiable es un único rectángulo compartido: deja que la ilustración establezca sus límites ajustados, mide esos límites y calcula a partir de ellos el centro de cada anotación. Añade el diseño circundante solo después de establecer esa relación.

Fuentes