SwiftUIでTextEditorのカーソル位置と半角カタカナ変換の処理でハマった

はじめに

SwiftUIのTextEditorは非常に使いやすいコンポーネントですが、カーソル位置の制御を必要とする場合はいくつかの問題があります。

今回は、TextEditor内の一部のテキストを半角カタカナに変換する処理を作成する過程で遭遇した2つの問題とその解決策をご紹介します。

問題点と解決策

問題1: カーソル位置のズレ

最初に遭遇した問題は、カーソル位置(currentCursorPosition)がずれることでした。この問題は、半角カタカナに濁音が付いた場合に顕著に現れます。

解決策

string.countではなく、string.utf16.countcurrentCursorPositionに与えることで解決しました。この変更は、絵文字などの多バイト文字を扱う場合にも有用です。

問題2: カーソルがテキストの末尾に移動

inputTextcurrentCursorPositionを同時に更新する際に、時々inputTextの再設定が後に実行され、カーソルがテキストの末尾に移動してしまいます。

※おそらくこれはテキストとカーソル位置を同時に更新するときにだけ発生する問題だと思います。

解決策

DispatchQueue.main.asyncだけでは不十分だったため、DispatchQueue.main.asyncAfterで約50ミリ秒待つことで、この問題は解決しました。

コード例(一部抜粋)

ViewModel

ViewModelでは、半角カタカナへの変換処理と、カーソル位置の管理を行います。

    @Published var inputText: String
    @Published var currentCursorPosition: NSRange
    @Published var markedTextRange: NSRange?

    // 現在選択中の文字列を半角カタカナに変換する
   func replaceHankaku() {
 
        // 編集中ならmarkedTextRange、何か選択中ならcurrentCursorPositionを使う.
        let nsRange = markedTextRange ?? currentCursorPosition

        guard let range = Range<String.Index>(nsRange, in: inputText) else { return }

        let transformed = String(inputText[range]).transformKatakana(type: .halfWidthKatakana)
        inputText.replaceSubrange(range, with: transformed)

        // transformed.countだと ガ とかの濁音付きの半角カタカナとかでカーソル位置がずれるのでutf16.countを使う.
        let newPosition = nsRange.location + transformed.utf16.count
        // textの更新のタイミングとかぶるとcursorの位置がtextの末尾になってしまうのでasyncする.
        // 普通にasyncしただけだと時々textのセットのほうがあとになるようでカーソルが末尾にジャンプしてしまうので50ms待っている.
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
            self?.currentCursorPosition = NSRange(location: newPosition, length: 0)
        }
    }

TextEditorのModifier

TextEditorにはカーソル位置を直接制御する機能が標準で提供されていないため、独自のModifierを作成しました。

import Combine
import SwiftUI
import SwiftUIIntrospect

extension View {
    func delegate(
        text: Binding<String>,
        cursorPosition: Binding<NSRange>,
        markedTextRange: Binding<NSRange?>
    ) -> some View {
        modifier(
            TextEditorCursorModifier(
                text: text,
                cursorPosition: cursorPosition,
                markedTextRange: markedTextRange
            )
        )
    }
}

/// SwiftUIのTextEditorはcursorの制御ができないため外部から制御できるようにするためのModifier.
/// UITextViewのdelegateを上書きするとTextEditor(text: Binding<Text>)のBindingのsetが呼ばれないので
/// このModifierでも Binding<Text> を受け取ってsetするようにしている.
/// textのgetは見ていないのでTextEditorに直接与えてください.
struct TextEditorCursorModifier: ViewModifier {
    /// getでは使われず、setだけされます.
    @Binding var text: String
    /// getとset両方機能します.
    @Binding var cursorPosition: NSRange

    // setだけされます.
    @Binding var markedTextRange: NSRange?

    private let delegate = Delegate()
    @State private var textView: UITextView?

    func body(content: Content) -> some View {
        content
            .introspect(.textEditor, on: .iOS(.v16, .v17)) { textView in
                textView.delegate = delegate
                // 警告が出るのでasyncしている
                DispatchQueue.main.async {
                    textView.selectedRange = cursorPosition
                    self.textView = textView
                }
            }
            .onReceive(delegate.didChangeText) {
                text = $0
            }
            .onReceive(delegate.didChangeSelection) {
                cursorPosition = $0
            }
            .onReceive(delegate.didChangeMarkedTextRange) {
                markedTextRange = $0
            }
            .onChange(of: cursorPosition) { range in
                guard let textView else {
                    return
                }
                textView.selectedRange = range
            }
    }
}

extension TextEditorCursorModifier {
    final class Delegate: NSObject, UITextViewDelegate {
        private let textViewDidChangeSubject = PassthroughSubject<String, Never>()
        var didChangeText: AnyPublisher<String, Never> {
            textViewDidChangeSubject.eraseToAnyPublisher()
        }

        private let textViewDidChangeSelectionSubject = PassthroughSubject<NSRange, Never>()
        var didChangeSelection: AnyPublisher<NSRange, Never> {
            textViewDidChangeSelectionSubject.eraseToAnyPublisher()
        }

        private let textViewDidChangeMarkedTextRangeSubject = PassthroughSubject<NSRange?, Never>()
        var didChangeMarkedTextRange: AnyPublisher<NSRange?, Never> {
            textViewDidChangeMarkedTextRangeSubject.removeDuplicates().eraseToAnyPublisher()
        }

        func textViewDidChange(_ textView: UITextView) {
            textViewDidChangeSubject.send(textView.text)
            textViewDidChangeMarkedTextRangeSubject.send(
                textView.markedTextRange.map { .init(textRange: $0, in: textView) }
            )
        }

        func textViewDidChangeSelection(_ textView: UITextView) {
            textViewDidChangeSelectionSubject.send(textView.selectedRange)
            textViewDidChangeMarkedTextRangeSubject.send(
                textView.markedTextRange.map { .init(textRange: $0, in: textView) }
            )
        }
    }
}

View

Viewでは、作成したModifierを適用しています。

     TextEditor(text: statusTextBinding)
                    .delegate(
                        text: $viewModel.inputText,
                        cursorPosition: $viewModel.currentCursorPosition,
                        markedTextRange: $viewModel.markedTextRange
                    )

まとめ

今回紹介した2つの問題が同時に発生したため再現や原因の特定に時間がかかり結構大変でした...

私はSwiftUIのTextEditorにはカーソル位置を変更する方法が標準で組み込まれていないため、自前で操作している箇所に無理が生じていると考えています。UIViewRepresentableで包んだUITextViewを使ったほうが良いのかもしれません...

これらが誰かの役に立てば幸いです。