チャットアプリではもはや当然のデザインとなっている「一定の高さまで可変」のテキスト入力欄ですが、SwiftUIでTextEditorが登場するまでは各自でUIViewRepresentable
を実装する方法しかありませんでした。 しかし、TextEditor はSwiftUIで複数行のテキスト入力ができます。今回はこの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のパディングがあるのでAutoResizingTextEditor
のwidth
はUIScreen.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のイニシャライザの引数に設定する必要がある。この場合はwidth
とfont
だが、lineSpacing
も変更したい場合はそれも追加しなくてはならない。またこれらは通常の変数として与えられているが、fontやwidth、lineSpacing
がアプリ動作中に変更される場合はBindingとして渡す必要がある。今回は文字数がまさにアプリ動作中に変更されるものでかつ高さ計算に必要な因子なのでBinding変数として渡している。また最低限width
は外側から変数として渡してもいいと思うが、font
やlineSpacing
はメッセージアプリ程度であれば引数として渡す必要もないという判断もできる。widthに関していうとGeometryReaderを使えば親から渡す必要がなくなるかもしれないが、それはできない。
Q: GeometryReader
からwidthを取り出せばwidth
を引数からもらう必要はないのではないか?
A:できない(今の範囲では)。理由としてはGeometryReaderはそのViewがとりうる最大のサイズを提供するのでその時点でサイズが最大限に広がってしまう。SwiftUIのサイズの決定手順は子Viewから必要なサイズを親に渡し親が限られたサイズから可能なサイズを子Viewに返すという手順を取るためだ。それを防止するために親Viewの中にあるSpacerのlayoutPriorityを上げる(SpacerがどこにレイアウトされているかはTL;DR内を参照)こともできるがそうすると今度はSpacerが目一杯にサイズを取るため、高さを計算した結果をTextEditorのサイズに反映されなくなってしまう。
考えること | YES | NO |
親Viewが子Viewに高さ計算に必要な情報を持っているか? | 親が子のイニシャライザに引数として設定する | 子Viewが内部的に定数として持つ |
与えられた引数はアプリ起動中に変化するか? | その引数を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から引数で与えられるwidth
とfont
がある。それとは別にTextEditorを囲んでいるパディング(innerPaddingと命名した)として8pt、それからサイドに若干の幅(6pt * 2 = 12pt)を差し引いている。この「若干」の幅はTextEditor自身が持っていると推察できるので追加した。これを差し引かないと計算が合わなくなり、テキストが数文字折り返しても高さが大きくならず、差し引く量が多すぎるとテキストが折り返しまで入力される前に高さが大きくなる。
.usesLineFragmentOrigin
は複数行に渡るテキストのサイズを割り出す際に使うオプションでこれがないと1行のテキストとしてみなされてしまうため追加した。また、Font
とUIFont
は対になっているが変換することができないのでそれ用の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())
}
}
以上。