はじめに
SwiftUIは、宣言的な記述で簡単にUIを構築できます。
しかし、UIKitと比べるとまだまだカスタマイズ性が低かったり、不具合があったりと言う部分が散見されます。
今回は、SwiftUIのTextEditorでの複数行入力を実装した際に出会った不具合と、それを乗り越えるための試行錯誤について紹介します。
環境
- OS: macOS Sonoma 14.7.1
- XCode: 16.2.0
SwiftUIの複数行入力を攻略する
TextEditor内部での高さ計算の不具合?
以下のような単純なViewを考えます。
グレーの背景にTextEdtor
を置いただけですね。
struct ContentView: View {
@State var text: String = ""
var body: some View {
ZStack {
Color.gray
TextEditor(text: $text)
.frame(height: 200)
}
}
}
実は、このTextEditor
、何度も改行しているとおかしな挙動をします。
playgroundなどで再現できますので、試してみてください。
何度も改行して行くと、カーソルが画面外となってしまいました!
スクロールもうまくいかず、内部の高さ計算が誤っているかのような挙動をしていますね
このままでは、複数行入力を持つアプリをSwiftUIで作れません。
回避策を考えましょう。
iOS16+ならTextFieldが複数行入力に対応している
TextField
で目的の挙動が表現できるなら、この方法が良いと思います。
iOS16から、TextField
にaxis
というパラメタが設定できるようになっています。
入力前はテキスト1行分の高さで、入力内容に合わせて高さが変化する挙動をします。
TextField(
"プレースホルダ",
text: $text,
axis: .vertical
)
高さを指定したい場合は、lineLimit
を併用するとよいです。
reservesSpace
というパラメタがiOS16から利用でき、lineLimit分だけ高さを確保してくれます。
TextField(
"プレースホルダ",
text: $text,
axis: .vertical
)
.lineLimit(10, reservesSpace: true)
しかし、欠点もいくつか……
TextField
はframe
モディファイアが効きません。
例えば、初期状態で2行以上の高さで、その後入力内容に合わせて高さを高くしていくというような挙動が表現できません。
コメントにて指摘いただきました通り、lineLimit
モディファイアはPartialRangeFrom<Int>
を取ることができるため、上記の挙動は実現可能でした。
その他、ClosedRange<Int>
やPartialRangeThrough<Int>
とることができ、柔軟な範囲指定に対応しているようです。
https://developer.apple.com/documentation/swiftui/view/linelimit(_:)-251ko
やはりTextEditor
使った対応策を考える必要がありそうです。
TextEditor in ScrollView
次に、TextEditor
内部のスクロールを利用せず、ScrollView
内にTextEditor
を配置する方法を考えました。
TextEditor
内のスクロールをscrollDisabled
モディファイアで止めています。
ただし、この方法は不具合を含みます。
TextEditor
のwidthに収まらない文字列を1行に書くと、TextEditor
の高さが大きくならず、カーソルが画面外になってしまいます
その他、ScrollView
の高さにTextEditor
の高さが追従しなかったり、改行のタイミングで不具合と思われる挙動をすることがありました。
ScrollView {
TextEditor(text: $text)
.scrllDisabled(true)
}
TextEditorの高さ計算にTextViewを用いる
TextEditor in ScrollViewの方針は、高さの計算をTextEditor
が担っていることが不具合を生んでいそうです。
そこで、TextEditor
の高さ計算をTextView
に任せてることを考えてみました。
この方法は現状不具合なく動いているのでおすすめです。
foregroundStyle
モディファイアで文字色を透明にしたTextView
を配置し、その背景にTextEditor
を置いています。
TextView
の高さを元にTextEditorのframeが計算されるため、frame
モディファイアを利用すれば自由に高さを制御できます。
TextEditor
とTextView
ではinsetなどに差があるため、padding
やoffset
を利用して調整しています。
ZStack {
Color.gray
ScrollView {
HStack(spacing: 0) {
Text(text.isEmpty ? " " : text)
Spacer()
}
.padding(.leading, 5)
.padding(.vertical, 12)
.allowsHitTesting(false)
.foregroundStyle(Color.clear)
.background {
TextEditor(text: $text)
.offset(y: 4)
}
}
}
defaultScrollAnchorを活用する
この方法を利用すると、ユーザーの操作に大きく影響する不具合を回避することができました。
ただ、1点気になる挙動があります。
表示領域いっぱいまで文字を入力してから改行したとき、TextEditor
では改行後の行まで自動でスクロールしてくれます。
しかし、この方法ではスクロールをScrollViewに頼っているため、改行時にカーソルが画面外になってしまいます。
iOS17では、これを解決するdefaultScrollAnchor
モディファイアが登場し、iOS18では設定がより細かくできるようになりました。
例えば、以下のようにすることで改行時のスクロールが下に張り付くようになります
ZStack {
Color.gray
ScrollView {
HStack(spacing: 0) {
Text(text.isEmpty ? " " : text)
Spacer()
}
.padding(.leading, 5)
.padding(.vertical, 12)
.allowsHitTesting(false)
.foregroundStyle(Color.clear)
.background {
TextEditor(text: $text)
.offset(y: 4)
}
}
.defaultScrollAnchor(.top, for: .initialOffset)
.defaultScrollAnchor(.top, for: .alignment)
.defaultScrollAnchor(.bottom, for: .sizeChanges)
}
終わりに
SwiftUIのTextEditorを利用して出会った、不具合や試行錯誤でした。
間違っている箇所や改善案、補足などありましたらコメントで教えてくださると嬉しいです。