Where UIKit Is Still Needed: Text, Composition, and the Hardware Keyboard
~17 min · universal-ios, uikit, uitextview, korean-input, swiftui
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Korean composition in UITextView must be tested early, on hardware."
SwiftUI for the App, UIKit for the Text
The family's iOS apps are SwiftUI apps, and for most screens that is the whole story. The exception is the same one the Mac has: when text is the product. The prose editor's iPad and iPhone app is a universal SwiftUI app whose editor is a UITextView wrapped in UIViewRepresentable. SwiftUI's TextEditor gives you a string binding. A document editor needs what sits underneath it: the text storage to style headings and hide markup in place, the marked text of an input method, the system find-and-replace interaction, custom input views like a formatting panel, and precise control of the selection.
A Syllable Is Not a Character Yet
Korean is typed by composition. While you press ㅎ, ㅏ and ㄴ, the input method holds "한" as marked text, underlined and provisional, and only commits it when the syllable is finished. During that time markedTextRange is non-nil. Anything that rewrites the text view's contents in that window, whether a restyle, a model value flowing back from SwiftUI, a formatting command or a fold, can commit the syllable early or break it. So the rule in the editor is mechanical: every path that changes the text storage checks markedTextRange first and waits. The model may still follow each change as it happens; styling and replacement wait for the composition to end.
This cannot be proven in the Simulator by typing on a Mac keyboard into a Korean-keyboard Simulator, which turns ASCII into Korean letters on its own. The editor's acceptance list therefore names long Korean typing on an iPad with a hardware keyboard, and on an iPhone, as hardware checks.
The Hardware Keyboard Is Part of the App
An iPad with a keyboard is used like a small Mac. SwiftUI's keyboardShortcut on a button registers a command that appears when the Command key is held; the editor gives ⌘N and ⌘T to a new note (⌘T following the Mac app), ⌘S to save and ⌘R to sync, and its formatting commands (⌘B, ⌘I, ⌘K) run through the same shared command code as the Mac's toolbar.
A Neighbouring SwiftUI Trap: The Field You Cannot Reach
In the travel journal's composer, an optional "where?" field appeared only while the note field was focused. It rendered correctly and accepted no input at all. Tapping it moved focus away from the note field, which set the focus state to false, which removed the row in the same event that would have delivered the tap. Any view removed by a state change that the tap itself causes is unreachable. Gate a progressive-disclosure row on content, or on one focus value that covers every field in the group. It survived a full green suite, because tests do not exercise focus and the capture saved fine, just always without a place.
Code
A UITextView editor that never rewrites or restyles under active composition·swift
import SwiftUI
import UIKit
/// A document editor needs UITextView: marked text for Korean composition, TextKit, find, and
/// hardware-keyboard behaviour that SwiftUI's TextEditor does not expose.
struct NoteEditor: UIViewRepresentable {
@Binding var text: String
var style: @MainActor (UITextView) -> Void
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.isFindInteractionEnabled = true // the system find and replace bar
view.text = text
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
// A syllable is being composed: replacing the text now would commit or break it.
guard view.markedTextRange == nil, view.text != text else { return }
let selection = view.selectedRange
view.text = text
view.selectedRange = NSRange(location: min(selection.location, (text as NSString).length), length: 0)
style(view)
}
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
@MainActor
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NoteEditor
init(parent: NoteEditor) { self.parent = parent }
func textViewDidChange(_ view: UITextView) {
parent.text = view.text // the model follows every change
guard view.markedTextRange == nil else { return } // styling waits for the syllable
parent.style(view)
}
}
}
/// Hardware-keyboard commands that show in the iPad's Command-key overlay.
struct EditorCommands: View {
let newNote: () -> Void
let save: () -> Void
var body: some View {
HStack {
Button("New Note", action: newNote).keyboardShortcut("n", modifiers: .command)
Button("Save", action: save).keyboardShortcut("s", modifiers: .command)
}
}
}
A disclosure row gated on focus is unreachable; gate on content or one focus value·swift
// Unreachable: tapping PlaceField moves focus away from the note field, which removes the row
// in the same event that would have delivered the tap.
if writingNote { PlaceField(place: $place) }
// Reachable: gate on content, or on ONE focus value that covers every field in the group.
enum ComposerField: Hashable { case note, place }
@FocusState private var focus: ComposerField?
if focus != nil || !note.isEmpty || !place.isEmpty {
PlaceField(place: $place).focused($focus, equals: .place)
}
Put NoteEditor in SparkMobile with a style closure that bolds every line starting with #. On a real iPhone or iPad with a Korean keyboard, type a heading in Korean and confirm syllables compose normally. Then remove the markedTextRange guard from textViewDidChange, type the same heading, and describe what breaks. Restore the guard, and add ⌘N and ⌘S commands that you can see in the Command-key overlay on an iPad with a keyboard.
Hint
If you only have the Simulator, switch its keyboard list to English before typing through the Mac keyboard, and treat the Korean test as still open until it runs on hardware. Styling that only changes attributes, not characters, is still a change to the text storage.
Progress
Progress is local-only — sign in to sync across devices.