SwiftUIでカーソル位置を取得したり、設定して移動させたりする方法

2022年5月19日

現時点ではSwiftUIだけでカーソル位置の操作はできないようです。

今回の記事に関するソースコードはこちらMySwiftUISamples/MySwiftUISamples/UITextViewContainer/
をみてください。

UIViewRepresentableとCoordinator

さて、色々と調べた結果、以下を真似するところから始まりました。
もともとはTextFieldだけでなんとかしたかったんですけどね。

https://stackoverflow.com/questions/58136743/how-can-you-move-the-cursor-to-the-end-in-a-swiftui-textfield

UIViewRepresentableを使ってUIKitにあるUITextFieldを使えばカーソル位置を変更できることがわかりました。

ただ参考にしたところだと、UITextFieldを使っていたんですが、複数行に対応させるためにUITextViewを使いました。
残念ながらこちらの解答では、入力中の最後の位置(通常の入力だとこうなるはずですが、ちょい特殊なことをしていたから明示的に指定する必要があったみたい)にカーソルを移動させることがゴールだったので、自分が欲しい情報をすべて得ることはできませんでした。

ここで勉強になったのはCoordinatorを使ってUITextFieldのイベントをフックすることができるという部分です。回答にあった以下の部分です。textFieldDidChangeでカーソルを一番最後に変更する処理がはいっています。こいつをうまい具合に改造すれば、現在のカーソル位置を取得したり、カーソル位置をボタンで変更したりできるような気がしました。

    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: TextFieldContainer

        init(_ textFieldContainer: TextFieldContainer) {
            self.parent = textFieldContainer
        }

        func setup(_ textField:UITextField) {
            textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
        }

        @objc func textFieldDidChange(_ textField: UITextField) {
            self.parent.text.wrappedValue = textField.text ?? ""

            let newPosition = textField.endOfDocument
            textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition)
        }
    }

カーソル位置の取得

カーソル位置の取得は、UITextView.selectedTextRangeでいけるとわかりました。
型がCursorChangeableUITextViewとなっているのは、カーソル位置の設定で必要となるからです。
これについては後述します。

class Coordinator: NSObject, UITextViewDelegate {
        略

        @objc func textViewDidChangeSelection(_ uiTextView: CursorChangeableUITextView) {
            guard let selectedTextRange = uiTextView.selectedTextRange else {
                return
            }
            // selectedTextRange.startはUITextPosition型なのでそこからIntにするにはこうする
            let cursorPosition = uiTextView.offset(from: uiTextView.beginningOfDocument, to: selectedTextRange.start)
            //print(cursorPosition)
            self.parent.cursorPosition = cursorPosition
        }
    }

カーソル位置の設定

ポイントは、CombineのPassthroughSubjectを使ってmakeUIViewで生成されるコントロールの生成時まで遡ってメソッドをコールするというところです。

まずはソース

func makeUIView(context: UIViewRepresentableContext<UITextViewContainer>) -> CursorChangeableUITextView {
        let inneruiTextView = CursorChangeableUITextView(frame: .zero)
        inneruiTextView.font = UIFont.systemFont(ofSize: 22)
        inneruiTextView.text = text
        inneruiTextView.delegate = context.coordinator
        context.coordinator.setup(inneruiTextView)

        // ここはメインスレッドで実行する必要があるようだ
        DispatchQueue.main.async {
            // very important to capture it as a variable, otherwise it'll be short lived.
            self.cancellable = cursorChangeSubject.sink { (value) in
                print("Received: \(value)")

                // こちらでどのメソッドが呼び出されたかをみて実施する
                if (value.type == ApplyTypeEnum.changeCursorPosition) {
                    inneruiTextView.changeCursorPosition(direction: value.direction, offset: value.offset)
                }
            }
        }

        return inneruiTextView
    }

カーソル位置の設定は上記のchangeCursorPositionでやってます。
changeCursorPositionはUITextViewにないので、クラスを継承して関数を定義しました。

class CursorChangeableUITextView: UITextView {
    func changeCursorPosition(direction:UITextLayoutDirection,offset:Int){
        // only if there is a currently selected range
        if let selectedRange = selectedTextRange {
            // and only if the new position is valid
            if let newPosition = position(from: selectedRange.start, in:direction, offset: offset) {
                // set the new position
                selectedTextRange = textRange(from: newPosition, to: newPosition)
            }
        }
    }
}

UIViewRepresentableのmakeUIViewメソッドでコントロールを生成しているわけですが、SwiftUIのViewからそのコントロールのメソッドをどう呼べばいいのかわかりませんでした。
当初考えたのは、以下のようなやり方。でもどうやってchangeCursorPositionを呼び出せばいいかわからなかった。

struct UITextUsageView: View {
    // こんな感じでViewを変数に保持しておいて、これを経由してメソッドをコールできないか??結論、これはうまくいかない
    //private var component = UITextViewContainer(UITextViewContainer)

    var body: some View {
            component
    }

っで、上の方に乗せたソースに戻るんですが、PassthroughSubjectを使って呼び出すことにしました。

実際にViewからは以下のように呼び出しています。
ポイントは、
self.cursorChangeSubject.send(CursorChangeableUIViewContext(type:.changeCursorPosition,direction: .left, offset: 1))
ここですね。

import SwiftUI
import Combine

struct UITextUsageView: View {
    @State var text = ""
    @State var cursorPosition = 0
    private var cursorChangeSubject = PassthroughSubject<CursorChangeableUIViewContext, Never>()
    var body: some View {
        VStack{
            UITextViewContainer(cursorChangeSubject: cursorChangeSubject,text: $text, cursorPosition: $cursorPosition)
                    .frame(height: 300)
                    .font(.title).border(.white, width: 1)
            HStack{
                Button(action:{
                    self.cursorChangeSubject.send(CursorChangeableUIViewContext(type:.changeCursorPosition,direction: .left, offset: 1))
                }){
                    Text("left").frame().background(.white).padding()
                }
                Button(action:{
                    self.cursorChangeSubject.send(CursorChangeableUIViewContext(type:.changeCursorPosition,direction: .right, offset: 1))
                }){
                    Text("right")
                }
            }
            Spacer()
        }
        Text(cursorPosition.description)
    }
}

SwiftUI

Posted by takumioda