Availability for modifiers

Is there a new way to specify availability for modifiers?

I'm looking for something like this (pseudocode):

struct Bar: View {
    var body: some View {
        Foo()
            #if available(iOS 15)
            .refreshable
            #endif
    }
}

or, perhaps even more clean:

struct Bar: View {
    var body: some View {
        Foo()
            .refreshable_ios15
    }
}

whereas refreshable_ios15 is a modifier that is marked in a special way with some availability marker, and does something on iOS 15 and nothing on previous OS versions (but still compiles without a problem).

instead of the current dance:

struct Bar: View {
    var body: some View {
        if #available(iOS 15, *) {
            Foo()
                .refreshable {
                    // something
                }
        } else {
            Foo()
        }
    }
}
3 Likes

@davedelong has an excellent article tackling the same issue

1 Like

And @shapsuk has been aggregating a number of SwiftUI-specific stuff into his repo here using this approach: GitHub - shaps80/SwiftUIBackports: A collection of SwiftUI backports for iOS, macOS, tvOS and watchOS

1 Like

Great stuff.

However I wonder if something like this (pseudocode below) worth considering into the Swift language itself to help not having such wrappers.

Foo()
    .first
    #available(iOS 15).second
    .third

I don't find myself just returning an unmodified self much. If I did, I might use one of those other solutions. As it is, I do this:

Foo().modifier {
  if #available(iOS 15, *) {
    $0.refreshable {

    }
  } else {
    $0 // .someKindOfFallback
  }
}
public extension View {
  /// Modify a view with a `ViewBuilder` closure.
  ///
  /// This represents a streamlining of the
  /// [`modifier`](https://developer.apple.com/documentation/swiftui/view/modifier(_:))
  /// \+ [`ViewModifier`](https://developer.apple.com/documentation/swiftui/viewmodifier)
  /// pattern.
  /// - Note: Useful only when you don't need to reuse the closure.
  /// If you do, turn the closure into an extension! ♻️
  func modifier<ModifiedContent: View>(
    @ViewBuilder body: (_ content: Self) -> ModifiedContent
  ) -> ModifiedContent {
    body(self)
  }
}
3 Likes

I find it a bit hard to wrap some newer modifiers, such as popoverTip(_:arrowEdge:action:) , because it takes a newer type as a parameter.

It is true that I can still use the conditional view approach to wrap the whole view with an availability check, but the custom modifier approach seems not useful for this case. :thinking: Do you have any advice on this? Thanks.

4 Likes

Anyone came up with some robust approach for this problem? We’re now struggling when updating our app to Liquid Glass and trying out different solutions but didn’t find any that would satisfy us and feel right for the framework.

Building backport modifiers with duplicated types works well but takes a lot of time.

We can work with the modifier approach

.modifier {
      if #available(iOS 26.0, *) {
           $0.glassEffect()
      } else { $0 }
 }

But it doesn’t feel right, it seems like the approach for the framework is to come up with the “inert” modifiers so that you can pass nil or 0 or something that would allow you to easily chain modifiers but in here it’s simply impossible.

Also we came up with a different approach to modifier that doesn’t require else branch (because in the previously suggested modifier approach omitting else branch would result in view disappearing)

@ViewBuilder
func ifAvailable<ConditionalView: View>(
    @ViewBuilder _ content: (Self) -> ConditionalView?
) -> some View {
    if let content = content(self) {
        content
    } else {
        self
    }
}
2 Likes

I've been using the Engine library's VersionedView and VersionedViewModifier protocol to wrap up these sorts of conditional fallbacks, but I'm not sure there's really a good solution for supporting multiple versions of Xcode at the same time. Once you can build with just Xcode 26, you can just call .glassEffect() and the other APIs unconditionally. If you need entirely different views based on the running OS, VersionedView can help encapsulate that.

1 Like

But this modifier is not related to Xcode 26 but rather minimal deployment target. The VersionView seems to be quite nice and convenient solution but from what I see it doesn’t solve the problem of a unavailable parameter type.

Ah sure. In that case you'd probably want make your own conditional versions attached to some marker value, so you could do something like view.backport.glassEffect(), where the backport value vends the conditional modifiers you need. There may be other solutions in the Engine library, I haven't explored it fully.

Yeah what I’m talking about is that there’s no clear approach to this problem defined by the framework itself, which is rapidly changing year by year. I thought I might look for some guidelines in the example projects like Landrmarks, but Apple just bumps the minimal deployment target to the latest os so they don’t need to play with this issue.

Found this workaround that works when conditional thing is the last thing done to the view:

struct ContentView: View {
    var body: some View {
        let view = Text("Hello, world!")
            .foregroundColor(Color.blue)
            .font(.title)
            .padding()
        if #available(iOS 15.0, *) {
            return view.refreshable {}
        } else {
            return view
        }
    }
}

The above approach should handle this use case as well. For example:

        let view = Text("Hello, world!")
            .foregroundColor(Color.blue)
            .font(.title)
            .padding()
        if #available(iOS 15.0, *) {
            return view.searchable(text: $search, placement: SearchFieldPlacement.automatic, prompt: "")
        } else {
            return view
        }

The (new) type SearchFieldPlacement is available since iOS 15 and I set deployment target to iOS 14 (for a test) where it was not available.

1 Like

Thanks! While the approach works, I think it’s far from ideal. Especially the dance with assigning the view to a constant seems very unnatural to SUI and as you mentioned requires the modifier to be applied last which also sometimes can complicate things a lot. I’m really hoping to get some answer from a SUI team. To get some guidelines on that topic.

True. Besides it doesn't work in other cases:

    var body: some View {
        List {
            ForEach(1..<10) { I in
                // compilation error below
                let view = Text("Hello, world!")
                    .foregroundColor(Color.blue)
                    .font(.title)
                    .padding()
                if #available(iOS 15.0, *) {
                    return view.searchable(text: $search, placement: SearchFieldPlacement.automatic, prompt: "")
                } else {
                    return view
                }
            }
        }
    }

Well you can always trust `AnyView()`, but in most cases I take this as a warning to extract views furthermore.

I’d just like to note that we are already implicitly relying on AnyView when we write if #available inside a ViewBuilder context.

The thing is that a result build type must implement buildLimitedAvailability to avoid runtime issues when clients write if #available . And SwiftUI’s definition of this method is:

// from arm64e-apple-ios-macabi.swiftinterface
extension SwiftUICore.ViewBuilder {
  @_alwaysEmitIntoClient public static func buildLimitedAvailability<Content>(_ content: Content) 
  -> SwiftUICore.AnyView where Content : SwiftUICore.View {
    .init(content)
  }
}
5 Likes

That’s correct, but only if you’re in a ViewBuilder context (or a result builder in general)! If you use return in your builder body, that’ll disable the result builder and turn it back into an ordinary function.

And for ordinary functions with an opaque result type, the use of if #available(...) allows SE-0360 Opaque result types with limited availability to kick in, which I think is what also happened in @tera‘s workaround above.

Here are a few more examples to demonstrate how it all works (currently). In Example1, we have an implicit ViewBuilder where buildLimitedAvailability introduces AnyView to the opaque result type exactly as @CrystDragon said:

// Implicitly a ViewBuilder, returns _ConditionalContent<AnyView, Text>.
struct Example1: View {
    /*@ViewBuilder*/ var body: some View {
        if #available(iOS 26, *) {
            Text("Hello").glassEffect()
        } else {
            Text("Hello")
        }
    }
}

But if we turn both branches to return their results in Example2, its body is no longer a ViewBuilder and the result type changes as well (into something I can’t quite decipher from LLDB output, but I trust SE-0360 here):

// Returns an opaque result around the one type this consistently evaluates to.
struct Example2: View {
    var body: some View {
        if #available(iOS 26, *) {
            return Text("Hello").glassEffect()
        } else {
            return Text("Hello")
        }
    }
}

The compiler remained silent about disabling the ViewBuilder above, but if we try to add it explicitly in Example3, we’ll get a warning:

// warning: application of result builder 'ViewBuilder' disabled by explicit 'return' statement
struct Example3: View {
    @ViewBuilder var body: some View {
        if #available(iOS 26, *) {
            return Text("Hello").glassEffect()
//          ^
        } else {
            return Text("Hello")
        }
    }
}

Finally, even if you weren’t in an explicit or implicit ViewBuilder body, you can’t omit the return keyword because SE-0360 doesn’t really interact with SE-0380 if and switch expressions any more than it did with SE-0289 Result builders.

4 Likes

Thanks for sharing this since I started to question the placement of availability checks, if it’s converting the views inside the ViewBuilder into AnyView then cases like this

ForEach(items) { item in if #available(iOS 15, *) { ModernRow(item) } else { LegacyRow(item) } }

can become problematic since views identity will be lost, then I assume it would be better in this example to define 2 different loops

if #available(iOS 15, *) { ForEach(items) { ModernRow($0) } } else { ForEach(items) { LegacyRow($0) } }

Unless the Example2 that you showed keeps the views identity

As I see it, the use of SE-0360 in Example2 should indeed keep the identity of views.

But you need to use return for the behaviour to kick in, and unlike inside var body, you can’t seem to do it straight away in trailing closures like that of a ForEach:

struct Example: View {
    var items: [Item]

    var body: some View {
        ForEach(items) { item in
//      ^
// error: generic parameter 'Content' could not be inferred
            if #available(iOS 26, *) {
                return ModernRow(item: item)
//              ^
// error: cannot use explicit 'return' statement in the body of result builder 'ViewBuilder'
            } else {
                return LegacyRow(item: item)
            }
        }
    }
}

Instead, you can move the ForEach content into another non-ViewBuilder helper such as a private member function using the same trick as in Example2:

struct Example: View {
    var items: [Item]

    var body: some View {
        ForEach(items, content: makeRow(from:))
    }

    private func makeRow(from item: Item) -> some View {
        if #available(iOS 26, *) {
            return ModernRow(item: item)
        } else {
            return LegacyRow(item: item)
        }
    }
}
2 Likes

This seems crazy as the SwiftUI team recommended to use the availability checks inside ViewBuilders, but didn’t mention this and the implications of it at all

1 Like