~17 min · universal-ios, uikit, uitextview, korean-input, swiftui
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"UITextView의 한글 조합은 일찍, 실제 기기에서 테스트해야 해."
앱은 SwiftUI로, 글자는 UIKit으로
가족 iOS 앱은 SwiftUI 앱이고, 대부분의 화면은 그걸로 끝이야. 예외는 Mac과 같아. 글자가 곧 제품일 때. 글쓰기 편집기의 아이패드와 아이폰 앱은 유니버설 SwiftUI 앱인데, 편집기 부분만 UIViewRepresentable로 감싼 UITextView야. SwiftUI의 TextEditor가 주는 건 문자열 바인딩이고, 문서 편집기엔 그 아래 층이 필요해. 제목을 꾸미고 마크업을 본문 안에서 바로 숨길 텍스트 저장소, 입력기의 조합 중인 글자, 시스템 찾기와 바꾸기, 서식 패널 같은 사용자 입력 뷰, 그리고 선택 영역을 정밀하게 다루는 기능.
음절은 아직 글자가 아니야
한글은 자모를 조합해서 입력해. ㅎ, ㅏ, ㄴ을 누르는 동안 입력기는 "한"을 밑줄 그은 임시 조합 글자로 들고 있다가, 음절이 끝나야 확정해. 그동안 markedTextRange는 nil이 아니야. 꾸미기 다시 적용, SwiftUI에서 흘러 들어온 모델 값, 서식 명령, 접기처럼 그 사이에 텍스트 뷰 내용을 다시 쓰는 건 전부 음절을 일찍 확정하거나 깨뜨릴 수 있어. 그래서 편집기의 규칙은 기계적이야. 텍스트 저장소를 바꾸는 모든 경로가 먼저 markedTextRange를 확인하고, 조합 중이면 기다려. 모델은 바뀔 때마다 따라가도 되고, 꾸미기와 교체는 조합이 끝날 때까지 기다려.
한국어 키보드 시뮬레이터에 Mac 키보드로 쳐서는 이걸 증명할 수 없어. ASCII가 알아서 한글 자모로 바뀌거든. 그래서 편집기의 확인 목록엔 하드웨어 키보드를 단 아이패드와 아이폰에서 한글을 길게 치는 게 실제 기기 검사로 올라가 있어.
하드웨어 키보드도 앱의 일부야
키보드를 단 아이패드는 작은 Mac처럼 쓰여. 버튼에 붙인 SwiftUI keyboardShortcut은 Command 키를 누르고 있으면 보이는 명령을 등록해. 편집기는 ⌘N과 ⌘T를 새 노트에(⌘T는 Mac 앱을 따른 거야), ⌘S를 저장에, ⌘R을 동기화에 할당해. 서식 명령(⌘B, ⌘I, ⌘K)도 Mac 도구 막대와 같은 공유 명령 코드로 돌아.
옆 동네 SwiftUI 함정: 닿을 수 없는 필드
여행 일지 작성 화면에서 선택 사항인 "어디서?" 필드는 노트 필드에 포커스가 있을 때만 나타났어. 멀쩡하게 그려졌는데 입력은 하나도 안 받았어. 누르면 포커스가 노트 필드를 떠나고, 그러면 포커스 상태가 false가 되고, 그 탭을 배달했어야 할 바로 그 이벤트에서 행이 사라졌거든. 탭 자체가 일으킨 상태 변화로 없어지는 뷰는 절대 누를 수 없어. 점점 드러나는 행은 내용이나, 그 묶음의 모든 필드를 덮는 포커스 값 하나에 걸어. 이 버그는 테스트를 전부 통과했어. 테스트는 포커스를 옮겨보지 않았고, 담기도 멀쩡히 저장됐거든. 늘 장소만 빠진 채로 말이야.
Code
조합 중엔 절대 다시 쓰거나 꾸미지 않는 UITextView 편집기·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)
}
}
}
포커스에 따라 나타나는 행은 닿을 수 없어. 내용이나 포커스 값 하나에 걸어·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)
}
#으로 시작하는 줄을 전부 굵게 만드는 style 클로저를 붙여서 SparkMobile에 NoteEditor를 넣어. 한국어 키보드를 켠 실제 아이폰이나 아이패드에서 한글로 제목을 쳐서 음절이 정상적으로 조합되는지 확인해. 이어서 textViewDidChange에서 markedTextRange 확인을 빼고 같은 제목을 쳐서 뭐가 깨지는지 설명해. 확인을 되돌리고, 키보드를 단 아이패드의 Command 키 오버레이에 보이는 ⌘N과 ⌘S 명령을 더해.
Hint
시뮬레이터밖에 없으면 Mac 키보드로 치기 전에 시뮬레이터 키보드 목록을 영어로 바꾸고, 한글 테스트는 실제 기기에서 돌릴 때까지 끝나지 않은 항목으로 남겨둬. 글자가 아니라 속성만 바꾸는 꾸미기도 텍스트 저장소를 바꾸는 거야.
Progress
Progress is local-only — sign in to sync across devices.