Aby otrzymywać różne typowane wyniki na jednym ekranie macOS SwiftUI, przypisz każdemu komponentowi generyczny adapter lub typowaną domkniętą funkcję. Swift nie pozwala ekranowi dwukrotnie przyjąć zgodności z tym samym protokołem dla różnych typów skojarzonych.

W tym samouczku zbudujemy niewielki podgląd desktopowy, którego przyciski ustawiają przykładowy tytuł i liczbę kolumn. Celem jest zachowanie sprawdzanego przez kompilator kontraktu zakończenia dla każdego komponentu, przy jednoczesnym umożliwieniu widokowi nadrzędnemu odbierania obu wartości.

Dlaczego jeden typ nie może przyjąć obu kontraktów zakończenia?

Typ skojarzony protokołu należy do jego zgodności. Jeśli protokół obsługi zakończenia deklaruje typ skojarzony o nazwie Value, typ przyjmujący zgodność musi określić dla niego jedną spójną wartość. Dodanie dwóch przeciążonych metod zakończenia nie tworzy dwóch niezależnych zgodności. Przewodnik po języku Swift wyjaśnia, jak typy skojarzone otrzymują konkretne typy podczas przyjmowania zgodności z protokołem. Generics — Associated Types.

Na przykład ekran nie może przyjąć zgodności z jednym protokołem zakończenia raz z Value równym TitleChoice, a drugi raz z Value równym LayoutChoice. Przeniesienie deklaracji do osobnych rozszerzeń nie zmienia tego ograniczenia. SE-0143 wyraźnie utrzymuje zakaz powtarzania zgodności, także w przypadku zgodności warunkowych o rozłącznych warunkach. Zgodności warunkowe.

Podstawowe typy skojarzone nie znoszą tego ograniczenia. Składnia CompletionHandling<TitleChoice> wyraża warunek dotyczący typu skojarzonego; nie tworzy odrębnej tożsamości protokołu dla każdego argumentu. SE-0346 wprowadził tę składnię warunków w Swift 5.7. Uproszczone wymagania równości typów dla podstawowych typów skojarzonych.

Poniższe rozwiązanie przenosi zgodność na generyczny adapter. Istnieje jedna deklaracja zgodności, a aplikacja tworzy adaptery o różnych specjalizacjach dla obu komponentów. Widok nadrzędny zarządza wynikowym stanem, nie przyjmując zgodności z protokołem zakończenia.

Który projekt obsługi zakończenia pasuje do komponentu?

Przed zmianą interfejsu skorzystaj z poniższej wskazówki. To zalecenia projektowe wynikające z udokumentowanych reguł typów, a nie dodatkowe wymagania języka Swift.

Wymagania komponentu Sugerowany projekt Co pozostaje jawne
Jedno wywołanie zwrotne, a interfejs jest pod Twoją kontrolą Typowana domknięta funkcja Typ wejściowy wywołania zwrotnego
Istniejący interfejs oparty na protokole Generyczny adapter przyjmujący zgodność Protokół i jego typ skojarzony
Jeden kanał zdarzeń z kilkoma nazwanymi wariantami Wyliczenie z wartościami skojarzonymi Każdy wariant zdarzenia obsługiwany przez odbiorcę
Kilka niezależnych operacji o różnych znaczeniach Osobne nazwane protokoły Odrębne wymagania każdej operacji

W tym przykładzie załóżmy, że komponent wielokrotnego użytku już wymaga obsługi opartej na protokole. Generyczny adapter zachowuje ten interfejs i utrzymuje kod obsługi ekranu obok aktualizacji jego stanu. Jeśli komponent potrzebuje tylko wywołania zwrotnego i nie wymaga protokołu, prostszym rozwiązaniem będzie domknięta funkcja.

Generyczna metoda zakończenia wyrażałaby inną obietnicę. Metoda taka jak complete<T>(_ value: T) musi działać dla każdego typu dozwolonego przez jej ograniczenia. Nie wyraża węższego wymagania, zgodnie z którym każdy komponent dostarcza jeden określony typ wyniku. Dokumentacja generyczności w Swift opisuje działanie funkcji generycznych na typach wybieranych w miejscach ich wywołania. Generics — Generic Functions.

Zbuduj typowany adapter obsługi zakończenia

W tym przykładzie użyj aplikacji macOS SwiftUI z kompilatorem Swift 5.7 lub nowszym oraz docelową wersją macOS 12 lub nowszą. Wymaganie dotyczące kompilatora wynika z deklaracji podstawowego typu skojarzonego. Docelowa wersja systemu to ostrożnie przyjęta wartość bazowa dla tego samouczka, a nie twierdzenie, że każde użyte API pojawiło się po raz pierwszy w macOS 12.

Trzy poniższe pliki składają się na kompletny przykład. Dodaj je do tego samego celu aplikacji, zastępując wygenerowany punkt wejścia aplikacji i widok zawartości, tak aby cel zawierał tylko jedną deklarację @main. Nie są potrzebne żadne pakiety, zasoby, pliki przykładowe ani dane zewnętrzne.

Utwórz plik CompletionContracts.swift z następującymi deklaracjami:

import SwiftUI

struct TitleChoice {
    let text: String
}

struct LayoutChoice {
    let columns: Int
}

@MainActor
protocol CompletionHandling<Value> {
    associatedtype Value
    func complete(_ value: Value)
}

@MainActor
struct CompletionAdapter<Output>: CompletionHandling {
    typealias Value = Output

    private let receive: @MainActor (Output) -> Void

    init(_ receive: @escaping @MainActor (Output) -> Void) {
        self.receive = receive
    }

    func complete(_ value: Output) {
        receive(value)
    }
}

@MainActor
struct CompletionButton<Value, Handler: CompletionHandling>: View
where Handler.Value == Value {
    let title: String
    let value: Value
    let handler: Handler

    var body: some View {
        Button(action: {
            handler.complete(value)
        }) {
            Text(title)
        }
    }
}

Warunek Handler.Value == Value łączy typ wyniku komponentu z typem wejściowym akceptowanym przez obsługę. Przycisk przenoszący TitleChoice wymaga więc obsługi, której typ skojarzony to TitleChoice. Jawny alias typu ułatwia sprawdzenie typu skojarzonego adaptera.

Adapter przechowuje domkniętą funkcję do późniejszego wywołania, dlatego jego parametr inicjalizatora ma oznaczenie @escaping. Dokumentacja domkniętych funkcji w Swift wymaga tego oznaczenia, gdy domknięta funkcja może istnieć dłużej niż wywołanie funkcji, która ją otrzymuje. Closures — Escaping Closures.

Protokół, adapter i widok używają @MainActor, a przechowywane wywołanie zwrotne zachowuje tę izolację w swoim typie funkcji. Dzięki temu zamierzony kontekst wykonywania interfejsu użytkownika jest częścią kontraktu. SE-0316 opisuje zarówno izolację globalnego aktora, jak i typy funkcji kwalifikowane globalnym aktorem. Globalni aktorzy.

Apple dokumentuje Button jako element przyjmujący akcję i etykietę. W tym przypadku akcja wywołuje obsługę z typowaną wartością komponentu, a Text(title) dostarcza widoczną etykietę. Przycisk.

Połącz dwa typy wyników z jednym ekranem SwiftUI

Utwórz plik ContentView.swift. Wyniki dotyczące tytułu i układu mają osobne miejsca docelowe w stanie, a domknięta funkcja każdego adaptera aktualizuje tylko odpowiadające jej miejsce.

import SwiftUI

@MainActor
struct ContentView: View {
    @State private var previewTitle = "Sample Board"
    @State private var columnCount = 1

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Component Preview")
                .font(.headline)

            Text(previewTitle)
            Text("Columns: \(columnCount)")

            HStack {
                CompletionButton(
                    title: "Apply title",
                    value: TitleChoice(text: "Pebble Board"),
                    handler: CompletionAdapter<TitleChoice> { choice in
                        previewTitle = choice.text
                    }
                )

                CompletionButton(
                    title: "Apply layout",
                    value: LayoutChoice(columns: 3),
                    handler: CompletionAdapter<LayoutChoice> { choice in
                        columnCount = choice.columns
                    }
                )
            }
        }
        .padding(24)
        .frame(minWidth: 420, minHeight: 180)
    }
}

Utwórz plik TypedCompletionsApp.swift jako jedyny punkt wejścia aplikacji:

import SwiftUI

@main
@MainActor
struct TypedCompletionsApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Widok nadrzędny przechowuje wartości początkowe w prywatnych właściwościach @State. Apple dokumentuje, że SwiftUI zarządza tym miejscem przechowywania i aktualizuje zależne widoki po zmianie wartości. Aktualna dokumentacja zwraca też uwagę, że kompilacje z Xcode 27 lub nowszym używają zamiast tego makra State; pokazany styl deklaracji opiera się na wcześniejszym wariancie z opakowaniem właściwości. State.

WindowGroup zawiera hierarchię widoków ekranu. Apple dokumentuje niezależne przechowywanie stanu dla każdego okna utworzonego przez tę grupę, więc w tym przykładzie stan należy do każdego okna podglądu. WindowGroup.

Istnieją dwa wyspecjalizowane typy adapterów: CompletionAdapter<TitleChoice> i CompletionAdapter<LayoutChoice>. Oba powstają na podstawie jednej deklaracji generycznego adaptera. ContentView przyjmuje zgodność wyłącznie z View; odbiera oba wyniki przez dostarczone przez siebie domknięte funkcje.

Sprawdź działanie i diagnozuj niezgodności

W środowisku badawczym nie ma kompilatora Swift, dlatego pliki zostały sprawdzone, ale nie zostały skompilowane ani uruchomione. Zbuduj cel aplikacji na swoim Macu, a następnie wykonaj poniższe czynności, aby zweryfikować oczekiwane działanie przykładu.

  1. Uruchom aplikację. Podgląd powinien wyświetlać Sample Board i Columns: 1.
  2. Kliknij Apply title. Tytuł powinien zmienić się na Pebble Board, a liczba kolumn pozostać bez zmian.
  3. Kliknij Apply layout. Liczba powinna zmienić się na Columns: 3, a wybrany tytuł pozostać bez zmian.
  4. Kliknij ponownie dowolny przycisk. Powinien ponownie zastosować tę samą przykładową wartość.

Aby sprawdzić granicę typów, tymczasowo zmień specjalizację obsługi pierwszego przycisku na CompletionAdapter<LayoutChoice> i pozostaw pustą treść jego domkniętej funkcji. Zachowaj wartość przycisku typu TitleChoice. Komponent wymaga wtedy, by niezgodne typy były równe poprzez Handler.Value == Value, więc kompilacja powinna się nie powieść. Następnie przywróć pierwotną obsługę.

Jeśli kompilator zgłasza powtarzającą się zgodność, poszukaj innej deklaracji, która sprawia, że ten sam typ nominalny przyjmuje zgodność z CompletionHandling. Zachowaj pojedynczą generyczną zgodność adaptera i utwórz kolejną wartość adaptera dla dodatkowego wyniku. Dwa ograniczone rozszerzenia deklarujące tę samą zgodność nadal podlegają zakazowi powtarzania zgodności w Swift. Zgodności warunkowe — wiele zgodności.

Jeśli kompilator odrzuca składnię podstawowego typu skojarzonego w protokole, sprawdź jego wersję. W starszym kompilatorze projekt adaptera może zachować zwykłą deklarację typu skojarzonego. Poniższy fragment zastępuje wyłącznie deklarację protokołu; pozostałe deklaracje pozostaw bez zmian:

@MainActor
protocol CompletionHandling {
    associatedtype Value
    func complete(_ value: Value)
}

Ta zmiana usuwa zależność tego przykładu od składni wprowadzonej w Swift 5.7. Nie usuwa wymogu kompilatora obsługującego @MainActor, wprowadzone w Swift 5.5, ani nie zmienia docelowej wersji systemu w samouczku. Komponent już używa klauzuli where dla typu generycznego, więc w miejscu użycia nie potrzebuje składni podstawowego typu skojarzonego. Uproszczone wymagania równości typów, Globalni aktorzy.

Co gwarantuje ten kontrakt obsługi zakończenia?

Przykład gwarantuje zgodność typu danych na granicy każdego komponentu. Implementacja wywołuje obsługę raz po każdym aktywowaniu przycisku, synchronicznie. Nie implementuje operacji asynchronicznej, anulowania, trwałości ani cyklu życia z jednorazowym zakończeniem. Wymagałyby one dodatkowego działania i jawnego kontraktu.

Przykład wykorzystuje też znane wartości lokalne. Jeśli przyszły komponent będzie przyjmował dowolne liczby kolumn, przed aktualizacją podglądu zweryfikuj dane wejściowe i określ sposób zgłaszania odrzucenia. Sama zgodność typów Swift nie gwarantuje, że liczba całkowita jest użyteczną liczbą kolumn.

W tym zadaniu desktopowym użyj jednego adaptera dla każdego typowanego punktu końcowego i pozwól widokowi nadrzędnemu zarządzać wynikowym stanem. Dzięki temu dwa komponenty otrzymują precyzyjne kontrakty obsługi zakończenia przez jedną deklarację zgodności wielokrotnego użytku, a miejsce docelowe każdego wyniku jest widoczne w miejscu wywołania.

Źródła