SwiftUIでテキストエディタ?

おはようございます。watura です。

iOS noteアプリのエディタをよりよいものにできないものかと最近調べています。この記事は、調べてみたけど難しいねぇ。すぐできる気はぜんぜんしないね!という記事になります。

現在のエディタ構成としては、この記事が参考になります。

解決したい問題

  • StackViewにいれているため段落数がおおいnote記事を編集できない

    • メモリがあふれてしまいアプリがクラッシュする

  • Undo/Redoができない

    • 別記事にもしましたが、中途半端なものがあるという状態

  • 複数ブロックをまたぐ選択ができない

注意:この記事は解決したぜ!waiwaiという記事ではなくSwiftUIでできるのかな?試してみるか?っていう軽い気持ちの記事です。

SwiftUIでテキスト編集

SwiftUIでテキスト編集する方法として、次の3つがあります。

  • TextField

  • TextEditor

  • UIViewRepresentableでUITextViewなどをくるむ

TextFieldはUITextFieldに相当するものでテキストエディタで使えるようなものではありません。なので、UITextViewを使うか、それをバックエンドに持っているらしいTextEditorを使うというふうになります。

さて、TextEditorのドキュメントを見るとInitializerが init(text: Binding<String>)しかありません。
そうです。Stringしか受け付けられないのです。ということは、noteのようなWYSWYGエディタでは使えません。
長文を扱える唯一のSwiftUIのViewであるTextEditorは残念なことにプレーンテキストしか扱えないのです。
したがって、SwiftUIを中心としたものにnoteエディタを置き換えることは現時点では難しいです。今後、もし、TextEditorでAttributedStringが扱えるようになったとしても、後方互換性あるよ!iOS 17でもつかえるよ!ってならない限りは数年使えない機能となってしまいますしSwiftUIごりごりでエディタ選択肢はなさそうです。
ちなみに、9万文字くらいの文字列をいれたところ、スクロールの位置が吹っ飛んでいったり、もっさり、だったりしたのでただAttributedStringが使えるようになっても使えない子のままな可能性が高いです。

UIViewRepresentableでUITextViewをくるむ

周りのほとんどがUIViewControllerな世界で、UIKitのクラスをSwiftUIでわざわざやる理由?ないね。

ないんですけど、今後のためにやってみたいんでやってみました。

まず、noteの記事はテキストのみでもかなりの文字数に対応しています。ヘルプページをざっと眺めても文字数はわからなかったのですが、10万文字以上には対応していると思われます。
なので、10万文字くらいのテキストを使ってテストしていってみたいと思います。

現noteエディタでも単一ブロック(段落)に10万文字いれるとかだったら無理ではないくらいのパフォーマンスで編集できました。
複数ブロックのパフォーマンスは、残念なことに1000ブロック(段落)くらいコピペした下書き記事を開こうとしたら落ちました。

以下のテストは基本的にSwiftUIでUIViewRepresentableでくるんで試しています。

単一のUITextViewに全部いれる ーその1ー

複数ブロックをまたぐ選択が簡単にはできないという点を考えると複数のUITextViewをたくさん使うのではなく、単独で出来ると一番いいよね!となります。ただ、ドラッグアンドドロップで並べ替えたりというの考えると、単一UITextViewは微妙かもしれませんが。

シンプルにUITextViewをUIViewRepresentableにいれて試してみました。
結果は、圧倒的にパフォーマンスが悪く救いがない感じでした。
SwiftUIにいれたことが原因なのか、それともなにか別の原因があるのかはわからないですが、使い物になりませんでした。
1万文字くらいならぎりぎりかなぁというくらいだったら、ぎりぎりたえられるかな?という位でした。

複数のUITextViewを並べる

さて、一つのUITextViewにいれてみてだめだったのなら,複数のUITextViewに分割しましょう。今のnoteエディタみたいな感じですね。
ざっくりとですが,こういう感じでLazyVStackにいれてみました。TextViewWrapperは単一のときにつかったものと同じものを使っています。

ScrollView {
	LazyVStack {
	  Header()
		ForEach {
			TextViewWrapper()
		}
		Footer()
	}
}

このとき、UITextViewのサイズがうまく調整されず、改行されなくなってしまったりする問題があったのですが、Wrapperの中に以下のコードをいれて対応しました。
このコードはUITextViewのサイズを計算するためのメソッドになります。


func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context _: Context) -> CGSize? {
    let width = proposal.width ?? uiView.frame.width
    let sizeThatFits = CGSize(width: width, height: .greatestFiniteMagnitude)
    let size = uiView.sizeThatFits(sizeThatFits)
    return CGSize(width: width, height: max(minHeight, size.height))
}

proposed widthとUITextViewの高さを使って、Viewの高さを決定させています。そのさい、余白を取り込んでいきたいので,minHeightとかを設定したりもしています。

肝心のパフォーマンスですが、かなりいい感じです。メモリの消費量もそれほどではなく、文章の編集もさくさくうごきます。
さらに、onDragとonDropを設定してあげれば、微妙に今とUIが違ってしまうのですが,段落ごとの並べ替えもサクサク出来るようになります。
9万段落のテストでもさっくさくで動いていました。
ただし、なんかカーソルの動きが変だったりしたので、もしこの方法を採用するならば調査が必要です。

単一のUITextViewに全部いれる ーその2ー

現行のエディタであれば10万文字でも編集ことを考えると、こんなにも編集できないのにはなにか理由があるはずです。もちろんSwiftUIでくるんでしまっているので,そこにオーバーヘッドがあるからだよ!という可能性もあります。それでもあまりに遅すぎるので調査してみました。

どうも、TextKit 2には長文を表示したときにパフォーマンスの問題があるようです。というわけで、TextKit 1を使うようにしてみました。

let textView = UITextView(usingTextLayoutManager: false)

また、allowsNonContiguousLayout というオプションがあり、これをtrueにするとパフォーマンスがよくなるという情報があったのでこちらも有効にして見ました。

textView.layoutManager.allowsNonContiguousLayout = true

false時の動作としては、上から順にグリフの高さ座標を計算していくという動きをするようです。trueにすると上から順ではなく必要な箇所を処理していく動作になるようです。
これらの設定をいれたところ、分割法に比べたら多少ラグがあるものの、十分実用性がある動きになりました。
TextKit 2ではこの動作がデフォルトなようですが、パフォーマンスがものすごく悪いのはなんか別な原因がきっとあるんでしょうね。。。

まとめ

ざっくりnoteエディタをSwiftUIで置き換えられるか検証するぞ!の気分ではじめた調査でしたが,やはり、SwiftUIではできないようでした。

UIKitのみに絞っても、TextKit 2は長文を扱うさいのパフォーマンスに懸念があり、まだまだTextKit 1の時代は続くな。という感じでした。

URLや画像などの埋め込みを考えると,今のnoteエディタのようにList系に複数のUITextViewをいれて使うようにすれば,パフォーマンス面でも、drag & drop 対応などの拡張を含めても容易であると考えられます。
ただ、この方法をとった場合は全文選択やUndo/Redoという点では問題が残るので悩ましいところです。

結論としては、

  • 文章だけでいいならば、UITextViewをTextKit1で使うのが幸せそう

  • 画像などの埋め込みとかをいろいろいれるなら分割するのがよさそう

というかんじになりました。そして、noteエディタとしては埋め込みや並べ替えが大切な要素なので、分割するのがよさそうだなと思いました。

いつか、本当に時間が有り余っていたら,SwiftUIでnoteエディタを作り直したいですね。。。

あと、先日公開したRubyの実装方法もエディタがUITextViewなのだから、表示もUITextViewにすると整合性がとれてすごくいいのでは?と感じています。

その他

こういうライブラリをつかうとSwiftUIの裏側でうごいているUIKitにアクセスできます。これをつかえば、TextEditorでNSAttributedStringも?と思わなくもないですが、あまりにもリスキーなので出来るかどうかの確認もしませんでした。いつか時間があればやってもいいのかな?と思ってはいます。