The Pragmatic Ball boy

iOSを中心にやってる万年球拾いの老害エンジニアメモ

SwiftUIのViewで条件によってViewを出し分ける方法

potatotips #64でLTした内容です。


SwiftUIのViewで条件によってViewをだしわけたいことがたまにあるかと思います。

SwiftUIでこのように条件によってViewを出し分けるようなコードを書いてみます。

var body: some View {
    if imageName.isEmpty {
        return Text("no image")
    } else {
        return Image(imageName)
    }
}

そうするとこのようなコンパイルエラーになってしまいます。

! Function declares an opaque return type, but the return statements in its body do not have matching underlying types

なぜかというとbodyの戻り値の型がOpaque Result Typeなので、条件によってTextを返したりImageを返すといった複数の型を返すことができないからです。

ちなみにどちらの条件でも同じ型を返す場合はOKです。

var body: some View {
    if imageName.isEmpty {
        return Text("no image")
    } else {
        return Text(imageName)
    }
}

解決方法

解決方法はざっくり3つあります

1. Group / HStack / VStack / ZStack

1つはGroupやZStackなどをつかってwrapしてやる方法

var body: some View {
    Group {
        if imageName.isEmpty {
            Text("no image")
        } else {
            Image(imageName)
        }
    }
}

2. @ViewBuilder

もう1つは@ViewBuilderアノテーションをつける方法

@ViewBuilder
var body: some View {
    if imageName.isEmpty {
        Text("no image")
    } else {
        Image(imageName)
    }
}

3. ViewBuilder.buildEither()

最後はViewBuilder.buildEither()を使う方法です。(こちらの場合は3項演算子にする必要があります)

var body: some View {
    imageName.isEmpty
        ? ViewBuilder.buildEither(first: Text("no image"))
        : ViewBuilder.buildEither(second: Image(imageName))
}

※この3つの中でどれが一番よいかは最後に紹介します

以上3つの書き方を紹介しましたが、どれもViewBuilderの力によるものです。

なぜViewBuilderを使うと条件分岐が使えるのでしょうか?

ViewBuilderを使うと条件分岐が使える理由

ViewBuilderの仕組みを説明すると ViewBuilderはfunction builderを用いて実装されています。

このfunction builderはコンパイル時に構文解析をしてif文のchainだったらbuildEitherというfunctionを使うというふうになっています。

つまりどういうことかというと

こういうコードをコンパイルすると

@ViewBuilder
var body: some View {
    if imageName.isEmpty {
        Text("Hello")
    } else {
        Image(imageName)
    }
}

このような感じにコンパイル時に解釈されるということです。

var body: some View {
    imageName.isEmpty
        ? ViewBuilder.buildEither(first: Text("no image"))
        : ViewBuilder.buildEither(second: Image(imageName))
}

ここでViewBuilderのbuildEitherの定義がどうなっているか確認してみます

extension ViewBuilder {
    public static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent : View, FalseContent : View

    public static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent : View, FalseContent : View
}

このように_ConditionalContent<TrueContent, FalseContent> を返すようになっていました。

確認のためにこのようなコードを書いて実行してみると

@ViewBuilder
var body: some View {
    if imageName.isEmpty {
        Text("Hello")
    } else {
        Image(imageName)
    }
}

戻り値の型は_ConditionalContent<Text, Image> になっていました。

このようにViewBuilderが条件分岐を_ConditionalContent<,>に変換してくれていたので、bodyの戻り値がopaque result typeであってもコンパイルが通るようになっていたわけです。

現時点で解決できない条件分岐

しかし、現時点(2019年8月)ではViewBuilderでは解決できない問題があります。

このようにswitch文やif let, if caseのようなif else以外の条件分岐を使うとコンパイルに通りません。(ちなみに@ViewBuilderをつかうとコンパイルは通りますがちゃんと動きません)

var body: some View {
    Group {
        switch foo {
        case a:
            Text("a")
        case b:
            Text("b")
        case c:
            Text("c")
        }
    }
}

これを解決するには面倒ですがif elseに書き換えましょう

var body: some View {
    Group {
        if foo == .a {
            Text("a")
        } else if foo == .b {
            Text("b")
        } else if foo == .c {
            Text("c")
        }
    }
}

ですが、どうしてもif else 以外で実装したい場合はAnyViewで、返すViewをwrapしてやると動かすことができます。

var body: some View {
    switch foo {
    case .a:
        return AnyView(Text("a"))
    case .b:
        return AnyView(Text("b"))
    case .c:
        return AnyView(Text("c"))
    }
}

まとめ

ViewBuilderのおかげでView内で条件分岐が使えるということがわかりました。

ただし、現在はif elseのみ対応していて、それ以外のswitch文などは使えません。

しかし、最悪AnyViewを使って型を消してやればif else以外の条件もつかうことができます。

最後に、条件分岐を使う場合は紹介した3つのやり方の中だとGroupやZStackなどでwrapする方法が一番オススメです。理由は@ViewBuilderを使うと利用できないswitch文などを使ってもコンパイルに通ってしまうので間違いに気づきにくいためです。buildEitherは直接使う用途ではないと思うのであえて使わなくて良いかなと思います。