Aby kursor pozostawał w tym samym miejscu podczas edycji listy w SwiftUI, nadaj każdemu elementowi niezmienne ID, powiąż pole bezpośrednio z jego tekstem i zachowaj położenie edytora w hierarchii widoków. W systemie macOS 15 lub nowszym użyj powiązania zaznaczenia, jeśli aplikacja ma również kontrolować położenie kursora wstawiania.
W tym przewodniku utworzymy małą aplikację na Maca do edycji syntetycznych etykiet, takich jak Blue tile. Zadanie polega na dalszym wpisywaniu tekstu w tym samym elemencie, gdy jego tekst i otaczająca go lista są aktualizowane. Wyjaśnimy też, kiedy przywrócenie zaznaczenia wymaga dodatkowego stanu lub edytora AppKit.
Dlaczego kursor przemieszcza się po zmianie elementu listy?
Najpierw sprawdź, czy tożsamość elementu zmienia się wraz z jego tekstem. SwiftUI używa tożsamości do łączenia kolejnych wartości widoku, a trwały stan widoku jest przypisany do tej tożsamości. Ponowna ewaluacja widoku body jest normalna; zmiana jego tożsamości kończy jego cykl życia. Apple wyjaśnia tę zależność w Demystify SwiftUI.
W edytowalnej kolekcji nie identyfikuj wiersza za pomocą jego edytowalnej etykiety. W przypadku id: \.self tożsamość ciągu zmienia się wraz ze zmianą jego wartości. Ten sam problem może wystąpić w modelu, którego hash uwzględnia etykietę. Identyfikowanie elementów, które można przenosić, za pomocą ich pozycji w tablicy również wiąże tożsamość z miejscem, a nie z logicznym elementem.
Zamiast tego użyj zapisanego identyfikatora, który nie zmienia się podczas edycji. Dokumentacja Apple dotycząca ForEach obsługuje kolekcje elementów identyfikowalnych lub jawną ścieżkę klucza identyfikatora. W tym edytorze praktycznym wyborem jest zapisane UUID przypisane do elementu.
Usuń też modyfikatory takie jak .id(item.text) lub .id(UUID()) z edytora i jego elementów nadrzędnych. Apple wyraźnie dokumentuje, że zmiana wartości przekazanej do id(_:) resetuje tożsamość widoku, łącznie z jego stanem. Stabilne ID wiersza nie skompensuje zmiany tożsamości elementu nadrzędnego przy każdej edycji.
Oddziel tożsamość, fokus i zaznaczenie
Te trzy elementy stanu odpowiadają na różne pytania. Tożsamość określa, który logiczny wiersz pozostaje tym samym elementem przy aktualizacjach. Fokus określa, który element sterujący odbiera dane z klawiatury. Zaznaczenie określa położenie kursora wstawiania lub zaznaczony tekst wewnątrz tego elementu.
FocusState pozwala aplikacji obserwować i zmieniać fokus. Nadaj każdemu polu osobną wartość fokusu, na przykład ID jego elementu. Powiązanie wszystkich wierszy z tą samą wartością logiczną nie pozwala wskazać, który konkretny element ma otrzymać programowe żądanie fokusu.
TextSelection reprezentuje położenie kursora wstawiania, zaznaczony zakres lub wiele zaznaczeń w systemie macOS. Inicjalizator TextField z powiązaniem zaznaczenia jest dostępny od systemu macOS 15.
Poniższa implementacja korzysta z tych udokumentowanych ról: model przechowuje tożsamość i tekst, lista przechowuje ID elementu z fokusem, a każdy aktywny wiersz przechowuje własne zaznaczenie. To rozwiązanie zapewnia nieprzerwaną edycję, gdy wiersz pozostaje widoczny, ale nie gwarantuje przywrócenia wszystkich szczegółów edycji po usunięciu i ponownym utworzeniu wiersza.
Utwórz listę ze stabilną tożsamością elementów
Utwórz w Xcode aplikację SwiftUI dla systemu macOS i ustaw docelową wersję wdrożenia na macOS 15 lub nowszą. Użyj SDK zawierającego inicjalizator z powiązaniem zaznaczenia. Zastąp wygenerowane deklaracje aplikacji i widoku zawartości poniższym kompletnym przykładem; w celu zachowania tylko jednej deklaracji @main w obrębie celu usuń pozostałe.
import Foundation
import SwiftUI
struct LabelItem: Identifiable {
let id: UUID
var text: String
init(id: UUID = UUID(), text: String) {
self.id = id
self.text = text
}
}
@main
struct CaretListApp: App {
var body: some Scene {
WindowGroup {
LabelListView()
}
}
}
struct LabelListView: View {
@State private var items = [
LabelItem(text: "Blue tile"),
LabelItem(text: "Round pebble"),
LabelItem(text: "Paper square")
]
@FocusState private var focusedID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Button("Add label") {
items.append(LabelItem(text: "New label"))
}
.keyboardShortcut("n", modifiers: [.command, .shift])
Button("Reverse rows") {
items.reverse()
}
.keyboardShortcut("r", modifiers: [.command, .shift])
}
List {
ForEach($items) { $item in
LabelRow(item: $item, focus: $focusedID)
}
}
}
.padding()
.frame(minWidth: 420, minHeight: 260)
}
}
struct LabelRow: View {
@Binding var item: LabelItem
let focus: FocusState<UUID?>.Binding
@State private var selection: TextSelection? = nil
var body: some View {
TextField("Label", text: $item.text, selection: $selection)
.textFieldStyle(.roundedBorder)
.focused(focus, equals: item.id)
}
}
Zapisane id jest tworzone podczas tworzenia elementu. Zmiana text lub odwrócenie kolejności istniejącej tablicy nie narusza tego ID. ForEach oparty na powiązaniu przekazuje każdemu wierszowi edytowalną wartość modelu, a pole zapisuje tekst bezpośrednio w tym elemencie.
Powiązany z ciągiem znaków TextField aktualizuje powiązanie w trakcie edycji. Dlatego ten przykład nie wymaga dodatkowej obsługi zmian, by kopiować każde naciśnięcie klawisza do drugiej tablicy ani ponownie tworzyć listę.
Wiersz zawiera jeden edytor, którego położenie pozostaje niezmienne. Nie ma modyfikatora tożsamości zależnego od tekstu, automatycznego resetowania zaznaczenia ani żądania fokusu podczas każdej aktualizacji. Powiązanie zaznaczenia pozwala polu przekazywać informację o jego bieżącym zaznaczeniu bez konieczności wielokrotnego nadpisywania go przez kod aplikacji.
Przykład sprawdzono względem pobranych deklaracji API. Nie został tutaj skompilowany ani uruchomiony, ponieważ w tym środowisku nie ma kompilatora Swift ani środowiska wykonawczego systemu macOS.
Zachowuj ID podczas odświeżania modelu
Odświeżając model, zachowaj identyfikatory elementów, które nadal odpowiadają tym samym etykietom. Utworzenie nowego LabelItem(text:) dla każdego istniejącego elementu wygeneruje nowe ID, nawet jeśli cały widoczny tekst pozostanie bez zmian.
Podczas ponownego tworzenia kolekcji na podstawie rekordów przechowywanych przez aplikację przenieś istniejące ID każdego rekordu do nowej wartości modelu. Nowe ID generuj tylko przy tworzeniu nowego elementu. Jeśli rekordy nie mają trwałego identyfikatora, dodaj go w chwili utworzenia, zamiast wyprowadzać go z edytowalnego tekstu.
Nie sortuj etykiet po każdym naciśnięciu klawisza, chyba że zamierzonym efektem jest natychmiastowe przemieszczanie wierszy. Stabilna tożsamość wskazuje SwiftUI, który element został przeniesiony, ale zmiana położenia wiersza podczas pisania wciąż wpływa na komfort edycji. Jawna akcja sortowania zapewnia czytelniejsze przejście.
Diagnozuj utrzymujące się przeskakiwanie kursora w przydatnej kolejności
Poniższa tabela pomocnicza wynika z udokumentowanego rozdzielenia tożsamości, fokusu i zaznaczenia. Pomaga zawęzić problem przed dodaniem kodu przywracającego stan.
| Obserwowane zachowanie | Najpierw sprawdź | Odpowiedni kolejny krok |
|---|---|---|
| Edytor resetuje się po każdym znaku | ID wyprowadzone z tekstu; zmieniające się wartości .id(...) |
Zachowaj stabilność ID elementu i tożsamości elementów nadrzędnych |
| Klawiatura przestaje wprowadzać tekst w polu | Zmiana fokusu lub usunięcie edytora | Sprawdź focusedID i warunkowe gałęzie widoku |
| Fokus pozostaje w polu, ale kursor się przesuwa | Zapisy aplikacji do tekstu lub zaznaczenia | Usuń bezwarunkowe nadpisywanie tekstu i przypisywanie zaznaczenia |
| Stan edycji podąża za pozycją po zmianie kolejności | Tożsamość oparta na indeksie | Identyfikuj logiczny element za pomocą jego zapisanego ID |
| Zaznaczenie znika po odfiltrowaniu wiersza i ponownym wyświetleniu go | Cykl życia stanu lokalnego wiersza | Przechowuj informacje potrzebne do przywrócenia stanu poza usuwanym wierszem |
Najpierw odtwórz problem bez sortowania, filtrowania, formatowania i odświeżania modelu. Edytuj środek ciągu Blue tile. Jeśli to działa, wprowadzaj po jednej operacji naraz. Dzięki temu ustalisz, która zmiana wiąże się z przeskokiem.
Następnie sprawdź zmiany strukturalne. Przełączanie między dwiema warunkowymi gałęziami zawierającymi osobne edytory może zmienić tożsamość edytora. Wyjaśnienie Apple dotyczące tożsamości i cyklu życia potwierdza, że warto zachować ten sam edytor, gdy zmienia się tylko jego wygląd.
Na koniec sprawdź zapisy do powiązania tekstu. Obsługa zmian, która przycina ciąg znaków, zamienia pierwsze litery na wielkie lub w inny sposób podmienia tekst w trakcie edycji, wprowadza drugą operację edycji. Praktycznym rozwiązaniem jest pozostawienie szkicu bez zmian podczas pisania i zastosowanie przekształcenia po zatwierdzeniu tekstu.
Zdecyduj, co ma przetrwać usunięcie wiersza
Stan zaznaczenia lokalny dla wiersza wystarcza, dopóki wiersz pozostaje aktywny. Nie nadaje się jednak jako jedyne miejsce przechowywania stanu przywracania, jeśli interfejs usuwa wiersz. W informacjach o wydaniu macOS 15 Apple podaje również, że ForEach może odzyskiwać trwały stan należący do nieużywanych widoków podrzędnych.
Jeśli powrót do elementu ma przywrócić jego pozycję edycji, przechowuj informacje potrzebne do przywrócenia stanu poza wierszami, które można usunąć, i przypisz je do ID elementu. Powiąż je z wersją tekstu, której dotyczą. Usuń te informacje, gdy element zostanie trwale skasowany.
Przywracanie fokusu i przywracanie zaznaczenia to osobne operacje. Jeśli element już nie istnieje, nie przenoś zapisanego położenia kursora do elementu, który zajmuje teraz tę samą pozycję w tablicy.
Przy zmianach tekstu z poziomu aplikacji jawnie określaj zachowanie kursora
Stabilna tożsamość nie pozwala ustalić, gdzie ma się przesunąć kursor po podmianie tekstu przez aplikację. Potrzebna jest reguła odpowiednia do danej akcji: zachowanie logicznego położenia, zaznaczenie nowego tekstu lub przesunięcie kursora na koniec.
Inicjalizatory SwiftUI typu TextSelection przyjmują położenie kursora lub zakresy oparte na String.Index. Gdy aplikacja zmienia ciąg znaków, utwórz nowe zaznaczenie na podstawie jego wyniku. Nie traktuj starego zaznaczenia jak stałej pozycji zapisanej za pomocą liczby.
Ciągi znaków w Swift używają typu String.Index, a liczba znaków może różnić się od długości w UTF-16 używanej przez NSString. Różnice te opisano w rozdziale Strings and Characters. Mają znaczenie przy tłumaczeniu zaznaczeń SwiftUI na zakresy tekstu Cocoa.
Dla przykładu przyjmijmy, że tekst to Blue tile, a kursor znajduje się bezpośrednio za słowem Blue. Wstawienie Soft na początku powinno przesunąć kursor o pięć pozycji naprzód, jeśli celem jest zachowanie jego położenia względem oryginalnego tekstu. Pozostawienie liczbowego przesunięcia bez zmian umieściłoby kursor w innej części etykiety.
Przykład dotyczy prostego tekstu ASCII. W przypadku ogólnym określ jednostkę przesunięcia i sprawdzaj poprawność uzyskanych granic. Gdy podmieniany tekst nachodzi na zaznaczenie, wynik również należy określić świadomie; samo ograniczenie przesunięcia do długości nowego tekstu nie ustala zamierzonego zaznaczenia.
Kiedy należy używać AppKit lub TextKit?
W przypadku wcześniejszych docelowych wersji macOS inicjalizator SwiftUI z powiązaniem zaznaczenia dostępny w macOS 15 nie jest dostępny. W zwykłym polu SwiftUI można nadal poprawić niestabilną tożsamość. Jeśli aplikacja ma też jawnie kontrolować zaznaczenie, jednym z obsługiwanych rozwiązań jest opakowanie edytora AppKit w NSViewRepresentable.
Apple opisuje NSTextView jako widok edycji zapewniający możliwości TextKit. Nie trzeba budować własnego mechanizmu układu tylko po to, by zachować położenie kursora. Wybierz dedykowany widok tekstu, gdy potrzebujesz bezpośrednio kontrolować jego stan edycji.
Opakowanie powinno realizować aktualizacje w następującej kolejności:
- Utwórz edytor AppKit w
makeNSView(context:)i połącz jego delegata za pomocą koordynatora. - Przekazuj zmiany tekstu dokonane przez użytkownika do powiązanego modelu, a zmiany zaznaczenia do stanu przypisanego do ID elementu.
- W
updateNSViewporównaj tekst modelu z bieżącym tekstem edytora przed przypisaniem jakiejkolwiek wartości. - W przypadku faktycznej podmiany programowej oblicz nowe zaznaczenie na podstawie tekstu wynikowego i zastosuj je raz.
- Pomijaj informacje zwrotne delegata wywołane aktualizacją aplikacji i uwzględnij tekst oznaczony podczas edycji przed zastosowaniem odroczonych przekształceń.
Są to reguły implementacji mostu, a nie drugi kompletny przykład kodu. Apple udostępnia selectedRanges do odczytu i zapisu zaznaczeń widoku tekstu, a textViewDidChangeSelection(_:) do odbierania zmian zaznaczenia. Jeśli edytor obsługuje wiele zakresów, zachowuj je wszystkie.
W przypadku aktualizacji uwzględniających tworzenie tekstu złożonego hasMarkedText() informuje, czy obecny jest tekst oznaczony. Praktyczna zasada polega na odroczeniu nieistotnych przekształceń aplikacji w trakcie tworzenia tekstu złożonego i zastosowaniu ich po jego zakończeniu.
W przypadku NSTextField z AppKit trzeba też uwzględnić inny aspekt cyklu życia: Apple dokumentuje, że proste elementy sterujące współdzielą z oknem edytor pól. Zapisane zaznaczenie przypisz do elementu i aktywnej sesji edycji, zamiast traktować ten współdzielony edytor jako trwały obiekt przypisany do każdego wiersza.
Pozostań w granicach udokumentowanej integracji. SwiftUI kontroluje układ reprezentowanego widoku, a Apple przestrzega przed bezpośrednim przypisywaniem zarządzanej przez niego ramki lub granic. Unikaj też założeń dotyczących wewnętrznej implementacji listy w SwiftUI: w informacjach o wydaniu macOS 15 Apple podaje, że listy bez struktury drzewa nie używają już NSTableView.
Sprawdź zachowanie edytora we własnym oknie
Użyj przykładowych etykiet, by osobno sprawdzić poszczególne zachowania. Poniższe kroki służą do weryfikacji; nie są opisem wyników testów.
- Ustaw kursor między
Bluea następującą po nim spacją. Wpiszsmall. Sprawdź, czy pisanie jest kontynuowane w oczekiwanym miejscu. - Zaznacz
tilei zastąp je ciągiemsquare. Sprawdź, czy zmiana obejmuje tylko zaznaczony tekst. - Podczas edycji wywołaj polecenie
Add labelskrótemCommand-Shift-N. Osobno sprawdź aktywny element, fokus i położenie kursora. - Wywołaj polecenie
Reverse rowsskrótemCommand-Shift-R. Sprawdź, czy edytowana wartość nadal należy do pierwotnego ID elementu, a następnie skontroluj fokus i zaznaczenie. - W rzeczywistym interfejsie przetestuj usuwanie i ponowne wyświetlanie wiersza oraz programową podmianę tekstu zgodnie z wybraną zasadą przywracania stanu.
Kliknięcie przycisku może zmienić fokus. Odróżnij przejście fokusu spowodowane interakcją od resetu zaznaczenia wywołanego aktualizacją modelu. Przetestuj dokładnie te elementy sterujące i skróty klawiaturowe, które udostępnia aplikacja.
Zacznij od stabilnych ID i bezpośredniego powiązania tekstu. Dodawaj jawne przywracanie stanu tylko dla przejść, które tego wymagają, i określaj sposób zaznaczania przy programowej podmianie tekstu. Dzięki temu zwykła edycja etykiet pozostaje prosta, a ograniczenia związane z usuwaniem wierszy i podmianą tekstu są jasno określone.
Źródła
Demystify SwiftUI — Apple Developer.
ForEach — dokumentacja Apple dla programistów.
id(_:) — dokumentacja Apple dla programistów.
FocusState — dokumentacja Apple dla programistów.
TextSelection — dokumentacja Apple dla programistów.
init(_:text:selection:prompt:axis:) — dokumentacja Apple dla programistów.
TextField — dokumentacja Apple dla programistów.
macOS Sequoia 15 Release Notes — dokumentacja Apple dla programistów.
Strings and Characters — The Swift Programming Language.
NSViewRepresentable — dokumentacja Apple dla programistów.
TextKit — dokumentacja Apple dla programistów.
selectedRanges — dokumentacja Apple dla programistów.
textViewDidChangeSelection(_:) — dokumentacja Apple dla programistów.
hasMarkedText() — dokumentacja Apple dla programistów.
fieldEditor(_:for:) — dokumentacja Apple dla programistów.