1

I want a if let statement inside a View.

@ObservedObject var person: Person?
 var body: some View {
      if person != nil {
        // this works
      }
      if let p = person {
        // Compiler error
      }
    }

Closure containing control flow statement cannot be used with function builder 'ViewBuilder'

Godfather
  • 4,040
  • 6
  • 43
  • 70
  • 1
    Nope, that's not currently supported. The only kind of control flow you can have in a view builder is `if...else`. – Sweeper Feb 13 '20 at 07:55
  • so I should forcecast i those situations? – Godfather Feb 13 '20 at 07:56
  • 2
    using force cast is not a good solution. It's better to check with nil and handle it in if-else – Mac3n Feb 13 '20 at 08:11
  • 1
    At the moment you can either force cast/unwrap or call another property or function that isn't directly in an `@ViewBuilder` scope so you can use normal control flow like `if let`. – bscothern Feb 13 '20 at 08:12

3 Answers3

4

2022 Update

You can just use normal Swift if let:

if let pieceOfData = pieceOfData {
  // now it's guaranteed
} else {
  // now it's not
}

Previous Answer

Or you can create your own IfLet view builder:

import SwiftUI

struct IfLet<Value, Content, NilContent>: View where Content: View, NilContent: View {

    let value: Value?
    let contentBuilder: (Value) -> Content
    let nilContentBuilder: () -> NilContent

    init(_ optionalValue: Value?, @ViewBuilder whenPresent contentBuilder: @escaping (Value) -> Content, @ViewBuilder whenNil nilContentBuilder: @escaping () -> NilContent) {
        self.value = optionalValue
        self.contentBuilder = contentBuilder
        self.nilContentBuilder = nilContentBuilder
    }

    var body: some View {
        Group {
            if value != nil {
                contentBuilder(value!)
            } else {
                nilContentBuilder()
            }
        }
    }
}

extension IfLet where NilContent == EmptyView {

    init(_ optionalValue: Value?, @ViewBuilder whenPresent contentBuilder: @escaping (Value) -> Content) {
        self.init(optionalValue, whenPresent: contentBuilder, whenNil: { EmptyView() })
    }
}

Using this, you can now do the following:

var body: some View {
    IfLet(pieceOfData) { realData in
        // realData is no longer optional
    }
}

Want to respond if your optional is nil?

var body: some View {
    IfLet(pieceOfData, whenPresent: { realData in
        // realData is no longer optional
        DataView(realData)
    }, whenNil: {
        EmptyDataView()
    })
}
Procrastin8
  • 4,193
  • 12
  • 25
  • Since this is the accepted answer it should be updated to show that if let is now supported – tommmm Nov 03 '22 at 03:49
3

Yes, the notation requested in code snapshot is not allowed, at least for now, but the intended result is possible to achieve with the following very simple approach - extract control flow into function:

var person: Person? // actually @ObservedObject does not allowed optional
var body: some View {
    VStack {
         if person != nil {
           // same as before
         }
         personViewIfExists() // << just extract it in helper function
    }
}

private func personViewIfExists() -> some View { // generates view conditionally
    if let p = person {
      return ExistedPersonView(person: p) // << just for demo
    }
}

on some conditions also the following variant of function might be required (although it produces the same result)

private func personViewIfExists() -> some View {
    if let p = person {
      return AnyView(ExistedPersonView(person: p))
    }
    return AnyView(EmptyView())
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • If possible, one should avoid using `AnyView` in SwiftUI, and instead use the `@ViewBuilder` wrapper. – Aecasorg Dec 21 '21 at 13:41
  • For the above comment, see Demystify SwiftUI: https://developer.apple.com/videos/play/wwdc2021/10022/ – Aecasorg Dec 21 '21 at 13:48
3

The simpler approach is to just use .map()

It will show a UserProfile if user has a value, and nothing if user is nil:

var user: User? 
var body: some View {
  user.map { UserProfile(user: $0) }
}

If you want to provide a default view for nil value, you can just use the nil-coalescing operator:

var user: User? 
var body: some View {
  user.map { UserProfile(user: $0) } ?? UserProfile(user: .default) 
}

However this requires the two views to be of the same type, and in practice if they're of the same type you're better off just writing UserProfile(user: $0 ?? .default).

What's interesting is the case where the two views are not of the same type. You could just erase their type by wrapping them in AnyView, but it does get a bit cumbersome and difficult to read at this point:

var user: User? 
var body: some View {
  user.map { AnyView(UserProfile(user: $0)) } ?? AnyView(Text("Not logged in"))
}

So in this case, my preferred approach is to use a custom IfLet struct (same name as Procrastin8's, but a different implementation and syntax) that allows me to write something like this, which I find very clear in intent and easy to type.

var user: User? 
var body: some View {
  IfLet(user) { UserProfile(user: $0) } 
    .else { Text("Not logged in") }
}

The code for my IfLet component is as follows.

struct IfLet<Wrapped, IfContent> : View where IfContent: View {
  let optionalValue: Wrapped?
  let contentBuilder: (Wrapped) -> IfContent

  init(_ optionalValue: Wrapped?, @ViewBuilder contentBuilder: @escaping (Wrapped) -> IfContent) {
    self.optionalValue = optionalValue
    self.contentBuilder = contentBuilder
  }

  var body: some View {
    optionalValue.map { contentBuilder($0) }
  }

  func `else`<ElseContent:View>(@ViewBuilder contentBuilder: @escaping () -> ElseContent) -> some View {
    if optionalValue == nil {
      return AnyView(contentBuilder())
    } else {
      return AnyView(self)
    }
  }
}

Enjoy!

KPM
  • 10,558
  • 3
  • 45
  • 66