はじめに
先日開催されたiOSDC Japan 2026で、「EmptyViewは本当に“空”なのか?」というタイトルでLT登壇しました。iOSDC Japanは、iOS関連技術を中心に、開発者が知見を共有する国内最大級のカンファレンスです。
この記事では、プロポーザルを提出した背景や、今回のトークで取り上げたSwiftUIのView Identityについて振り返ります。
プロポーザル提出にあたって
今回採択されたプロポーザルは、日頃の業務で取り組んでいるバグ修正が出発点になっています。複数のケースに分岐するswitch文で不具合が発生し、原因を調べると、EmptyViewを含む条件分岐でView Identityが関係していた、というケースに何度も遭遇してきました。なお、今回のトークでは、スライド上でコードを読みやすくするため、if文を使った例に置き換えて紹介しています。
View Identityが変わると、それまでのViewの生存期間(View Lifetime)が終了し、紐づいていたStateも失われます。この仕組みを理解していても、日々の実装や修正の中で、条件分岐の変更がView Identityにどう影響するかまで意識し続けるのは難しいのではないかと感じています。
そこで、EmptyViewに関連する身近なコードを題材に、View Identityと状態の保持の関係を整理し、その重要性をあらためて確認する機会にしたいと考え、プロポーザルを作成しました。
View Identity
LTでは時間の関係で一部の説明を省略したので、ここではView Identityについて補足します。
View Identityは、SwiftUIが更新の前後で「同じViewかどうか」を判断するための手がかりです。bodyが再評価されてViewの値が作り直されても、Identityが維持されていれば、そのViewが持つStateは引き継がれます。一方、Viewが取り除かれたり、Identityが変わったりすると、それまでのView Lifetimeは終了します。
else句を省略した場合
まず、トーク冒頭のコードは、else句にEmptyViewを書かなくても、AttendeeCounterViewを非表示にできます。あえてEmptyViewを明示したのは、ifとelseの分岐を表す_ConditionalContentの説明につなげるためでした。
if isSessionExpanded {
AttendeeCounterView()
}
このようなelseのない条件分岐は、ViewBuilder.buildIf(_:)によってOptionalなViewとして扱われます。処理のイメージを概念的に表すと、次のようになります。コンパイラが生成するコードをそのまま示したものではありません。
let content: AttendeeCounterView?
if isSessionExpanded {
content = AttendeeCounterView()
} else {
content = nil
}
let result = ViewBuilder.buildIf(content)
elseでEmptyViewを返す場合とは型の構造が異なりますが、isSessionExpandedがfalseになるとAttendeeCounterViewがView階層から外れる点は同じです。そのViewが内部で保持する@Stateは引き継がれず、再び表示したときには初期値から始まります。
つまり、else句のEmptyViewを取り除くだけでは、状態がリセットされる問題は解消しません。
同じ.idを指定すれば状態は維持されるのか
では、.id(_:)でExplicit Identity(明示的なIdentity)を指定すればよいのでしょうか。たとえば、次のように両方の分岐へ同じIDを付けた場合を考えます。
if isSessionExpanded {
AttendeeCounterView()
.id("attendeeCounter")
} else {
EmptyView()
.id("attendeeCounter")
}
この指定によって、分岐をまたいでAttendeeCounterViewの状態が維持されるわけではありません。ifとelseは構造上異なる位置にあり、同じIDを付けても、非表示になったAttendeeCounterViewのLifetimeを延ばすことにはならないためです。
.id(_:)を、取り除かれたViewのStateを保存・復元するための仕組みとして使うことはできません。状態を維持したい場合は、「状態をどこで持つか」または「Viewを階層に残すか」を考える必要があります。
方法1: より長く存在するViewでStateを保持する
LTで紹介したのは、保持したいStateを条件分岐の外側にある親Viewへ移し、子ViewにBindingとして渡す方法です。
struct SessionView: View {
@State private var isSessionExpanded = true
@State private var count = 0
var body: some View {
VStack {
// ・・・
if isSessionExpanded {
AttendeeCounterView(count: $count)
}
}
}
}
struct AttendeeCounterView: View {
@Binding var count: Int
var body: some View {
// カウンターUI
}
}
この設計では、AttendeeCounterViewが取り除かれても、countを保持するSessionViewのLifetimeが続く限り、値は維持されます。再表示した子Viewは、親Viewが持つ値をBinding経由で参照・更新します。
画面からViewを取り除きつつ、入力値やカウントなどのデータを残したい場合に使える方法です。ただし、親View自体のIdentityが変わったり、親Viewが取り除かれたりすれば、そのStateもリセットされます。
方法2: Viewを階層に残して、表示だけを切り替える
もう一つは、条件分岐でViewを出し入れせず、opacityで見た目だけを切り替える方法です。以下は、AttendeeCounterViewが内部に@Stateを持つ実装を使う場合の例です。
AttendeeCounterView()
.opacity(isSessionExpanded ? 1 : 0)
.allowsHitTesting(isSessionExpanded)
.accessibilityHidden(!isSessionExpanded)
この書き方では、表示を切り替えてもViewは同じ構造上の位置に残るため、この切り替えによって内部のStateがリセットされることはありません。非表示中の操作や読み上げも対象外にするため、allowsHitTestingとaccessibilityHiddenを併用しています。
ただし、opacity(0)は透明にするだけなので、Viewが占めるレイアウト上の領域は残ります。非表示時にその領域も詰めたい場合は、方法1のように状態を親Viewへ移したうえで、条件分岐でViewを取り除く設計が適しています。
WWDC21の「Demystify SwiftUI」でも、同じViewの見た目を変える場合には、分岐で別のViewに切り替えるよりも、Identityを保ちながらプロパティを変更する設計が勧められています。常にopacityを使うという意味ではなく、表示の要件と状態を保持したい期間に合わせて、設計を選ぶことが大切です。
View Identityの話は、昨年のパンフレット記事でも記載していますので、開発の参考になれば嬉しいです。
最後に
毎年恒例のLT大会は、今年もどのトークも面白く、自分もスピーカーとして参加できたことを嬉しく思います。
クロージングで長谷川さんがお話しされていたように、カンファレンスの運営が難しくなっていく中で、これだけの規模のイベントを継続して開催してくださることに、あらためて感謝しています。今後もiOSDCが続いていくよう、個人としてもできる形で協力していきたいです。
また来年も登壇できるよう、日々の開発で得た気づきや学びを積み重ね、プロポーザルを提出したいと思います。
運営やスタッフの皆様、本当にありがとうございました!
