Skip to content

Instantly share code, notes, and snippets.

@xissy
Last active December 28, 2025 16:27
Show Gist options
  • Select an option

  • Save xissy/2beaca7d8e625c2629985daf8239f730 to your computer and use it in GitHub Desktop.

Select an option

Save xissy/2beaca7d8e625c2629985daf8239f730 to your computer and use it in GitHub Desktop.
Fixing Korean Text Composition (Jamo Separation) Bug in SwiftUI

Fixing Korean Text Composition (Jamo Separation) Bug in SwiftUI

The Problem

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.

The Solution

We use UIKit's UITextView wrapped in UIViewRepresentable. This approach:

  1. Handles Korean IME composition correctly
  2. Provides focus change callbacks for keyboard scroll handling
  3. Integrates with DesignSystem theming

Implementation

DesignSystem Components

DSMultilineTextField (UIViewRepresentable)

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

DSTextArea (SwiftUI Wrapper)

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

Usage with Keyboard Scroll Support

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

Key Points

  1. IME Safety: updateUIView only updates text when !uiView.isFirstResponder to prevent interrupting composition
  2. Focus Callback: onFocusChange enables scroll-to-visible behavior
  3. Timing: 0.15s delay allows keyboard animation to start before scrolling
  4. Theme Integration: Uses @Environment(\.dsTheme) for consistent styling
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment