🚫

[SwiftUI] ViewThatFitsの中に状態を持たない方が良い

2023/07/28に公開

ViewThatFitsとは

iOS16+から使える、レスポンシブレイアウトのようなレイアウトを組めるViewです。

https://developer.apple.com/documentation/swiftui/viewthatfits

ViewThatFits evaluates its child views in the order you provide them to the initializer. It selects the first child whose ideal size on the constrained axes fits within the proposed size.
(翻訳) ViewThatFits は、イニシャライザに指定された順序で子ビューを評価します。これは、制約軸上の理想的なサイズが提案されたサイズに収まる最初の子を選択します。

struct UploadProgressView: View {
    var uploadProgress: Double

    var body: some View {
        ViewThatFits(in: .horizontal) {
	    // A
            HStack {
                Text("\(uploadProgress.formatted(.percent))")
                ProgressView(value: uploadProgress)
                    .frame(width: 100)
            }
	    
	    // B
            ProgressView(value: uploadProgress)
                .frame(width: 100)
		
	    // C
            Text("\(uploadProgress.formatted(.percent))")
        }
    }
}

公式ドキュメントの例では、複数のサイズを指定した際の比較が出ていました

VStack {
    // Aが収まるサイズ
    UploadProgressView(uploadProgress: 0.75)
        .frame(maxWidth: 200)
	
    // Aは治らないがBが収まるサイズ
    UploadProgressView(uploadProgress: 0.75)
        .frame(maxWidth: 100)
	
    // A,Bは収まらないがCが収まるサイズ
    UploadProgressView(uploadProgress: 0.75)
        .frame(maxWidth: 50)
}

上から、サイズに応じてA, B, CのViewが出ています。

本題: ViewThatFitsの中に状態を持たない方が良い

実用例の一つに、端末サイズの差異を吸収するケースがあると思います。
次のような、カウンターのViewを考えてみましょう。
画面が大きい場合はHStackで表示し、画面が小さい場合はVStackで表示します。

struct SampleView2: View {

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Text("カウンターだよ!")
                    .font(.title)

                CounterView()
            }
            .frame(minWidth: 380)

            VStack {
                Text("カウンターだよ!")
                    .font(.title)

                CounterView()
            }
        }
    }
}

struct CounterView: View {

    @State var count = 0

    var body: some View {
        Button {
            count += 1
        } label: {
            Text(count.description)
                .font(.title)
        }
    }
}
iPhone 14 Pro iPhone SE3

ここで問題なのが、画面回転やiPadのSplit View・Slide Over機能を利用した場合、画面サイズが変わるためViewThatFitsで表示されるViewが変わる可能性があるという点です。この時、View Identityが変更されるため、@State@StateObjectの状態が消え・Viewが完全に再描画されます。

画面回転

Split View

以上より、ViewThatFits内で@State@StateObjectの状態を持たない方が良いと言えます。@Binding などを利用して、状態をViewThatFitsの外で定義しましょう。

struct SampleView3: View {

    @State var count = 0

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Text("カウンターだよ!")
                    .font(.title)

                CounterView(count: $count)
            }
            .frame(minWidth: 380)

            VStack {
                Text("カウンターだよ!")
                    .font(.title)
                    .onTapGesture {
                      print(type(of: self.body))
                    }

                CounterView(count: $count)
            }
        }
        
    }
}

struct CounterView: View {

    @Binding var count: Int

    var body: some View {
        Button {
            count += 1
        } label: {
            Text(count.description)
                .font(.title)
        }
    }
}

ViewThatFitsはどのように実現されているのか?

最初は以下のようにifで実現されていると思っていましたが、違いました。

var body: some View {
    if (Aがサイズに収まるか) {
        A()
    } else if (Bがサイズに収まるか) {
        B()
    } else {
        C()
    }
}

挙動から推測すると、以下のような実装になっていると思います。

var body: some View {
    A().opacity(Aが収まるか ? 1 : 0)

    if Aが収まらない {
        B().opacity(Bが収まるか ? 1 : 0)
    }

    if A,Bが収まらない {
        C()
    }
}

検証

Xcode 14.3 iOS/iPadOS 16.4で検証

以下のViewを用意します。

var body: some View {
    ViewThatFits(in: .horizontal) {
        Text("text")
            .frame(minWidth: 800)

        Image(systemName: "globe")
            .frame(minWidth: 600)

        Color.red
    }
}

これは、iPad Pro 12.9-inch 横向きでSplit Viewした時に2/3, 1/2, 1/3の分割でそれぞれText, Image, Colorが出るようにしたViewです。

2/3 1/2 1/3

ここで、それぞれの分割で、XcodeのCapture View Hierarchy機能を利用してView階層を見てみましょう。

2/3 1/2 1/3

はい、Colorだけが見えている時でも、Text, Imageがopacity(0)の状態で存在しています。
つまり、それぞれの分割でbodyは以下のような状態になっています。

// 2/3の時
var body: some View {
    A().opacity(1)
}

// 1/2の時
var body: some View {
    A().opacity(0)

    B().opacity(1)
}

// 1/3の時
var body: some View {
    A().opacity(0)

    B().opacity(0)

    C().opacity(1)
}

これだけじゃない

と、、、思っていた時期が私にもありました。

1/3分割の状態でアプリを起動し、1/2に分割を変更します。

その後のView階層はこちらになります。

はい。opacity(0)のColorが残っています。
つまり、正しくは以下です。

var body: some View {
    A().opacity(Aが収まるか ? 1 : 0)

    if Bを一度でも表示した || Aが収まらない {
        B().opacity(Aが収まらない && Bが収まるか ? 1 : 0)
    } 

    if Cを一度でも表示した || A,Bが収まらない {
        C().opacity(A,Bが収まらない ? 1 : 0)
    }
}

意味がわかりません
結論

  • ViewThatFitsはViewを上から評価して行き、最初にサイズに収まったViewを表示する
  • 過去に一度でも評価されたViewはopacity(0)でキャッシュする

と言う挙動だと言うことがわかりました。

補足・余談

ViewThatFitsは便利で、煩雑なGeometryReaderの上位互換のように感じますが、Identityが変わってしまうデメリットがあるので、全てを置き換えるわけではないと思います。

たとえ状態が消えなくても、Identityが変わるとViewの完全な再描画が起きてパフォーマンス低下を引き起こすので、ViewThatFitsではなくGeometryReaderを使えばifを使わなくて良いレイアウトがあった場合、パフォーマンスを考えてGeometryReaderを利用する選択もあるのかと思いました。(画面回転等は頻繁には起きないので、そこはバランスですが)

正直View Identityが変わるレスポンシブは微妙だなと思いました。Identityが変わらない形式でレスポンシブを実現して欲しかった... WrapHStackみたいな。
というか、使わないViewまでopacity(0)でUIViewにaddされてるってどうなんですかこれ、パフォーマンス的にも悪いんじゃないか...?

Discussion