Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

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)
}

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.