SwiftUI 高さ可変のTextEditorを作る

チャットアプリではもはや当然のデザインとなっている「一定の高さまで可変」のテキスト入力欄ですが、SwiftUIでTextEditorが登場するまでは各自でUIViewRepresentableを実装する方法しかありませんでした。 しかし、TextEditor はSwiftUIで複数行のテキスト入力ができます。今回はこのTextEditorのテキストの量に応じて高さ可変にするものを作ります。最終的な成果物は以下になります。

一定の高さになるまでTextEditor の高さが大きくなります。

本題に入る前にいくつか実装方法があるのでそのリンクを貼っておきます。

実装方法概要Link
TextEditorの裏にTextを配置するTextEditorが監視しているtextをTextに渡す。Textは可変長なので文字量に応じて高さが増し、その上に乗っているTextEditorの高さも大きくなる。Textの文字色は透明にしておくことでユーザーからは見えない。Mimicking behavior of iMessage with TextEditor for text entry
UIViewRepresentableを使う(TextEditorを使用しない)UITextViewをラップしたUIViewRepresentableを作り、UITextViewDelegate.textViewDidChangeで取得した最新のcontentSizeを監視する。その高さを親Viewに渡し、親Viewはその子Viewのframeを変更する。
❌ 文字が空だと高さが0になるためテキストビューが見えなくなってしまう。(リンク先では空文字の際にplaceholderを入れて回避)
SwiftUI 2.0 Auto Sizing TextField With Input Accessory View (Done Button) – SwiftUI Tutorials
String.boundingRectで文字量に応じたサイズを取得する(この記事でやること)TextEditorに渡すtextからboundingRectを使用して高さを算出し、frameを変更する。

TL;DR

以下のコードをコピー&ペーストする。

import SwiftUI
import UIKit
private let innerPadding:CGFloat = 8
struct AutoResizingTextEditor: View {
    init(
        text: Binding<String>,
        font: Font = .body,
        width: CGFloat = UIScreen.main.bounds.width,
        maxHeight: CGFloat = 100
    ) {
        self._text = text
        self.font = font
        self.width = width
        self.maxHeight = maxHeight
    }
    @Binding var text: String
    private let font: Font
    private let width: CGFloat
    private let maxHeight: CGFloat
    
    private var height: CGFloat {
        let height = text.boundingRect(
            with: .init(width: width - innerPadding * 2 - 12, height: UIScreen.main.bounds.height),
            options: .usesLineFragmentOrigin,
            attributes: [.font: UIFont.preferredFont(forTextStyle: font.uiFontTextStyle)],
            context: nil)
            .height
        let paddingHeight = height + innerPadding * 2
        return paddingHeight < maxHeight ? paddingHeight : maxHeight
    }
    
    var body: some View {
        TextEditor(text: $text)
            .font(font)
            .frame(height: height)
            .allowsTightening(false)
            .padding(innerPadding)
            .background(Color.white)
            .cornerRadius(innerPadding)
    }
}
struct AutoResizingTextEditor_Previews: PreviewProvider {
    static var previews: some View {
        AutoResizingTextEditor(
            text: .init(
                get: { "" },
                set: { _ in }
            ),
            width: UIScreen.main.bounds.width,
            maxHeight: 100
        )
    }
}
extension AutoResizingTextEditor {
    
    func font(_ font: Font) -> AutoResizingTextEditor {
        .init(text: $text, font: font, width: width, maxHeight: maxHeight)
    }
    
    func frame(width: CGFloat, maxHeight: CGFloat) -> AutoResizingTextEditor {
        .init(text: $text, font: font, width: width, maxHeight: maxHeight)
    }
}
extension Font {
    var uiFontTextStyle: UIFont.TextStyle {
        switch self {
        case .largeTitle:
            return .largeTitle
        case .title:
            return .title1
        case .title2:
            return .title2
        case .title3:
            return .title3
        case .headline:
            return .headline
        case .subheadline:
            return .subheadline
        case .body:
            return .body
        case .callout:
            return .callout
        case .footnote:
            return .footnote
        case .caption:
            return .caption1
        case .caption2:
            return .caption2
        default:
            fatalError()
        }
    }
}

使い方

iOSのメッセージアプリのように下側に固定して使う場合はSpacer()を使う。以下の例では左右に16ptのパディングがあるのでAutoResizingTextEditorwidthUIScreen.main.bounds.widthから32pt差し引く。maxHeightは100ptを設定したので、それ以上の高さにはならない。

import SwiftUI
private let padding: CGFloat = 16.0
struct ContentView: View {
    @State var text: String = ""
    var body: some View {
        VStack {
            Spacer()
            AutoResizingTextEditor(text: $text)
            .font(.largeTitle)
            .frame(width: UIScreen.main.bounds.width - padding * 2, maxHeight: 100) // 左右32.0pt のパディングを差し引く
            .padding(padding) // 上下左右に16.0pt のパディング
        }
        .background(Color.red.ignoresSafeArea())
    }
}

設計

SwiftUIでは親Viewが内包している Sub View を更新することはできるがSub Viewが自分自身を更新することはできない。そのため高さを可変にしようとした場合は自分ではなく自分の親Viewに更新してもらう必要がある。

上記の図はTextEditorの高さを親Viewから更新する流れを説明している。TextEditorはBinding<String>を引数に取り、ユーザーがテキストを入力するとその変更を親Viewに通知する。親Viewはこれを以ってbody配下のSub View( = TextEditor)を更新する。その際に親ViewがboundingRectメソッドを使用して適切な高さを計算しTextEditorのframe(ViewModifierの一つ。高さと幅を設定する。)に設定するというものである。コードにすると以下の通りになる。

import SwiftUI
struct ContentView: View {
    @State var text: String = ""
    private let font: Font = .body
    private let maxHeight = 100
    private var height: CGFloat {
        let height = text.boundingRect(
            with: .init(width: UIScreen.main.bounds.width, height: .infinity),
            options: .usesLineFragmentOrigin,
            attributes: [.font: UIFont.preferredFont(forTextStyle: .body)],
            context: nil)
            .height
        return height < maxHeight ? height : maxHeight
    }
    var body: some View {
        VStack {
            Spacer()
            TextEditor(text: $text)
                .font(font)
                .frame(height: height)
        }
    }
}

上記が最小限の構成になるが、実際にはコンポーネント化して親Viewの仕事を少なくしたい。具体的には以下のようなView(AutoResizingTextEditorと呼ぶことにする)をもう一枚挟み、高さ計算のロジックを隠蔽する。

イニシャライザ

struct AutoResizingTextEditor: View {
    init(
        text: Binding<String>,
        font: Font = .body,
        width: CGFloat = UIScreen.main.bounds.width,
        maxHeight: CGFloat
    ) {
        self._text = text
        self.font = font
        self.width = width
        self.maxHeight = maxHeight
    }
    @Binding var text: String
    private let font: Font
    private let width: CGFloat
    private let maxHeight: CGFloat
    
}

高さの計算に必要なものを外側から与えたい場合はそのAutoResizingTextEditorのイニシャライザの引数に設定する必要がある。この場合はwidthfontだが、lineSpacingも変更したい場合はそれも追加しなくてはならない。またこれらは通常の変数として与えられているが、fontやwidth、lineSpacingがアプリ動作中に変更される場合はBindingとして渡す必要がある。今回は文字数がまさにアプリ動作中に変更されるものでかつ高さ計算に必要な因子なのでBinding変数として渡している。また最低限widthは外側から変数として渡してもいいと思うが、fontlineSpacingはメッセージアプリ程度であれば引数として渡す必要もないという判断もできる。widthに関していうとGeometryReaderを使えば親から渡す必要がなくなるかもしれないが、それはできない。

Q: GeometryReaderからwidthを取り出せばwidthを引数からもらう必要はないのではないか?
A:できない(今の範囲では)。理由としてはGeometryReaderはそのViewがとりうる最大のサイズを提供するのでその時点でサイズが最大限に広がってしまう。SwiftUIのサイズの決定手順は子Viewから必要なサイズを親に渡し親が限られたサイズから可能なサイズを子Viewに返すという手順を取るためだ。それを防止するために親Viewの中にあるSpacerのlayoutPriorityを上げる(SpacerがどこにレイアウトされているかはTL;DR内を参照)こともできるがそうすると今度はSpacerが目一杯にサイズを取るため、高さを計算した結果をTextEditorのサイズに反映されなくなってしまう。

考えることYESNO
親Viewが子Viewに高さ計算に必要な情報を持っているか?親が子のイニシャライザに引数として設定する子Viewが内部的に定数として持つ
与えられた引数はアプリ起動中に変化するか?その引数をBindingで渡すその引数を普通の変数で渡す
Binding, 変数あるいは内部変数を取るかの方針

高さ計算

private let innerPadding:CGFloat = 8
struct AutoResizingTextEditor: View {
     中略 
    private var height: CGFloat {
        let height = text.boundingRect(
            with: .init(width: width - innerPadding * 2 - 12, height: UIScreen.main.bounds.height),
            options: .usesLineFragmentOrigin,
            attributes: [.font: UIFont.preferredFont(forTextStyle: font.uiFontTextStyle)],
            context: nil)
            .height
        let paddingHeight = height + innerPadding * 2
        return paddingHeight < maxHeight ? paddingHeight : maxHeight
    }
    
    var body: some View {
        TextEditor(text: $text)
            .font(font)
            .frame(height: height)
            .allowsTightening(false)
            .padding(innerPadding)
            .background(Color.white)
            .cornerRadius(innerPadding)
    }
}

boundingRectに必要な情報として親Viewから引数で与えられるwidthfontがある。それとは別にTextEditorを囲んでいるパディング(innerPaddingと命名した)として8pt、それからサイドに若干の幅(6pt * 2 = 12pt)を差し引いている。この「若干」の幅はTextEditor自身が持っていると推察できるので追加した。これを差し引かないと計算が合わなくなり、テキストが数文字折り返しても高さが大きくならず、差し引く量が多すぎるとテキストが折り返しまで入力される前に高さが大きくなる。

.usesLineFragmentOriginは複数行に渡るテキストのサイズを割り出す際に使うオプションでこれがないと1行のテキストとしてみなされてしまうため追加した。また、FontUIFontは対になっているが変換することができないのでそれ用のextensionを追加し、Attributeの一つとして設定した。以下の通り。

extension Font {
    var uiFontTextStyle: UIFont.TextStyle {
        switch self {
        case .largeTitle:
            return .largeTitle
        case .title:
            return .title1
        case .title2:
            return .title2
        case .title3:
            return .title3
        case .headline:
            return .headline
        case .subheadline:
            return .subheadline
        case .body:
            return .body
        case .callout:
            return .callout
        case .footnote:
            return .footnote
        case .caption:
            return .caption1
        case .caption2:
            return .caption2
        default:
            fatalError()
        }
    }
}

最後のlet paddingHeight = height + innerPadding * 2は、カーソル表示のために付けている。この値が小さいとテキスト削除時や入力開始時にカーソルが上に若干せり上がってしまう。

糖衣構文

以上の実装で基本的には使うことができるが、イニシャライザの引数が多いのでSwiftUIらしくない、というのは以下の呼び出し側のコードを見ればわかる。

import SwiftUI
private let padding: CGFloat = 16.0
struct ContentView: View {
    @State var text: String = ""
    var body: some View {
        VStack {
            Spacer()
            AutoResizingTextEditor(
                text: $text,
                font: .largeTitle
                width: Screen.main.bounds.width - padding * 2,
                maxHeight: 100
            )
            .padding(padding) // 上下左右に16.0pt のパディング
        }
        .background(Color.red.ignoresSafeArea())
    }
}

そのため以下のような糖衣構文的なメソッドを用意して簡素にしていく。

extension AutoResizingTextEditor {
    
    func font(_ font: Font) -> AutoResizingTextEditor {
        .init(text: $text, font: font, width: width, maxHeight: maxHeight)
    }
    
    func frame(width: CGFloat, maxHeight: CGFloat) -> AutoResizingTextEditor {
        .init(text: $text, font: font, width: width, maxHeight: maxHeight)
    }
}
import SwiftUI
private let padding: CGFloat = 16.0
struct ContentView: View {
    @State var text: String = ""
    var body: some View {
        VStack {
            Spacer()
            AutoResizingTextEditor(text: $text)
            .font(.largeTitle)
            .frame(width: UIScreen.main.bounds.width - padding * 2, maxHeight: 100) // 左右32.0pt のパディングを差し引く
            .padding(padding) // 上下左右に16.0pt のパディング
        }
        .background(Color.red.ignoresSafeArea())
    }
}

以上。



Posted

in