Aby nakładki pozostawały wyrównane z diagramem SwiftUI dopasowanym do proporcji, zmierz sam dopasowany diagram i umieść znaczniki w jego lokalnym układzie współrzędnych. Dołącz nakładkę pomiarową przed każdą zewnętrzną ramką, która dodaje pustą przestrzeń.

W tym samouczku powstaje niewielka aplikacja macOS z syntetycznym diagramem i dwoma ponumerowanymi znacznikami. Zadanie polega na tym, by każdy znacznik pozostawał wyśrodkowany na odpowiednim elemencie diagramu, gdy zmienia się kształt okna. Implementacja używa współrzędnych znormalizowanych, dzięki czemu pozycje adnotacji pozostają niezależne od wymiarów okna.

Dlaczego nakładki przesuwają się względem diagramu dopasowanego do proporcji?

Dopasowanie proporcji zachowuje proporcje diagramu, mieszcząc go w dostępnej przestrzeni. W zależności od proporcji kontenera może to pozostawić pustą przestrzeń nad diagramem i pod nim albo po jego bokach. Apple pokazuje to zachowanie w artykule Dopasowywanie obrazów do dostępnej przestrzeni.

Przesunięcie występuje, gdy grafika i jej znaczniki używają różnych prostokątów. Na przykład grafika może zajmować wyśrodkowany prostokąt o wymiarach 600 na 375 punktów w kontenerze o wymiarach 600 na 700 punktów. Pozycja znacznika wyznaczona jako ułamek wysokości kontenera uwzględnia w obliczeniach otaczającą pustą przestrzeń.

Kolejność modyfikatorów decyduje o tym, który prostokąt mierzysz. Apple dokumentuje, że modyfikatory widoków opakowują widok, do którego są zastosowane, a ramka umieszcza widok potomny w innym prostokącie. Nakładka dołączona po rozszerzającej ramce mierzy zatem inną granicę układu niż nakładka dołączona bezpośrednio do dopasowanej grafiki. Zobacz dokumentację Apple: View oraz elastyczna ramka.

W tym zadaniu zastosuj następującą kolejność:

  1. Narysuj elastyczną grafikę.
  2. Ustaw jej jawne proporcje za pomocą .fit.
  3. Dołącz nakładkę zawierającą GeometryReader.
  4. Rozmieść znaczniki, używając rozmiaru odczytanego przez ten reader.
  5. Dodaj zewnętrzną ramkę i otaczające ją wypełnienie.

Apple zaleca użycie nakładki, gdy zmodyfikowany widok ma dominować w układzie. Dzięki temu diagram może wyznaczać układ, a adnotacje mogą być na nim warstwowo wyświetlane. Układ równorzędnych widoków w ZStack zachowuje się inaczej i wymaga większej uwagi przy ustalaniu, który widok określa rozmiar. Zobacz overlay(alignment:content:).

Zbuduj kompletny przykład dla macOS

Utwórz w Xcode projekt aplikacji macOS z interfejsem SwiftUI. Ustaw docelową wersję systemu na macOS 12 lub nowszą, ponieważ używane tu zamknięcie API nakładki jest dostępne od macOS 12. Zastąp wygenerowany plik punktu wejścia aplikacji poniższym kompletnym programem, pozostawiając w celu tylko jedną deklarację aplikacji @main. Dostępność tego API opisano w dokumentacji overlay(alignment:content:).

Syntetyczna grafika przedstawia przestrzeń projektową o wymiarach 800 na 500, co daje proporcje 1,6. Współrzędne znaczników są ułamkami tej przestrzeni: zero w poziomie oznacza lewą krawędź, zero w pionie oznacza górną krawędź, a jeden odpowiada przeciwległej krawędzi. Nie są potrzebne pliki zasobów ani zewnętrzne pakiety.

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

Uruchom aplikację i zmieniaj szerokość oraz wysokość okna. Znacznik 1 powinien pozostać na środku pierwszego prostokąta, a znacznik 2 — na środku drugiego. Cienka ramka wyznacza granicę dopasowanego diagramu; szary otaczający obszar należy do układu zewnętrznego.

Dlaczego ta kolejność modyfikatorów działa

Podstawowy prostokąt dostarcza elastyczną grafikę, a .aspectRatio(800.0 / 500.0, contentMode: .fit) ogranicza jej proporcje. Apple definiuje argument proporcji jako szerokość podzieloną przez wysokość i określa, że wynik dopasowania dotyczy zawartości, której rozmiar można zmieniać. Zobacz aspectRatio(_:contentMode:).

Nakładka pomiarowa znajduje się bezpośrednio za tym ograniczeniem. GeometryReader określa zawartość na podstawie własnego rozmiaru i układu współrzędnych, a geometry.size udostępnia rozmiar kontenera. W tym przykładzie kontener obejmuje dopasowany prostokąt. Pomnożenie współrzędnych znormalizowanych przez jego szerokość i wysokość daje więc lokalne pozycje znaczników. Zobacz GeometryReader oraz GeometryProxy.size.

Końcowa rozszerzająca ramka wyśrodkowuje kompozycję diagramu i znaczników w dostępnej przestrzeni. Ponieważ znaczniki są już dołączone do wewnętrznego prostokąta, nie muszą obliczać zewnętrznych marginesów.

Zachowaj kontrakty rysowania i pozycjonowania

DiagramArtwork implementuje protokół Shape, a jego metoda path(in:) zwraca Path określony w przekazanym prostokącie. Nie zwraca widoku ani nie wykonuje zmian w układzie. Jest to zgodne z udokumentowanym przez Apple kontraktem path(in:).

Metoda .position(x:y:) każdego znacznika umieszcza jego środek w obliczonym punkcie. Nie umieszcza tam jego lewego górnego rogu. Przykład celowo zakotwicza środek każdego ponumerowanego koła na elemencie diagramu, zgodnie z definicją position(x:y:) Apple.

Koła zachowują średnicę 28 punktów podczas zmiany rozmiaru okna, a ich środki podążają za grafiką. To celowy wybór ułatwiający odczytywanie adnotacji. Jeśli wymiary znaczników reprezentują odległości na rysunku, obliczaj je również na podstawie dopasowanego rozmiaru.

Sprawdź wyrównanie na przykładzie obliczeń współrzędnych

Obliczenie liczbowe ułatwia odróżnienie błędu współrzędnych od problemu ze stylem. Przy wyśrodkowanym dopasowaniu proporcji niech dodatnie wymiary źródłowe będą równe sourceWidth i sourceHeight, a skończone wymiary dostępnej przestrzeni — containerWidth i containerHeight. Poniższy zapis to pseudokod matematyczny, a nie osobny program w 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

Równania te wyznaczają jednolite dopasowanie i wyśrodkowane położenie na podstawie udokumentowanego zachowania mechanizmu dopasowania. Dla ilustracyjnego źródła o wymiarach 800 na 500 i znacznika 1 w punkcie (0.25, 0.40) otrzymujemy poniższe wyniki. Wymiary kontenera oznaczają tu obszar dostępny po uwzględnieniu otaczającego wypełnienia.

Dostępny kontener Dopasowany diagram Początek diagramu w kontenerze Środek znacznika w kontenerze
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)

W środkowym wierszu pomnożenie pełnej wysokości kontenera przez 0.40 daje 280, co nie odpowiada prawidłowej pozycji pionowej 312,5. W lokalnej nakładce z przykładu współrzędna pionowa wynosi po prostu 0.40 × 375 = 150; układ zewnętrzny zapewnia margines 162,5 punktu.

Obliczenia w tabeli sprawdzono za pomocą niewielkiego, deterministycznego skryptu w Pythonie. Program SwiftUI został przejrzany, ale nie skompilowano go w tym środowisku; sprawdź jego renderowanie w Xcode na docelowym Macu.

Zastosuj ten wzorzec do istniejącego diagramu

W przypadku diagramu opartego na obrazie ustaw Image jako skalowalny przed zastosowaniem modyfikatorów dopasowania proporcji i nakładki. Przewodnik Apple dotyczący dopasowywania obrazów wyjaśnia, że w przeciwnym razie obraz jest wyświetlany w pierwotnym rozmiarze, a samo .resizable() może niezależnie skalować jego osie. Zachowaj rzeczywiste proporcje obrazu, a następnie dołącz nakładkę znaczników przed zewnętrzną ramką. Zobacz Dopasowywanie obrazów do dostępnej przestrzeni.

Gdy adnotacje początkowo są określone współrzędnymi źródłowymi, normalizuj je względem tych samych pełnych granic grafiki: podziel źródłową współrzędną poziomą przez szerokość źródła, a pionową przez jego wysokość. Przezroczyste marginesy obrazu nadal należą do tych granic. Jeśli współrzędne zmierzono względem przyciętego obszaru, przed zastosowaniem tego wzorca przelicz je na układ współrzędnych pełnego obrazu.

Modyfikatory specyficzne dla obrazu umieszczaj na Image, zanim ogólne modyfikatory widoku go opakują. Podobnie zachowaj var body: some View dla złożonego widoku oraz typ zwracany Path dla metody rysującej kształt. Apple opisuje tworzenie własnych kompozycji widoków i opakowywanie modyfikatorów w View.

Obliczenie zakłada jednolite dopasowanie bez dodatkowego przycinania, obrotu ani niezależnej transformacji przesuwania i powiększania. Jeśli wprowadzisz takie operacje, zastosuj wspólną transformację do grafiki i adnotacji albo jawnie rozszerz mapowanie współrzędnych. Zmiana na .fill modyfikuje warunki dopasowania i może spowodować renderowanie zawartości poza ramką; Apple opisuje przycinanie jako osobną operację w artykule Dopasowywanie obrazów do dostępnej przestrzeni.

Diagnozuj pozostałe problemy z wyrównaniem

Sprawdź po kolei następujące warunki:

  1. Granica pomiaru: Umieść nakładkę znaczników przed zewnętrzną rozszerzającą ramką. Jej obramowanie powinno przebiegać wzdłuż grafiki, a nie otaczającej ją pustej przestrzeni.
  2. Granice współrzędnych: Upewnij się, że współrzędne znormalizowane odnoszą się do pełnego prostokąta źródłowego i zakładanego początku w lewym górnym rogu. W tym przykładzie prawidłowe wartości mieszczące się w granicach biegną od zera do jedności; nieoczekiwane wartości należy odrzucić, zamiast po cichu ograniczać do tego zakresu.
  3. Wybór punktu zakotwiczenia: Pamiętaj, że .position(x:y:) zakotwicza środek znacznika. Jeśli róg etykiety ma dotykać elementu, potrzebne jest jawne dodatkowe przesunięcie albo inna strategia zakotwiczenia.
  4. Małe układy: Znacznik może pozostać prawidłowo wyśrodkowany, a jednocześnie nachodzić na inny znacznik. Ustal osobną regułę ukrywania lub przestawiania etykiet przy małych rozmiarach.
  5. Zachowanie przy krawędzi: Znacznik wyśrodkowany przy krawędzi grafiki wystaje poza tę krawędź. Przed dodaniem przycinania zdecyduj, czy takie wystawanie jest dopuszczalne.

Wyznaczaj pozycje na podstawie bieżącej geometrii, zamiast zapisywać pierwszy zmierzony rozmiar w .onAppear. Apple definiuje to wywołanie zwrotne jako związane z pojawieniem się widoku, a jego moment zależy od typu widoku; nie jest to wywołanie zwrotne przy zmianie rozmiaru okna. Bezpośredni odczyt geometrii wiąże obliczenia położenia w tym przykładzie z bieżącym układem. Zobacz onAppear(perform:).

W przypadku diagramu dopasowanego do zmieniającego rozmiar okna Maca niezawodnym punktem wyjścia jest jeden wspólny prostokąt: pozwól, by grafika wyznaczyła swoje dopasowane granice, zmierz je i wyznacz pozycję środka każdej adnotacji na ich podstawie. Dopiero po ustaleniu tej zależności dodaj otaczający układ.

Źródła