When using SwiftUI's TextField with axis: .vertical or TextEditor, you may encounter a bug where Korean characters are separated into their constituent Jamos (e.g., typing '김밥' results in 'ㄱㅣㅁ밥').
This occurs because SwiftUI's state updates during text entry can interrupt the IME (Input Method Editor) composition process, committing the intermediate Jamo characters prematurely before they can be combined into a full syllable block.
We use UIKit's UITextView wrapped in UIViewRepresentable. This approach:
- Handles Korean IME composition correctly
- Provides focus change callbacks for keyboard scroll handling
- Integrates with DesignSystem theming
Location: Packages/DesignSystem/Sources/DesignSystem/Components/Controls/DSMultilineTextField.swift
public struct DSMultilineTextField: UIViewRepresentable {
@Binding var text: String
let font: UIFont
let textColor: UIColor
var onFocusChange: ((Bool) -> Void)?
// Key implementation detail: only update text when NOT first responder
// to avoid interrupting IME composition
public func updateUIView(_ uiView: UITextView, context: Context) {
if !uiView.isFirstResponder && uiView.text != text {
uiView.text = text
}
}
}Location: Packages/DesignSystem/Sources/DesignSystem/Components/Controls/DSTextArea.swift
public struct DSTextArea: View {
@Environment(\.dsTheme) private var theme
@Binding var text: String
let placeholder: String
let minHeight: CGFloat
var onFocusChange: ((Bool) -> Void)?
}When using DSTextArea inside a ScrollView, wrap with ScrollViewReader for automatic scroll-to-visible behavior:
struct MyView: View {
@State private var text: String = ""
@State private var focusedField: Field?
enum Field: Hashable {
case textInput
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack {
// Other content...
DSTextArea(
text: $text,
placeholder: "Enter text...",
minHeight: 120,
onFocusChange: { isFocused in
if isFocused {
focusedField = .textInput
}
}
)
.id(Field.textInput)
// More content...
}
}
.scrollDismissesKeyboard(.interactively)
.onChange(of: focusedField) { _, newValue in
if let field = newValue {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(field, anchor: .center)
}
}
}
}
}
}
}- IME Safety:
updateUIViewonly updates text when!uiView.isFirstResponderto prevent interrupting composition - Focus Callback:
onFocusChangeenables scroll-to-visible behavior - Timing: 0.15s delay allows keyboard animation to start before scrolling
- Theme Integration: Uses
@Environment(\.dsTheme)for consistent styling