Bygg en Mac-redigerare genom att behålla godkänd text i den överordnade vyn och redigera ett separat utkast i ett ark. Endast Commit kopierar tillbaka utkastet; Cancel stänger det. Den här handledningen implementerar det flödet för en liten anteckning i oformaterad text i din egen app.
Definiera vad Commit och Cancel ska göra
Läsarens uppgift är enkel: öppna en befintlig anteckning, revidera flera rader och antingen godkänna hela revideringen eller låta originalet vara orört. Exemplet använder syntetisk text och en enda redigeringssession. Godkänd text behålls i minnet; att lägga till fillagring är ett separat beslut för appen.
Apples TextEditor visar flerradig, rullningsbar text och tar emot en bindning till värdet som redigeras. En bindning till det godkända värdet skulle därför göra att redigeringar ändrar värdet direkt. Om godkännandet ska skjutas upp binder du redigeraren till ett utkast i stället. Se Apples dokumentation för TextEditor.
Utformningen har tre delar: godkänd text som ägs av den överordnade vyn, en ögonblicksbild som skapas när redigeringen börjar och ett föränderligt utkasttillstånd som ägs av arket. Swift-strängar har värdesemantik, så när utkastet ändras förändras inte den godkända strängen. Lagring med kopiering vid skrivning är en implementeringsoptimering; beteendet med oberoende värden är detsamma. Se String.
| Händelse | Utkastets beteende | Godkänd text |
|---|---|---|
| Öppna redigeraren | Börjar med den aktuella godkända texten | Oförändrad |
| Skriva eller ta bort text | Ändras i arket | Oförändrad |
Välja Commit |
Skickas till den överordnade vyn, sedan stängs arket | Ersätts med utkastet |
Välja Cancel |
Arket stängs utan att skicka tillbaka utkastet | Oförändrad |
| Öppna redigeraren igen | En ny session startar | Tillhandahåller den nya starttexten |
Tabellen är också implementationskontraktet. Att stänga arket innebär inte i sig att ändringarna godkänns. Om den skillnaden hålls tydlig fungerar avbrytning utan någon återställningsåtgärd.
Skapa det kompletta macOS-exemplet
Skapa ett macOS-approjekt i Xcode med SwiftUI och ange macOS 12 eller senare som distributionsmål. Ersätt de genererade app- och innehållsvydeklarationerna med följande enda källfil och ta bort den gamla appdeklarationen så att målet innehåller exakt en @main-ingångspunkt. Exemplet använder redigering av oformaterad text i stället för den nyare initialiseraren för attributerad text.
Minimikravet macOS 12 beror på arkets DismissAction; TextEditor för oformaterad text finns från macOS 11. Apple dokumenterar dessa tillgänglighetsgränser i DismissAction och TextEditor.
import SwiftUI
import Foundation
@main
struct DraftEditorApp: App {
var body: some Scene {
WindowGroup {
NoteView()
}
}
}
private struct EditSession: Identifiable {
let id = UUID()
let initialText: String
}
private struct NoteView: View {
@State private var acceptedText = """
Sample note
Arrange the blue tiles beside the green tiles.
"""
@State private var editSession: EditSession?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Accepted note")
.font(.headline)
ScrollView {
Text(acceptedText.isEmpty ? "(Empty note)" : acceptedText)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button("Edit Note") {
editSession = EditSession(initialText: acceptedText)
}
}
.padding(20)
.frame(minWidth: 460, minHeight: 300)
.sheet(item: $editSession) { session in
DraftEditor(initialText: session.initialText) { newText in
acceptedText = newText
}
.id(session.id)
}
}
}
private struct DraftEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var draft: String
private let initialText: String
private let onCommit: (String) -> Void
init(
initialText: String,
onCommit: @escaping (String) -> Void
) {
self.initialText = initialText
self.onCommit = onCommit
_draft = State(initialValue: initialText)
}
private var hasChanges: Bool {
draft != initialText
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit note")
.font(.headline)
TextEditor(text: $draft)
.font(.body)
.accessibilityLabel("Note draft")
.frame(minHeight: 220)
Text(hasChanges ? "Draft changed" : "No changes")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Commit") {
onCommit(draft)
dismiss()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding(20)
.frame(minWidth: 480, minHeight: 340)
}
}
Det här är ett komplett appexempel med starttext, presentationstillstånd, redigerare och ingångspunkt. En Swift-kompilator och ett macOS-SDK fanns inte tillgängliga i forskningsmiljön, så exemplet kontrollerades mot den hämtade API-dokumentationen i stället för att kompileras eller köras här.
Varför den överordnade vyn äger det godkända värdet
Den överordnade vyn äger acceptedText eftersom den visar det godkända resultatet och tillhandahåller startvärdet för nästa redigeringssession. Undervyn äger draft eftersom det bara är redigeringsgränssnittet som behöver ändra det. Det följer Apples vägledning om att hålla tillstånd privat och placera det på lämplig nivå i vyhierarkin. Se State.
Den överordnade vyn skickar inte $acceptedText till redigeraren. I stället skickar den ett strängvärde och en avslutning. Avslutningen innehåller den enda tilldelningen som godkänner en revidering: acceptedText = newText. I den här implementationen kan varken skrivande eller avbrytning nå den tilldelningen.
Varför varje öppning får en sessionsidentifierare
Den valfria EditSession innehåller både presentationstillstånd och starttext. Apple dokumenterar att sheet(item:onDismiss:content:) visar innehåll när det bundna objektet inte är nil och skickar objektet till innehållsavslutningen. Se sheet(item:onDismiss:content:).
Varje tryck på Edit Note skapar en ny identifierare. När den identifieraren tillämpas på redigeraren blir den avsedda livslängden för tillståndet tydlig: ett utkast hör till en redigeringssession. Initialiseraren anger det första tillståndsvärdet; den är inte ett sätt att kontinuerligt synkronisera ett befintligt utkast med senare ändringar i den överordnade vyn.
För den här uppgiften är beteendet med en ögonblicksbild önskvärt. En delvis redigerad anteckning ska inte plötsligt återställas för att någon orelaterad del av den överordnade vyn uppdateras. När redigeraren öppnas igen skapas ett nytt utkast från den text som senast godkändes.
Gör stängning och tangentbordsbeteende genomtänkta
Läs @Environment(\.dismiss) i den presenterade redigeraren, som i exemplet. Apple förklarar att åtgärden gäller miljön där den deklareras. Om den läses i den presenterande rotvyn kan åtgärden riktas mot vyns fönster i stället för arket. Se DismissAction.
Knappen Cancel stänger helt enkelt arket. Utkastet kan försvinna eftersom det aldrig har ändrat det godkända värdet. Knappen Commit anropar först godkännandeavslutningen och stänger sedan arket. Flytta inte godkännandet till onDismiss: återanropet beskriver att arket stängs, vilket också sker när någon avbryter.
Exemplet tilldelar Escape till Cancel med .cancelAction, Apples dokumenterade standardgenväg för avbrytning. Command-Retur tilldelas Commit med en uttrycklig tangentmotsvarighet och modifierare. Det här är genvägar för kontroller i appens gränssnitt. Se cancelAction och keyboardShortcut(_:modifiers:).
Vanlig Retur är fortfarande tillgänglig för redigering av flera rader eftersom exemplet inte tilldelar .defaultAction till Commit-knappen. Apple definierar .defaultAction som Retur utan modifierare. Command-Retur är ett redaktionellt val för det här flerradiga gränssnittet, inte ett påstående om att alla Mac-redigerare bör använda det. Se defaultAction.
Använd unika genvägar i det aktuella gränssnittet. Apple dokumenterar att när flera kontroller har samma genväg används den första matchande kontrollen som hittas under uppslagningen. Testa genvägarna medan insättningspunkten är i redigeraren och prova även att klicka på de synliga knapparna. Se keyboardShortcut(_:modifiers:).
Kontrollera gränsen för godkännande med en kort testsekvens
Gör följande kontroller i den körande Mac-appen. De testar beteendet som skiljer en utkastredigerare från en direktredigerare, inklusive fallet med en tom sträng som är lätt att missa.
- Öppna
Edit Note, ersätt den andra raden medOrdna de gröna brickorna i en rad.och klicka påCancel. Den godkända anteckningen ska behålla sin ursprungliga andra rad. - Öppna redigeraren igen. Den ska innehålla den ursprungliga godkända texten, utan den avbrutna ändringen.
- Gör samma ändring och klicka på
Commit. Den godkända anteckningen ska nu visa den ändrade raden. - Öppna redigeraren igen, lägg till en radbrytning och använd Command-Retur. Hela flerradiga utkastet ska bli den godkända texten.
- Öppna redigeraren igen, gör en ändring och testa Escape medan du redigerar. Kontrollera att avbrytningen inte ändrar den godkända texten.
- Öppna redigeraren igen, ta bort all text och klicka på
Commit. Den överordnade vyn ska visa(Empty note). När redigeraren öppnas igen ska den vara tom.
Tom text är giltig i det här exemplet. Implementationen tar inte bort omgivande blanksteg eller radbrytningar och inaktiverar inte godkännande för en tom sträng. Om din egen anteckningstyp kräver innehåll lägger du till en synlig valideringsregel och tillämpar den både i commit-åtgärden och på knappens aktiverade tillstånd.
Jämförelsen hasChanges styr endast statusetiketten. Om Commit förblir aktiverad utan ändringar blir godkännandet en ofarlig tilldelning av samma värde. Om du senare inaktiverar knappen när inget har ändrats ska Cancel fortfarande vara tillgänglig så att arket alltid har en tydlig utväg.
Veta när SwiftUI räcker och när AppKit hjälper
För den här uppgiften med oformaterad text ger TextEditor redan den redigeringsyta som behövs. Varken anpassad textlayout eller en AppKit-brygga behövs för att skilja utkaständringar från godkänd text. Den uppdelningen hör till appens tillståndsdesign.
Aktuell SwiftUI stöder också en TextEditor-initialiserare för attributerade strängar, tillgänglig från macOS 26. Därför kräver inte rik text automatiskt att SwiftUI ersätts. Däremot behöver utkastets typ ändras och det nyare distributionskravet följas. Apple dokumenterar även begränsningar för konvertering av formateringsattribut i den redigeraren. Se init(text:selection:).
Om du behöver AppKit-specifikt redigeringsbeteende eller direkt kontroll över textlayouten är den rekommenderade integrationsvägen en NSTextView som bäddas in via NSViewRepresentable. Apple beskriver representerbara metoder för att skapa, uppdatera och montera ned en AppKit-vy, samt en koordinator för att skicka delegerade ändringar tillbaka till SwiftUI. Se NSViewRepresentable.
Behåll samma godkännandekontrakt i den bryggan: ändringar i AppKit-redigeraren uppdaterar utkastet, och endast den uttryckliga commit-åtgärden uppdaterar den godkända modellen. Som en implementeringsåtgärd bör du undvika att ersätta hela textvyns innehåll vid varje SwiftUI-uppdatering; skilj inkommande ändringar från redigerarens egna utgående ändringar.
TextKit har ytterligare en gräns: NSTextView exponerar modern layout via textLayoutManager och äldre layout via layoutManager. Apple dokumenterar att åtkomst till det senare på macOS 12 eller senare försätter textvyn i kompatibilitetsläge med NSLayoutManager. Välj layoutmotor med omsorg när du lägger till layoutfunktioner. Se TextKit och NSTextView.
Exemplets commit-åtgärd är synkron och sker i minnet. Om du utökar den så att ett dokument skrivs till disk behöver du definiera om godkännande innebär att modellen uppdateras eller att skrivningen slutförs. Om dokumentet måste skrivas innan arket stängs ska arket förbli öppet om skrivningen misslyckas, och utkastet ska bevaras för ett nytt försök. Om samma anteckning kan ändras via ett annat gränssnitt som ägs av appen medan redigering pågår lägger du till en versionskontroll innan du ersätter den.
Grundutformningen är fortfarande liten: skapa ett utkast per redigeringssession, låt redigeraren ändra utkastet och ge godkännandet en enda uttrycklig väg. Med den gränsen på plats förblir avbrytning förutsägbar även när redigeringsytan växer.
Källor
- TextEditor — Apple
- String — Apple
- State — Apple
- sheet(item:onDismiss:content:) — Apple
- DismissAction — Apple
- cancelAction — Apple
- defaultAction — Apple
- keyboardShortcut(_:modifiers:) — Apple
- init(text:selection:) — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- NSTextView — Apple