🚫

[SwiftUI] if系extensionのmodifierは多用しない方がいい

2023/04/30に公開5

TL;DR

  • if系extensionのmodifierは暗黙的にレシーバを複製し、パフォーマンス低下・状態の消失を引き起こす
  • if系extensionのmodifierはif {}と(ほぼ)同じ挙動をする
    • それぞれの分岐でStructural Identity (View Identity)が変わる
  • if系extensionのmodifierは便利だが、上記デメリットを見えにくくし、不必要な利用を助長する
  • 使わない努力をした後、必要に迫られデメリットを理解した上で使うなら良いと思う

if系extensionのmodifierとは

以下のようなmodifierです。

extension View {
    @ViewBuilder
    func `if`<Content: View>(
        _ condition: Bool,
        @ViewBuilder transform: (Self) -> Content
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }

    @ViewBuilder
    func `if`<TrueContent: View, FalseContent: View>(
        _ condition: Bool,
        @ViewBuilder _ then: (Self) -> TrueContent,
        @ViewBuilder `else`: (Self) -> FalseContent
    ) -> some View {
        if condition {
            then(self)
        } else {
            `else`(self)
        }
    }
}

例えば、「ある条件に応じてmodifierを付け替えたい」と言う場合
このmodifierを用いると簡潔に記述できるという利点があります。

struct SampleView: View {
    let condition: Bool

    var body: some View {
        Text("text")
	    .if(condition) {
	        // trueの時だけ文字色を赤に
	        $0.foregroundColor(.red)
	    }
    }
}

なお、以下のようなextensionの利用時にif {}を使った場合も今回の議論に該当します。

func extend<Content: View>(@ViewBuilder transform: (Self) -> Content) -> some View {
    transform(self)
}

Text("text")
    .extend {
        if condition {
	    $0.foregroundColor(.red)
	} else {
	    $0
	}
    }

なぜ「多用しない方がいい」?

このコードを見てどう思うでしょうか?
一見特に問題のないコードに見えます。

struct CounterView: View {
    @State var count = 0

    var body: some View {
        Button {
            count += 1
        } label: {
            Text("count: \(count)")
        }
    }
}

struct SampleView: View {
    @State var condition = false

    var body: some View {
        VStack {
            CounterView()

            Button {
                condition.toggle()
            } label: {
                Text("toggle background")
            }
        }
        .if(condition) {
            $0.background(Color.red)
        }
    }
}

ですが、上のコードは次のコードと意味・挙動的には(ほぼ)一緒です。

struct SampleView: View {
    @State var condition = false

    var body: some View {
        if condition {
            VStack {
                CounterView()

                Button {
                    condition.toggle()
                } label: {
                    Text("toggle background")
                }
            }
	    .background(Color.red)
	} else {
	    VStack {
                CounterView()

                Button {
                    condition.toggle()
                } label: {
                    Text("toggle background")
                }
            }
	}
    }
}

「なんで@Stateを持っている CounterView を2回宣言してるんだ...」という感想だと思います。
これだとconditionが変わる度に別のCounterView@Stateが表示され、さらにconditionが変わる度に状態が消失してしまいます。

実行例

.if modifierの定義を改めて見るとselfif { self } else { self }のように囲われているので、こういう結果になるのも理解できると思います。

extension View {
    @ViewBuilder
    func `if`<Content: View>(
        _ condition: Bool,
        @ViewBuilder transform: (Self) -> Content
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

以上から .if modifierは暗黙的にレシーバを複製し、パフォーマンス低下・状態の消失を引き起こすため多用すべきではない と思います。

代わりにどうすればいい?

.if modifierを使いたいケースは「条件によって要素のmodifier切り替えたい」というケースだと思います。要素のmodifierの切り替えにおいては、modifierの引数に分岐を移動させることで(大抵のケースは)対処できます。この場合は軽いコストかつ単一のView identityで表現できます。

struct SampleView: View {
    let condition: Bool

    var body: some View {
        Text("text")
	    .foregroundColor(condition ? .red : .primary)
	    .overlay {
	        if condition {
		    Text("overlayed!")
		} else {
		    EmptyView()
		}
	    }
    }
}

そもそも、大抵のケースでは、if {}は「違う要素の出し分け」をするために用いるもので、「要素のmodifierを切り替える」ために用いる物ではないと思います。
要素のmodifierを切り替えるために if {}, .if modifier を使うのは大抵のケースで悪い選択です。

どうしてもif {} を使わなくてはいけない時

modifierの引数にisActive: Boolがない場合等、「どうしてもmodifierの付け替えにif {}を使わなくては行けない場合」もあるのかなと思っています。

struct SampleView: View {
    let condition: Bool

    var body: some View {
        VStack {
	    Text("hoge")
	    
            if condition {
                Text("text")
                    .hidden() // isActiveがない
            } else {
                Text("text")
            }
	    
	    Text("fuga")
	}
    }
}

このケースになって初めて .if modifierを使う選択をしてもいいかなと思います。
これなら上のコードも次のコードも同じ意味なので、このケースでは純粋に「便利なmodifier」だと思います。

struct SampleView: View {
    let condition: Bool

    var body: some View {
        VStack {
	    Text("hoge")
	    
            Text("text")
		.if(condition) {
                    $0.hidden()
		}
	    
	    Text("fuga")
	}
    }
}

他にも、「conditionが複雑で可読性がとてつもなく低い」かつ「@State等の状態が内部にない」かつ「軽い要素なので再描画でも大したコストではない」みたいなケースでも、デメリットを理解した上で.if modifierを利用するのはありかなと思います。

つまり、 可能な限り使わない努力をした上で、デメリットを理解して使うなら問題ない だと思います。

"自分は"View Identityの変化のデメリットを見た目で認識したいので、"自分は".if modifierを使いませんが、他の方がメリットとデメリットのバランスを考えて決断した結果、使うことになったのなら良いと思います。

Special Thanks

このツイートに参加してくれた皆さん

https://twitter.com/kntkymt/status/1652125984156704769?s=20

@fummicc1
@omochimetaru
@treastrain

(アルファベット順)

Discussion

KyomeKyome

Optionalをアンラップした上でModifierを使いたい時に分岐Modifierを使うのは問題ないでしょうか?

例えば以下の様なエラーがあってそれをOptionalで持っているときに、エラーをAlertで表示したい様なケースです。

enum MyError: Error {
    case someError

    var title: LocalizedStringKey {
        switch self {
        case .someError: return "Some Error"
        }
    }
}
struct SampleView: View {
    @State var showErrorAlert: Bool = false
    @State var myError: MyError? = nil

    var body: some View {
        Button {
            myError = .someError
            showErrorAlert = true
        } label: {
            Text("Push Me")
        }
        .alert(myError?.title ?? "",
               isPresented: $showErrorAlert,
               actions: {})
    }
}

このとき、以下の様な分岐Modifierがあったら

extension View {
    @ViewBuilder
    func unwrapMyError<Content: View>(
        _ myError: MyError?,
        @ViewBuilder transform: (Self, MyError) -> Content
    ) -> some View {
        if let myError {
            transform(self, myError)
        } else {
            self
        }
    }
}

この様にすっきり書けるのですが、パフォーマンス的に問題ないでしょうか?

struct SampleView: View {
    @State var showErrorAlert: Bool = false
    @State var myError: MyError? = nil

    var body: some View {
        Button {
            myError = .someError
            showErrorAlert = true
        } label: {
            Text("Push Me")
        }
        .unwrapMyError(myError) { view, myError in
            view.alert(myError.title,
                       isPresented: $showErrorAlert,
                       actions: {})
        }
    }
}
kntkymtkntkymt

質問ありがとうございます!

結論から言いますと、このケースも「特定の条件下でmodifierの付ける/付けないを切り替えている」ので同様にパフォーマンスやIdentityの問題を持っているかなと思います。


例えばonAppearでbodyの型(Structural Identity)を確認してみるとわかるのですが

struct SampleView: View {
    @State var showErrorAlert: Bool = false
    @State var myError: MyError? = nil

    var body: some View {
        Button {
            myError = .someError
            showErrorAlert = true
        } label: {
            Text("Push Me")
        }
        .unwrapMyError(myError) { view, myError in
            view.alert(myError.title,
                       isPresented: $showErrorAlert,
                       actions: {})
        }
        .onAppear {
            print(type(of: body))
        }
    }
}
_ConditionalContent<
  ModifiedContent<
    Button<Text>, 
    AlertModifier<ModifiedContent<EmptyView, ActionsModifier>, EmptyView>
  >, 
  Button<Text>
>

_ConditionalContentに囲われてButton<Text>が二つ出ているので
これも同様に以下のように複数回Buttonを記述しているのと同様のIdentityになってしまっており、パフォーマンス的に良くないかと思います...

var body: some View {
    if myError {
        Button {... }.alert()
    } else {
        Button { ... }
    }
}

毎回?? ""を書くのが煩雑でしたら(握りつぶすextensionを書くのはやや憚られますが)
できることとしては、以下のようにnilを許容するextensionを生やすくらいかなと思います...

空文字列渡すとxcstringsでいらないkeyが出るんですね...うーん、ここら辺はSwiftUIに対応して欲しいところではありますね

extension View {
    func alert<V: View>(_ title: LocalizedStringKey?, isPresented: Binding<Bool>, actions: () -> V) -> some View {
        alert(title ?? "", isPresented: isPresented, actions: actions)
    }
}
KyomeKyome

返答ありがとうございます。

Optionalの時に?? "" をしたくない理由としては、Xcode 15のString Catalog (xcstrings)を使っていると空文字も自動的にキーとして反応してしまうのでそれを回避したいというのがあります。

うまい具合にできればいいのですが、トレードオフですかね。

kntkymtkntkymt

xcstringsについて、なるほどです...これは嫌ですね...

うまい具合にできればいいのですが、トレードオフですかね。

そうですね...
if系modifier自体も僕としては「可能な限り使わない努力をした上で、デメリットを理解して使うなら問題ない」という意見なので
今回の例でも空文字が入るのを回避するために使うのも選択次第、って感じでしょうか...
(何か解決方法があればいいんですが...お役に立てずすみません)

KyomeKyome

この件、XCStringsについては要はLocalizedStringKeyと認識されることがいけないので、String()で包んでやれば良いことに気づきました。