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

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

はじめに

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などで再現できますので、試してみてください。

TextEditor_sample.gif

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

iOS16+ならTextFieldが複数行入力に対応している

TextFieldで目的の挙動が表現できるなら、この方法が良いと思います。

iOS16から、TextFieldaxisというパラメタが設定できるようになっています。 入力前はテキスト1行分の高さで、入力内容に合わせて高さが変化する挙動をします。

TextField(
    "プレースホルダ",
    text: $text,
    axis: .vertical
)

高さを指定したい場合は、lineLimitを併用するとよいです。 reservesSpaceというパラメタがiOS16から利用でき、lineLimit分だけ高さを確保してくれます。

TextField(
    "プレースホルダ",
    text: $text,
    axis: .vertical
)
.lineLimit(10, reservesSpace: true)

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

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の高さが追従しなかったり、改行のタイミングで不具合と思われる挙動をすることがありました。

ScrollView {
    TextEditor(text: $text)
        .scrllDisabled(true)
}

TextEditorの高さ計算にTextViewを用いる

TextEditor in ScrollViewの方針は、高さの計算をTextEditorが担っていることが不具合を生んでいそうです。 そこで、TextEditorの高さ計算をTextViewに任せてることを考えてみました。 この方法は現状不具合なく動いているのでおすすめです。

foregroundStyleモディファイアで文字色を透明にしたTextViewを配置し、その背景にTextEditorを置いています。 TextViewの高さを元にTextEditorのframeが計算されるため、frameモディファイアを利用すれば自由に高さを制御できます。 TextEditorTextViewではinsetなどに差があるため、paddingoffsetを利用して調整しています。

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を利用して出会った、不具合や試行錯誤でした。 間違っている箇所や改善案、補足などありましたらコメントで教えてくださると嬉しいです。