SwiftUIの複数行入力を攻略する

## はじめに
SwiftUIは、宣言的な記述で簡単にUIを構築できます。
しかし、UIKitと比べるとまだまだカスタマイズ性が低かったり、不具合があったりと言う部分が散見されます。
今回は、SwiftUIのTextEditorでの複数行入力を実装した際に出会った不具合と、それを乗り越えるための試行錯誤について紹介します。

## 環境
- OS: macOS Sonoma 14.7.1
- XCode: 16.2.0

## SwiftUIの複数行入力を攻略する

### TextEditor内部での高さ計算の不具合?
以下のような単純なViewを考えます。
グレーの背景に`TextEdtor`を置いただけですね。
```Swift
struct ContentView: View {
    @State var text: String = ""
    var body: some View {
        ZStack {
            Color.gray
            TextEditor(text: $text)
                .frame(height: 200)
        }
    }
}
```

実は、この`TextEditor`、何度も改行しているとおかしな挙動をします。
playgroundなどで再現できますので、試してみてください。

![TextEditor_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751443/93e9898f-c9c0-7a1b-f815-2d3ba75ab2c9.gif)

何度も改行して行くと、カーソルが画面外となってしまいました!
スクロールもうまくいかず、内部の高さ計算が誤っているかのような挙動をしていますね
このままでは、複数行入力を持つアプリをSwiftUIで作れません。
回避策を考えましょう。

### iOS16+ならTextFieldが複数行入力に対応している
`TextField`で目的の挙動が表現できるなら、この方法が良いと思います。

iOS16から、`TextField`に`axis`というパラメタが設定できるようになっています。
入力前はテキスト1行分の高さで、入力内容に合わせて高さが変化する挙動をします。
```Swift
TextField(
    "プレースホルダ",
    text: $text,
    axis: .vertical
)
```

高さを指定したい場合は、`lineLimit`を併用するとよいです。
`reservesSpace`というパラメタがiOS16から利用でき、lineLimit分だけ高さを確保してくれます。
```Swift
TextField(
    "プレースホルダ",
    text: $text,
    axis: .vertical
)
.lineLimit(10, reservesSpace: true)
```

しかし、欠点もいくつか……
`TextField`は`frame`モディファイアが効きません。
~~例えば、初期状態で2行以上の高さで、その後入力内容に合わせて高さを高くしていくというような挙動が表現できません。~~

:::note 
コメントにて指摘いただきました通り、`lineLimit`モディファイアは`PartialRangeFrom<Int>`を取ることができます。
その他、`ClosedRange<Int>`や`PartialRangeThrough<Int>`とることができ、柔軟な範囲指定に対応しているようです。
これによって、「初期状態で2行以上の高さで、その後入力内容に合わせて高さを高くしていく挙動」は実現可能です。
https://developer.apple.com/documentation/swiftui/view/linelimit(_:)-251ko

なお、`reservesSpace`引数を取れるのはlimit引数が`Int`を取っている場合のみです。
`PartialrangeFrom<Int>`や`ClosedRange<Int>`を取る場合には、`reservesSpace`を指定しなくても下限分の高さを確保してくれます
https://developer.apple.com/documentation/swiftui/view/linelimit(_:reservesspace:)
:::


やはり`TextEditor`使った対応策を考える必要がありそうです。

### TextEditor in ScrollView
次に、`TextEditor`内部のスクロールを利用せず、`ScrollView`内に`TextEditor`を配置する方法を考えました。
`TextEditor`内のスクロールを`scrollDisabled`モディファイアで止めています。
ただし、この方法は不具合を含みます。
`TextEditor`のwidthに収まらない文字列を1行に書くと、`TextEditor`の高さが大きくならず、カーソルが画面外になってしまいます
その他、`ScrollView`の高さに`TextEditor`の高さが追従しなかったり、改行のタイミングで不具合と思われる挙動をすることがありました。
```Swift
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`を利用して調整しています。
```Swift
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では設定がより細かくできるようになりました。
例えば、以下のようにすることで改行時のスクロールが下に張り付くようになります
```Swift
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を利用して出会った、不具合や試行錯誤でした。
間違っている箇所や改善案、補足などありましたらコメントで教えてくださると嬉しいです。