SwiftUIでカーソル位置を取得したり、設定して移動させたりする方法
現時点ではSwiftUIだけでカーソル位置の操作はできないようです。
今回の記事に関するソースコードはこちらのMySwiftUISamples/MySwiftUISamples/UITextViewContainer/
をみてください。
UIViewRepresentableとCoordinator
さて、色々と調べた結果、以下を真似するところから始まりました。
もともとは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)
}
}
ディスカッション
コメント一覧
まだ、コメントがありません