こんにちは、 はてなエンジニア Advent Calendar 2024 の12日目です。昨日は id:tomato3713 さんの Goによる独自スクリプトでテストケースを記述するテスト手法紹介 でした。
最近仕事で iOS アプリを作っていて、 SwiftUI の Text の文字列がなぜか省略されてしまうけど原因がよくわからなくて悲しいということがありました。この記事では、 SwiftUI のレイアウトプロセスの理解を深めつつこの問題をデバッグしていこうと思います。
省略されてしまう Text
実際に文字列が省略されてしまう問題が発生した View はけっこう複雑なものだったのですが、問題が起きる状態を維持しつつめちゃくちゃ単純化すると以下のようになります。
struct ContentView: View { var body: some View { VStack(spacing: 0) { Text("あいうえお") VStack(spacing: 0) { Text("か") Text("さしすせそ") } } .frame(maxWidth: 60) } }
この View を Xcode 16.1 でビルドして iOS 18.1 の iPhone 16 Pro のシミュレータで実行すると以下のような表示になります。
ここで さしすせそ が省略されているのはおかしい気がするし困らないでしょうか...? さしすせそ の Text には特に高さの制限をしていないので、もし幅が十分でなければ改行して
さしす せそ
のように表示されてほしいのですが、なぜか無理やり1行で表示されようとして幅が足りずに省略されてしまっています。
試しにこの View の frame(maxWidth: 60) を frame(width: 60) にしてみると、ちゃんと想定通り改行して2行で表示してくれます。
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
Text("あいうえお")
VStack(spacing: 0) {
Text("か")
Text("さしすせそ")
}
}
- .frame(maxWidth: 60)
+ .frame(width: 60)
}
}
frame(width: 60) と frame(maxWidth: 60) で改行されるかされないかの違いが生まれるのは不思議な感じがしますね。frame(maxWidth: 60) でも文字が改行されて表示されてほしい気がするので、なぜ省略されてしまうのかを調査していきたいと思います。
念のためですが、この問題は .frame(maxWidth:) を使うとその中の Text が必ず省略されるというわけではなく、 View 全体の構造、 maxWidth に渡す値、 Text の内容などがかみ合うと発生する可能性があるという話です。
SwiftUI のレイアウトプロセス
そもそも SwiftUI において View の画面上のレイアウトがどのような仕組みで決まるのかを理解していきましょう。この章は理屈が多いですが、次の章でレイアウトプロセスを覗くことができて面白くなると思うのでいったん許してほしいです。
SwiftUI のレイアウトプロセスについては、例えば Thinking in SwiftUI という本の4章にまとまっており、この記事でも大いに参考にしています。
SwiftUI では親子関係にある View 同士が以下のように交渉しながらレイアウトが決まっていきます。
- 親 View が子 View にサイズを提案
- 子 View は提案をもとに自分のサイズを決めて親 View に報告
- 親 View がサイズの報告を受けて子 View の位置を決め、配置する
ちなみに 2 において、もし子 View が自分の子として孫 View を持つ場合は、子 View と孫 View の間でも 1 から 3 のプロセスが再起的に走ることになります。
レイアウトプロセスの簡単な例として
VStack {
Text("Hello")
}
という View があった場合、 VStack が親 View で Text が子 View になるので、
VStackがTextにサイズを提案するVStackが提案するサイズはVStackの親 View がVStackに提案するサイズに依存する。VStackがルートの View なら画面全体のサイズになる
Textが提案されたサイズと自分のコンテンツ(=Hello)やフォントサイズをもとに自身のサイズを決めてVStackに報告するTextからの報告をもとにVStackがTextの位置を決めて配置する
という流れになります。
各 View のレイアウトの振る舞い
SwiftUI のレイアウトプロセスの流れは今見たように統一的かつシンプルなものですが、 View がどうやって自身のサイズを決めたり、どうやって子 View にサイズを提案したり子 View を配置したりするかの方針は View の種類ごとに異なります。つまり、
- 親から同じサイズを提案されても
TextとImageとRectangleが自身がどのようなサイズを取ると報告するかはそれぞれ異なる VStackとZStackとScrollViewが子 View にどうサイズを提案したり、子 View をどう配置したりするかそれぞれ異なる
ということです。
今回は VStack の中の Text が省略されてしまう問題の原因を調査したいので、 Text と VStack の振る舞いにのみ注目します。
Text の振る舞い
Text は基本的には親 View から提案されたサイズに自分を収めて描画しようとします。そのために、もし改行するだけの高さの余裕があれば改行し、余裕がなかったらテキストを省略します。その結果として自分が収まるぴったりのサイズを親に報告しますが、注意点として高さに関しては提案されたものをはみ出したとしても1行分の高さは確保します。
VStack の振る舞い
VStack は複数の子 View を受け取って縦に配置します。 VStack のレイアウトプロセスは大きく2つに分けられます。
1つ目のフェーズとして、複数の子 View をどの順で優先してレイアウトするかを最初に決めます。方針として、子 View の "柔軟性" を測定していき、柔軟性の低い子 View から順にレイアウトしようとします。柔軟性の測定のため、子 View 1つ1つに高さ 0 と高さ無限大をそれぞれ仮に提案し、それに対して子 View が報告してきたサイズの差が大きいほど柔軟であると判断します。
2つ目のフェーズでは実際に子 View を配置していきます。柔軟性の低い子 View から、 VStack に残っているサイズを残っている子 View の個数で割ったものを提案していき、報告されたサイズを残っているサイズから差し引く...ということを繰り返します。例えば VStack が親 View から提案された 高さが 100 で、子 View が4つある場合のことを考えます。まずもっとも柔軟性が低い子 View に高さ 100 / 4 = 25 を提案します。これに子 View が取りたいサイズが高さ 10 と返答した場合、 VStack に残された高さは 100 - 10 = 90 になるので2番目に柔軟性の低い子 View に高さ 90 / 3 = 30 が提案されます。このプロセスを繰り返すことですべての子 View の高さが決まったら、その合計が VStack 自体の高さとして VStack の親 View に報告されることになります。
レイアウトプロセスのデバッグ
ここまで SwiftUI のレイアウトプロセスについて簡単に整理してきました。この知識を問題解決に役立てるために、自分が書いた View のレイアウトプロセスをデバッグして追えるようにしたいです。そのために、 iOS 16 から Layout プロトコルを使うことができます。
Layout プロトコルについて詳しく説明していると日が暮れてしまうのでここでは割愛しますが、簡単にいうと Group や VStack のように子 View を受け取って自身の方針で配置するようなコンテナを自分でカスタマイズして作れる機能だと考えておくとよいと思っています。くわしくは、例えば Compose custom layouts with SwiftUI - WWDC 22 で解説されています。
本来の Layout の目的からははずれるのですが、レイアウトの邪魔をせずにレイアウトプロセスのデバッグだけ行える Layout が Swift Talk #318 - Inspecting SwiftUI's Layout Process で紹介されているので、これを参考にして以下のような Layout を作ります。
struct SizeDebugger: Layout { var label: String func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { assert(subviews.count == 1) print("[\(label)] Receive: \(proposal.string)") let result = subviews[0].sizeThatFits(proposal) print("[\(label)] Report: \(result.string)") return result } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { subviews[0].place(at: bounds.origin, proposal: proposal) } }
Layout プロトコルでは sizeThatFits と placeSubviews メソッドを実装する必要があります。それぞれ
sizeThatFitsは親から提案されたサイズであるproposalをもとに子 View であるsubviewsにサイズを提案し、それに対する子 View からの報告をもとに親 View に自身のサイズを報告するplaceSubviewsは子 View であるsubviewsをbounds内に位置を指定して配置する
を行う必要があります。上記の SizeDebugger の実装を見ると sizeThatFits は唯一の子 View の sizeThatFits を呼んだ結果をそのまま返し、 placeSubviews では子 View を bounds.origin を基準に配置しているだけなのがわかると思います。これはレイアウトとしては何もしていないのと同じであり、レイアウトプロセスに影響を与えないようにしつつ、 sizeThatFits 内の副作用として子 View が提案されたサイズと報告したサイズを print デバッグしています。その出力を見ることで子 View に提案されたサイズと子 View が報告したサイズを知ることができます。
この SizeDebugger を使って VStack と Text のレイアウトプロセスにおける振る舞いが本当に 各 View のレイアウトの振る舞い の通りになっているかを確認してみましょう。長さの違う2つの Text を子としてもつ VStack を考えます。
struct ContentView: View { var body: some View { VStack(spacing: 0) { Text("Long Long Long Long Long Long Text") Text("Short Text") } .frame(width: 200, height: 300) } }
SizeDebugger を以下のように差し込むことで、レイアウト自体は変えないままレイアウトプロセスをデバッグできるようにします。
struct ContentView: View { var body: some View { SizeDebugger(label: "VStack") { VStack(spacing: 0) { SizeDebugger(label: "LongText") { Text("Long Long Long Long Long Long Text") } SizeDebugger(label: "ShortText") { Text("Short Text") } } } .frame(width: 200, height: 300) } }
アプリを実行してみると画面は以下のようになります。
Xcode のコンソールを見ると、レイアウトの様子が SizeDebugger により print されています。実際の出力には含まれませんが、ここでは説明のために行番号をつけています。
1 [VStack] Receive: (200.00, 300.00) 2 [ShortText] Receive: (200.00, 0.00) 3 [ShortText] Report: (78.00, 20.33) 4 [ShortText] Receive: (200.00, inf) 5 [ShortText] Report: (78.00, 20.33) 6 [LongText] Receive: (200.00, 0.00) 7 [LongText] Report: (191.00, 20.33) 8 [LongText] Receive: (200.00, inf) 9 [LongText] Report: (169.00, 42.33) 10 [ShortText] Receive: (200.00, 150.00) 11 [ShortText] Report: (78.00, 20.33) 12 [LongText] Receive: (200.00, 279.67) 13 [LongText] Report: (169.00, 42.33) 14 [VStack] Report: (169.00, 62.67)
1行目では VStack に (200, 300) のサイズが提案されています。これは、 VStack に modifier として .frame(width: 200, height: 300) がつけられているためです。
2行目の [ShortText] Receive: (200.00, 0.00) から9行目の [LongText] Report: (169.00, 42.33) までは VStack による子 View の柔軟性測定フェーズです。 ShortText と LongText それぞれが高さ 0 と無限大を提案されてどのように自身のサイズを報告するかを見ています。 ShortText の方はいずれに対しても同じサイズとして (78.00, 20.33) を返しており、これは提案される高さがなんであろうと自身を1行で描画することを意味しています。一方で、 LongText の方は高さ 0 の提案に対しては (191.00, 20.33) を、高さ無限大の提案に対しては (169.00, 42.33) を報告します。提案される高さによって LongText の報告するサイズがなぜ変わるかは、実際にこのテキストを高さ 0 を指定したパターンと高さをとくに指定しないパターンを描画してみればわかります。
struct ContentView: View { var body: some View { VStack(spacing: 24) { Text("Long Long Long Long Long Long Text") .frame(height: 0) Text("Long Long Long Long Long Long Text") } .frame(width: 200) } }
これを見ると、
- 高さ 0 を提案すると文末を省略して1行でテキストが表示され、これがサイズ
(191.00, 20.33)にあたる - 高さを指定しないと途中で改行して2行で表示され、これがサイズ
(169.00, 42.33)にあたる
ことがわかります。
以上のように、 LongText は提案されたサイズによって自らが取りたいと報告するサイズが変わることから ShortText よりも柔軟性が高いと判断されます。
そのため、10行目からのレイアウトフェーズでは先に柔軟性が低い ShortText の方に VStack が提案された高さである 300.00 の半分の 150.00 が提案されます。続いて11行目にて ShortText が報告した高さ 20.33 を 300.00 から引いた 279.67 が12行目で LongText に提案されます。これが LongText を改行して表示するのに十分な高さであるため、13行目では改行して表示する場合の高さである 42.33 が報告されています。14行目では VStack は2つの子 View の高さを合わせた高さである 20.33 + 42.33 = 62.67 を自身の高さとして報告しています。
以上のように SizeDebugger を使うことで SwiftUI のレイアウトを追うことができます。
省略されてしまう Text をデバッグする
SwiftUI のレイアウトプロセスがデバッグできるようになったので、この記事の冒頭でお話しした Text がなぜか省略されてしまう問題を調査していきましょう。以下の View で さしすせそ が改行されずに省略されてしまうという問題でした。
struct ContentView: View { var body: some View { VStack(spacing: 0) { Text("あいうえお") VStack(spacing: 0) { Text("か") Text("さしすせそ") } } .frame(maxWidth: 60) } }
とはいえ、いきなりこのケースをデバッグするよりも、まずは正常に表示されるパターンのレイアウトプロセスについて知っておきたいです。以下のように maxWidth を width にすると想定通り さしすせそ が改行されるのでした。まずはこの View について調べていきます。
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
Text("あいうえお")
VStack(spacing: 0) {
Text("か")
Text("さしすせそ")
}
}
- .frame(maxWidth: 60)
+ .frame(width: 60)
}
}
調査のために各所に SizeDebugger をはさんでいきます。
struct ContentView: View { var body: some View { SizeDebugger(label: "VStack") { VStack(spacing: 0) { SizeDebugger(label: "ChildText") { Text("あいうえお") } SizeDebugger(label: "ChildVStack") { VStack(spacing: 0) { Text("か") SizeDebugger(label: "GrandChildText") { Text("さしすせそ") } } } } } .frame(width: 60) } }
この状態でアプリを実行すると、以下のようなログが出力されます。
1 [VStack] Receive: (60.00, 778.00) 2 [ChildVStack] Receive: (60.00, 0.00) 3 [GrandChildText] Receive: (60.00, 0.00) 4 [GrandChildText] Report: (48.00, 20.33) 5 [GrandChildText] Receive: (60.00, inf) 6 [GrandChildText] Report: (48.00, 42.33) 7 [ChildVStack] Report: (48.00, 40.67) 8 [ChildVStack] Receive: (60.00, inf) 9 [ChildVStack] Report: (48.00, 62.67) 10 [ChildText] Receive: (60.00, 0.00) 11 [ChildText] Report: (48.00, 20.33) 12 [ChildText] Receive: (60.00, inf) 13 [ChildText] Report: (48.00, 42.33) 14 [ChildVStack] Receive: (60.00, 389.00) 15 [GrandChildText] Receive: (60.00, 368.67) 16 [GrandChildText] Report: (48.00, 42.33) 17 [ChildVStack] Report: (48.00, 62.67) 18 [ChildText] Receive: (60.00, 715.33) 19 [ChildText] Report: (48.00, 42.33) 20 [VStack] Report: (48.00, 105.00)
13行目までは VStack が子 View の柔軟性を測定するフェーズです。2つの子である ChildVStack と ChildText が報告する高さの差を見ると、 ChildVStack の方は 62.67 - 40.67 = 22 、 ChildText の方は 42.33 - 20.33 = 22 と同じになっています。これは小数点以下をもっと増やしてみても変わらずで、どちらも差分は Text 1行分なのでまったく同じ値になっていると思われます。このような場合にどちらの子 View が柔軟と判断されるかがわからないのですが、手元で試した限り VStack と Text が測定の結果同じ柔軟性であった場合には Text の方が柔軟だと扱われていそうです。そのため、14行目以降の配置フェーズではより柔軟性がないと判断された ChildVStack の方からレイアウトされています。
正しく表示されるケースのレイアウトプロセスが追えたところで、 Text が省略されてしまうケースを見るために width を maxWidth に変えてみます。
struct ContentView: View {
var body: some View {
SizeDebugger(label: "VStack") {
VStack(spacing: 0) {
SizeDebugger(label: "ChildText") {
Text("あいうえお")
}
SizeDebugger(label: "ChildVStack") {
VStack(spacing: 0) {
Text("か")
SizeDebugger(label: "GrandChildText") {
Text("さしすせそ")
}
}
}
}
}
- .frame(width: 60)
+ .frame(maxWidth: 60)
}
}
レイアウトプロセスは以下のようになります。
1 [VStack] Receive: (60.00, 778.00) 2 [ChildVStack] Receive: (60.00, 0.00) 3 [GrandChildText] Receive: (60.00, 0.00) 4 [GrandChildText] Report: (48.00, 20.33) 5 [GrandChildText] Receive: (60.00, inf) 6 [GrandChildText] Report: (48.00, 42.33) 7 [ChildVStack] Report: (48.00, 40.67) 8 [ChildVStack] Receive: (60.00, inf) 9 [ChildVStack] Report: (48.00, 62.67) 10 [ChildText] Receive: (60.00, 0.00) 11 [ChildText] Report: (48.00, 20.33) 12 [ChildText] Receive: (60.00, inf) 13 [ChildText] Report: (48.00, 42.33) 14 [ChildVStack] Receive: (60.00, 389.00) 15 [GrandChildText] Receive: (60.00, 368.67) 16 [GrandChildText] Report: (48.00, 42.33) 17 [ChildVStack] Report: (48.00, 62.67) 18 [ChildText] Receive: (60.00, 715.33) 19 [ChildText] Report: (48.00, 42.33) 20 [VStack] Report: (48.00, 105.00) 21 [VStack] Receive: (60.00, 105.00) 22 [ChildVStack] Receive: (60.00, 52.50) 23 [GrandChildText] Receive: (60.00, 32.17) 24 [GrandChildText] Report: (48.00, 20.33) 25 [ChildVStack] Report: (48.00, 40.67) 26 [ChildText] Receive: (60.00, 64.33) 27 [ChildText] Report: (48.00, 42.33) 28 [VStack] Report: (48.00, 83.00)
実は20行目までは正しくレイアウトされる .frame(width: 60) のケースとまったく同じになっています。 .frame(width: 60) のケースはそこでレイアウトが完了してめでたしとなるのですが、 .frame(maxWidth: 60) の方だとそのあともう一回 VStack 以下のレイアウトが走り直していることがわかります。
その2回目のレイアウトにて VStack に提案されるのは1回目のレイアウトプロセスの結果定まった 105.00 で、子 View のうち柔軟性が低いと判断された ChildVStack にその半分の高さ 52.50 が提案されています。しかし、17行目を見るとわかりますが、もともとは ChildVStack は高さ 62.67 が必要だと報告していたのでした。これは GrandChildText の さしすせそ を2行に改行した高さに相当します。にもかかわらず ChildVStack がもともと報告したものよりも小さい 52.50 という高さが提案されたため、 ChildVStack の子である GrandChildText にも小さい高さが提案され、結果として GrandChildText は自らを1行にして文字列を省略することで提案に合わせたサイズを報告しています。
以上のように、 .frame(maxWidth: 60) をつけるとなぜかレイアウトプロセスの交渉が1回分増えてしまい、このせいで子の Text が省略されてしまうということが起こっていたようです。 .frame(maxWidth:) の指定時にはなにかしら1回分レイアウトを増やしたい理由が SwiftUI としてあるのかもしれませんが、考えてみてもよくわかりませんでした。
すごく単純な View で試してみたところ、たしかに .frame(width:) や .frame(height:) ではレイアウトプロセスが1回走るところで .frame(maxWidth:) や .frame(minWidth:)、 frame(maxHeight:) などでは2回走るようです。ただ、 frame(maxWidth: w, maxHeight: h) のように maxWidth / maxHeight を両方を指定すると1回しか走らず、なるほど〜と思って frame(minWidth: w, minHeight: h) を見てみるとこちらは2回走っていました。このあたりの振る舞いが意図的なのかたまたまなのかは自分にはわかっていません。また、 Xcode / iOS のバージョンによって細かい部分は変わるかもしれません。
省略されてしまう Text を省略されなくしよう
領域が十分あるのに SwiftUI の都合で Text が省略されてしまうのは困るので、省略されないようにしたいです。今回は2つの方針でそれぞれ直してみましょう。
.layoutPriority
まず .layoutPriotity modifier を使ってみます。 0 より大きな値を .layoutPriority に渡された子 View は柔軟性に関係なく親が優先してレイアウトしてくれるようになります。
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
Text("あいうえお")
VStack(spacing: 0) {
Text("か")
Text("さしすせそ")
}
+ .layoutPriority(1)
}
.frame(maxWidth: 60)
}
}
これで、無事 さしすせそ が省略されずに表示されるようになりました!めでたいね。
.layoutPriority によってレイアウトプロセスがどのように変わったのかを見てみましょう。
struct ContentView: View { var body: some View { SizeDebugger(label: "VStack") { VStack(spacing: 0) { SizeDebugger(label: "ChildText") { Text("あいうえお") } SizeDebugger(label: "ChildVStack") { VStack(spacing: 0) { Text("か") SizeDebugger(label: "GrandChildText") { Text("さしすせそ") } } } .layoutPriority(1) } } .frame(maxWidth: 60) } }
出力は以下です。
1 [VStack] Receive: (60.00, 778.00) 2 [ChildText] Receive: (60.00, 0.00) 3 [ChildText] Report: (48.00, 20.33) 4 [ChildVStack] Receive: (60.00, 757.67) 5 [GrandChildText] Receive: (60.00, 0.00) 6 [GrandChildText] Report: (48.00, 20.33) 7 [GrandChildText] Receive: (60.00, inf) 8 [GrandChildText] Report: (48.00, 42.33) 9 [GrandChildText] Receive: (60.00, 737.33) 10 [GrandChildText] Report: (48.00, 42.33) 11 [ChildVStack] Report: (48.00, 62.67) 12 [ChildText] Receive: (60.00, 715.33) 13 [ChildText] Report: (48.00, 42.33) 14 [VStack] Report: (48.00, 105.00) 15 [VStack] Receive: (60.00, 105.00) 16 [ChildVStack] Receive: (60.00, 84.67) 17 [GrandChildText] Receive: (60.00, 64.33) 18 [GrandChildText] Report: (48.00, 42.33) 19 [ChildVStack] Report: (48.00, 62.67) 20 [ChildText] Receive: (60.00, 42.33) 21 [ChildText] Report: (48.00, 42.33) 22 [VStack] Report: (48.00, 105.00)
.layoutPriority の指定によってレイアウトの流れが大きく変わっているのが見どころです。これまでは最初の柔軟性測定フェーズで各子 View に高さ0と無限大がそれぞれ提案されていましたが、今回は .layoutPriority がついていない方の子 View である ChildText の最小限の高さを知るために2行目で高さ0のみが提案されています。それに対する報告の 20.33 を VStack 自体から差し引いた 778.00 - 20.33 = 757.67 が丸ごと .layoutPriority が指定されている ChildVStack に提案されます。このように、 layoutPriority の指定がある場合は優先度が低い View の最低限のサイズを先に測っておいて、残りの領域を可能な限り優先度の高い View に渡すというふうになっているようです。
最終的なレイアウトが決まる16行目以降においても、 VStack の高さ 105.00 から ChildText の最小限の高さ 20.33 を引いた 105.00 - 20.33 = 84.67 が ChildVStack に提案されており、そのおかげで ChildVStack の子の GrandChildText にも2行分描画するのに十分な高さが提案されるので、内容が省略されずに済むようになっています。
.fixedSize
続いて .fixedSize modifier を使う方針も試してみます。 .fixedSize がつけられた View は親 View からのサイズの提案を無視して自身の取りたいサイズを取るようになります。
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
Text("あいうえお")
VStack(spacing: 0) {
Text("か")
Text("さしすせそ")
+ .fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: 60)
}
}
この修正でも .layoutPriority と同様 さしすせそ は省略されず2行で描画されてくれます。
いつも通りレイアウトプロセスを見ていきます。
struct ContentView: View { var body: some View { SizeDebugger(label: "VStack") { VStack(spacing: 0) { SizeDebugger(label: "ChildText") { Text("あいうえお") } SizeDebugger(label: "ChildVStack") { VStack(spacing: 0) { Text("か") SizeDebugger(label: "GrandChildText") { Text("さしすせそ") } .fixedSize(horizontal: false, vertical: true) } } } } .frame(maxWidth: 60) } }
出力は以下です。
1 [VStack] Receive: (60.00, 778.00) 2 [ChildVStack] Receive: (60.00, 0.00) 3 [GrandChildText] Receive: (60.00, nil) 4 [GrandChildText] Report: (48.00, 42.33) 5 [ChildVStack] Report: (48.00, 62.67) 6 [ChildVStack] Receive: (60.00, inf) 7 [ChildVStack] Report: (48.00, 62.67) 8 [ChildText] Receive: (60.00, 0.00) 9 [ChildText] Report: (48.00, 20.33) 10 [ChildText] Receive: (60.00, inf) 11 [ChildText] Report: (48.00, 42.33) 12 [ChildVStack] Receive: (60.00, 389.00) 13 [ChildVStack] Report: (48.00, 62.67) 14 [ChildText] Receive: (60.00, 715.33) 15 [ChildText] Report: (48.00, 42.33) 16 [VStack] Report: (48.00, 105.00) 17 [VStack] Receive: (60.00, 105.00) 18 [ChildVStack] Receive: (60.00, 52.50) 19 [ChildVStack] Report: (48.00, 62.67) 20 [ChildText] Receive: (60.00, 42.33) 21 [ChildText] Report: (48.00, 42.33) 22 [VStack] Report: (48.00, 105.00)
注目したい点としては3行目で GrandChildText に高さ nil が提案されている点です。これが .fixedSize の効果で、 .fixedSize をつけた View にはサイズとして nil が提案されます。今回は fixedSize(horizontal: false, vertical: true) をつけたため高さのみ nil になっていますが、引数の horizontal も true にすると幅も nil が提案されます。サイズとして nil を提案された View はその方向については制約なく自分が取りたいサイズを取ろうとします。そのため、11行目ではテキストを省略せず2行で描画したサイズを報告しています。
最終的な配置フェーズでも ChildVStack は18行目で提案された高さ 52.50 よりも大きな 62.67 を19行目で報告しています。これは ChildVStack の子の GrandChildText が .fixedSize の効果で親の提案を無視して自分の取りたいサイズを報告するためです。そのおかげで GrandChildText は省略されずに文字列を描画することができます。
まとめ
この記事では SwiftUI の Text がなぜか省略されてしまう問題と、その調査のために SwiftUI のレイアウトプロセスをデバッグしていく様子を紹介しました。 Text 省略問題についてが完全な解決はできませんでしたが、 SwiftUI のレイアウトプロセスについて理解を深め、それをもとに修正ができました。この記事は全体的に手探りで書いているので、もしこの記事でよくわからないとされた事象を理解しているぜという方や、なんらかの間違いを発見された方は @_maiyama18 にご連絡いただけるととても助かります。
SwiftUI でアプリを開発していくためにレイアウトプロセスを理解していると便利なことが多いです。この記事では分量の関係で説明を意図して簡略化・省略している箇所があるし、そもそも自分の理解が間違っている部分があるかもしれません。本文中でも言及しましたが、 Thinking in SwiftUI には SwiftUI の仕組みが網羅的かつわかりやすく書かれているので日頃 SwiftUI を書いている方にはおすすめです。
明日の はてなエンジニア Advent Calendar 2024 は id:ymse さんです。




