Aby etykiety w kontrolkach rozwijanych płynnie zmieniały wiersze, zmierz każdą kontrolkę w jej idealnym rozmiarze, rozmieszczaj je za pomocą wspólnego algorytmu zawijania i animuj zmiany wyboru na kontenerze. W tym samouczku zbudujemy mały panel sterowania dla macOS z przykładowymi opcjami.
Oddziel zawijanie od animacji
Zadanie jest konkretne: zbuduj panel na komputer, w którym wybranie dłuższej etykiety w kontrolce rozwijanej może przenieść sąsiednie kontrolki do kolejnego wiersza. Kontrolki powinny zachować swoją tożsamość podczas zmiany położenia, a panel powinien przekazywać nową wysokość otaczającemu interfejsowi.
Są tu dwa odrębne zadania. Niestandardowy Layout oblicza docelowe rozmiary i położenia. Animacja określa, jak SwiftUI ma przejść od starej do nowej geometrii. Apple opisuje Layout jako punkt rozszerzeń dla układów wymagających bardziej złożonego działania niż wbudowane kontenery. Dwie wymagane metody zwracają rozmiar kontenera i rozmieszczają jego podwidoki. Layout
W tym przykładzie każda kontrolka rozwijana to Menu, którego akcje aktualizują wybór. Apple obsługuje menu z własnymi konstruktorami etykiet, dzięki czemu wyświetlana etykieta może zawierać zarówno kategorię, jak i wybraną opcję. Układ rozmieszcza zamknięte kontrolki w oknie; przykład nie próbuje sterować geometrią wyświetlonej zawartości menu. Menu
Projekt opiera się na trzech jawnych zasadach:
- Kontrolki zachowują idealny rozmiar zamiast ściskać etykiety, by zmieścić je w jednym wierszu.
- Kontrolki zawijają się w dotychczasowej kolejności, gdy kolejna przekroczyłaby dostępną szerokość.
- Kontrolka szersza niż dostępne miejsce zajmuje osobny wiersz, a układ zgłasza jej większy rozmiar.
Ta ostatnia zasada ma znaczenie. Zawijanie kolekcji nie sprawi, że pojedyncza, niepodzielna kontrolka stanie się węższa. Jeśli aplikacja musi zmieścić dowolnie długie etykiety w wąskim panelu, potrzebujesz też osobnej strategii dla etykiet, na przykład celowo ograniczonego projektu etykiety. Apple zaznacza, że fixedSize() zachowuje idealny rozmiar, przez co widok może wykraczać poza granice elementu nadrzędnego. fixedSize()
Użyj jednego obliczenia do pomiaru i rozmieszczania
System układu SwiftUI przekazuje propozycje rozmiaru w dół hierarchii. Propozycja z nieokreślonymi wymiarami żąda idealnego rozmiaru widoku; różni się od propozycji zawierającej zero lub nieskończoność. Tutaj pomiary idealnego rozmiaru określają wymiary każdej kontrolki, a proponowana szerokość elementu nadrzędnego wyznacza podziały na wiersze. ProposedViewSize
Najważniejsza jest spójność: kontener musi zarezerwować tyle samo miejsca, ile wykorzystuje algorytm rozmieszczania. Apple wyraźnie wymaga spójnych obliczeń rozmiaru i rozmieszczenia, w tym odstępów oraz reakcji na propozycje. Określa też, że granice rozmieszczania podawane są w układzie współrzędnych elementu nadrzędnego, dlatego podczas rozmieszczania trzeba uwzględnić bounds.minX i bounds.minY. placeSubviews(in:proposal:subviews:cache:)
Utwórz w Xcode aplikację SwiftUI dla macOS i ustaw minimalną obsługiwaną wersję systemu na macOS 14 lub nowszą. Przykład korzysta z Layout, dostępnego od macOS 13, oraz geometryGroup(), dostępnego od macOS 14. Umieść poniższą deklarację układu oraz kod aplikacji z następnej sekcji w jednym pliku Swift, zastępując wygenerowany punkt wejścia aplikacji. Oba fragmenty razem tworzą kompletny przykład. Dostępność Layout, Dostępność geometryGroup()
import SwiftUI
struct WrappingControls: Layout {
var spacing: CGFloat = 8
struct Plan {
var size: CGSize
var origins: [CGPoint]
}
func plan(
proposal: ProposedViewSize,
subviews: Subviews
) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, origins: [])
}
let gap = max(0, spacing)
let limit: CGFloat
if let width = proposal.width, width.isFinite {
limit = max(0, width)
} else {
limit = .infinity
}
var origins: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
var rowHasItems = false
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let nextX = rowHasItems ? x + gap : 0
if rowHasItems && nextX + size.width > limit {
y += rowHeight + gap
x = 0
rowHeight = 0
rowHasItems = false
}
if rowHasItems {
x += gap
}
origins.append(CGPoint(x: x, y: y))
x += size.width
rowHeight = max(rowHeight, size.height)
usedWidth = max(usedWidth, x)
rowHasItems = true
}
let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
return Plan(
size: CGSize(width: width, height: y + rowHeight),
origins: origins
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
plan(proposal: proposal, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = plan(proposal: proposal, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let origin = result.origins[index]
subview.place(
at: CGPoint(
x: bounds.minX + origin.x,
y: bounds.minY + origin.y
),
anchor: .topLeading,
proposal: .unspecified
)
}
}
}
Obie metody układu wywołują plan, używając propozycji przekazanej przez SwiftUI. Pomiar i rozmieszczanie korzystają też z tej samej propozycji dla elementów podrzędnych: .unspecified. Interfejs pomiarowy podwidoku zwraca rozmiar, jaki podwidok wybiera dla przekazanej propozycji; przykład korzysta z tej wartości zamiast szacować szerokość na podstawie liczby znaków. sizeThatFits(_:)
Dla skończonej proponowanej szerokości układ wypełnia co najmniej tę szerokość i rzetelnie zgłasza rozmiar przekraczającego ją elementu podrzędnego. Dla szerokości nieokreślonej lub nieskończonej zwraca skończony, idealny rozmiar jednego wiersza dla kontrolek o skończonych wymiarach. Wysokość zależy od zawartości; układ celowo ignoruje proponowaną wysokość i zgłasza przestrzeń wymaganą przez wiersze.
Ta implementacja używa pustej pamięci podręcznej typu Void i za każdym razem ponownie oblicza niewielki plan. Apple zaleca dodawanie pamięci podręcznej tylko wtedy, gdy profilowanie wykaże korzyść. Jeśli później zaczniesz buforować pomiary, zachowaj wymóg, by ponowne utworzenie pamięci podręcznej nie zmieniało wyniku układu. makeCache(subviews:)
Powiąż zmieniające się wybory ze stabilnymi kontrolkami
Przykładowy panel ma trzy kategorie: Shape, Pattern i Arrangement. Każda kategoria przechowuje wybrany ciąg znaków, a wszystkie opcje są zdefiniowane w przykładzie. Etykieta zmienia się w ramach istniejącego obiektu Menu; program nie zastępuje menu po każdej zmianie wyboru.
Dodaj ten kod po deklaracji układu. W projekcie pozostaw tylko jedną deklarację aplikacji @main.
struct ControlChoice: Identifiable, Equatable {
let id: Int
let title: String
let options: [String]
var selected: String
}
struct DropdownPanel: View {
@State var choices: [ControlChoice] = [
ControlChoice(
id: 1,
title: "Shape",
options: ["Circle", "Rounded rectangle"],
selected: "Circle"
),
ControlChoice(
id: 2,
title: "Pattern",
options: ["Solid", "Alternating diagonal lines"],
selected: "Solid"
),
ControlChoice(
id: 3,
title: "Arrangement",
options: ["Rows", "Evenly spaced columns"],
selected: "Rows"
)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Layout options")
.font(.headline)
WrappingControls(spacing: 8) {
ForEach(choices) { choice in
Menu {
ForEach(choice.options, id: \.self) { option in
Button(option) {
select(option, for: choice.id)
}
}
} label: {
Text("\(choice.title): \(choice.selected)")
.lineLimit(1)
}
.fixedSize()
.geometryGroup()
}
}
.animation(
.easeInOut(duration: 0.25),
value: choices.map(\.selected)
)
Text("Choose a longer option, then narrow the window.")
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 320, minHeight: 240)
}
func select(_ option: String, for id: Int) {
guard let index = choices.firstIndex(where: { $0.id == id }),
choices[index].options.contains(option) else {
return
}
choices[index].selected = option
}
}
@main
struct RewrappingMenusApp: App {
var body: some Scene {
WindowGroup {
DropdownPanel()
}
}
}
Modyfikator animacji obserwuje tablicę wybranych ciągów znaków. Apple wymaga, aby obserwowana wartość spełniała wymóg Equatable; ta tablica go spełnia. Obserwowanie wartości wyboru oznacza też, że ten modyfikator nie uruchamia nowej animacji tylko dlatego, że zmieniła się szerokość okna. Układ nadal przelicza zawijanie, gdy zmienia się jego propozycja. animation(_:value:)
Stabilne identyfikatory kategorii łączą każde menu między kolejnymi aktualizacjami. ForEach tworzy widoki na podstawie danych z identyfikatorami, natomiast zmiana jawnego .id widoku resetuje jego tożsamość i powiązany stan. Dlatego dodanie .id(choice.selected) byłoby sprzeczne z potrzebą zachowania ciągłości w tym przykładzie. Ciągi opcji są unikatowe w obrębie każdej przykładowej kategorii, dzięki czemu id: \.self nadaje się do użycia w akcjach tych menu. ForEach, id(_:)
Przykład Apple dotyczący niestandardowych układów pokazuje, że zachowanie tożsamości strukturalnej pozwala animation(_:value:) animować zmiany układu. W tym przykładzie ta zasada służy do obsługi zmieniających się wymiarów w obrębie jednego typu układu. Ciąg wyboru zmienia się natychmiast; zamierzona animacja dotyczy wynikowego ruchu i zmiany rozmiaru, a nie interpolacji między znakami dwóch etykiet. Tworzenie niestandardowych układów w SwiftUI
Modyfikator geometryGroup() wyznacza granicę geometrii dla całego menu. Apple opisuje tę granicę jako mechanizm, który rozwiązuje i animuje geometrię elementu nadrzędnego, zanim przekaże ją elementom podrzędnym. W tym przykładzie zastosowano ten udokumentowany mechanizm, aby zawartość każdej kontrolki poruszała się razem; nie wpływa on na podział na wiersze ani nie naprawia błędnych obliczeń zawijania. geometryGroup()
Sprawdź podziały na wiersze przed dostrajaniem animacji
Prosty przykład pozwala prześledzić działanie zasad zawijania. Wymiary są poglądowe i nie opisują kontrolek macOS: załóżmy, że trzy kontrolki mają szerokości 80, 100 i 90 punktów, wszystkie wysokość 24 punktów, odstęp wynosi 8 punktów, a dostępna szerokość to 300 punktów.
Początkowy wiersz wymaga 286 punktów: 80 + 8 + 100 + 8 + 90. Jeśli środkowa kontrolka zwiększy szerokość do 150 punktów, cały wiersz wymagałby 336 punktów. Pierwsze dwie kontrolki nadal się mieszczą, zajmując 238 punktów, więc trzecia przechodzi do następnego wiersza. Nowa wysokość zawartości wynosi 56 punktów: 24 + 8 + 24.
| Warunek | Oczekiwany wynik tego algorytmu |
|---|---|
| Łączna szerokość jest równa limitowi | Pozostaw kontrolki w tym wierszu. |
| Kolejna kontrolka przekroczyłaby limit | Rozpocznij nowy wiersz przed tą kontrolką. |
| Kontrolki mają różne wysokości | Przesuń się o wysokość najwyższej kontrolki w zakończonym wierszu. |
| Jedna kontrolka przekracza proponowaną szerokość | Umieść ją osobno i zgłoś wymaganą większą szerokość. |
| Szerokość jest nieokreślona lub nieskończona | Zwróć jeden wiersz o łącznej idealnej szerokości kontrolek. |
| Brak kontrolek | Zwróć rozmiar zerowy. |
Obliczenia rozmieszczenia sprawdzono osobno przy użyciu deterministycznych wymiarów przykładowych, uwzględniając dłuższe etykiety, zbyt szeroką kontrolkę i nieokreśloną szerokość. Sama aplikacja SwiftUI nie została w tym środowisku skompilowana ani przetestowana wizualnie. Zbuduj ją i uruchom w systemie macOS, aby sprawdzić wygląd natywnych kontrolek i animację.
W uruchomionej aplikacji najpierw wybierz dłuższe opcje, nie zmieniając szerokości okna. Zmień szerokość okna tak, aby zmiana wyboru przekroczyła granicę wiersza, a następnie przełączaj się między krótkimi i długimi opcjami. Sprawdź, czy tekst objaśniający pod kontrolkami przesuwa się, by uwzględnić nową wysokość wiersza, i czy po powrocie do krótkich etykiet znika nieużywane miejsce w pionie.
Następnie zmień szerokość okna bez zmiany wyborów. Pozwoli to odróżnić błąd zawijania od błędu animacji: nieprawidłowe położenia końcowe wskazują na problem z pomiarem lub rozmieszczaniem, a poprawne położenia końcowe przy niepożądanym ruchu sugerują, by sprawdzić zakres animacji i grupowanie geometrii.
Zdiagnozuj pozostałe przyczyny błędów
Jeśli etykiety się zmieniają, ale podziały na wiersze nie, sprawdź, co faktycznie mierzy układ. Opakowanie każdego menu w element o stałej szerokości może sprawić, że krótkie i długie etykiety będą zgłaszać tę samą szerokość zewnętrzną. W tej implementacji każde menu celowo zachowuje idealny rozmiar, a układ mierzy całą kontrolkę zamiast osobno szacować rozmiar tekstu. fixedSize(), sizeThatFits(_:)
Jeśli kontrolki nachodzą na siebie albo kolejna sekcja zasłania drugi wiersz, najpierw porównaj obliczenia rozmiaru i rozmieszczania. Używaj tych samych odstępów, propozycji dla elementów podrzędnych, warunku zawijania i obliczenia wysokości wiersza w obu metodach. Nie obliczaj rozmiaru przy jednej szerokości, a rozmieszczenia przy innej. Wspólna funkcja plan utrudnia wprowadzanie takich rozbieżności. placeSubviews(in:proposal:subviews:cache:)
Jeśli kontrolki znikają i pojawiają się ponownie przy zmianach wyboru, przed dodaniem kolejnych modyfikatorów animacji sprawdź ich tożsamość. Zachowaj stabilny identyfikator kategorii i aktualizuj tylko jej wybór. Apple wyjaśnia, że zmiana jawnego identyfikatora widoku resetuje jego tożsamość, dlatego zmieniająca się etykieta nie jest dobrym kluczem tożsamości dla trwałej kontrolki. id(_:)
W przypadku wdrożenia dla macOS 13 usuń .geometryGroup() i pozostaw implementację Layout oraz animację zależną od wyboru. Dzięki temu zachowasz podejście do zawijania, ale usuniesz udokumentowaną granicę geometrii, dostępną od macOS 14; oceń uzyskany ruch na obsługiwanych systemach. Layout, geometryGroup()
Gotowe działanie opiera się na trzech konkretnych decyzjach: mierz rzeczywiste kontrolki, używaj tego samego planu wierszy do zgłaszania rozmiaru i rozmieszczania oraz zachowuj tożsamość kontrolek podczas zmiany etykiet. Gdy dzięki tym decyzjom geometria końcowa jest prawidłowa, animacja zależna od wyboru ułatwia śledzenie zawijania.
Źródła
- Layout — dokumentacja Apple dla deweloperów.
- ProposedViewSize — dokumentacja Apple dla deweloperów.
- placeSubviews(in:proposal:subviews:cache:) — dokumentacja Apple dla deweloperów.
- sizeThatFits(_:) — dokumentacja Apple dla deweloperów.
- makeCache(subviews:) — dokumentacja Apple dla deweloperów.
- Menu — dokumentacja Apple dla deweloperów.
- fixedSize() — dokumentacja Apple dla deweloperów.
- animation(_:value:) — dokumentacja Apple dla deweloperów.
- ForEach — dokumentacja Apple dla deweloperów.
- id(_:) — dokumentacja Apple dla deweloperów.
- Tworzenie niestandardowych układów w SwiftUI — dokumentacja Apple dla deweloperów.
- geometryGroup() — dokumentacja Apple dla deweloperów.