#available problem

Hello,

I'm running into an issue with the if #available(iOS 15, *) check. It's my understanding this is a run-time check which is what I require but I'm running into an issue on a SwiftUI utility I'm making where even though I wrap the caller to a function that uses an iOS 15 API the compiler is still trying to force me to guard the offending API at the implementation site.

To illustrate below I've included the simplest example of what I'm working on, the idea is to provide an if SwiftUI View modifier to allow one of two transform closures to be called based on iOS support.

extension View {

    @ViewBuilder func if_iOS15<Content>(_ trueTransform: (Self) -> Content,
                                          else falseTransform: (Self) -> Content) -> some View where Content: View {

        if #available(iOS 15, *) {
            trueTransform(self)
        } else {
            falseTransform(self)
        }
    }
}

// Desired Usage
struct MyView: View {

    var body: some View {
        Circle()
            .if_iOS15 { circle in
                circle.fill(.cyan) // Compiler error: 'cyan' is only available in iOS 15.0 or newer
            } else: { circle in
                circle.fill(Color.blue)
            }
    }
}

My actual implementation is a bit more fleshed out and doesn't limit itself to a specific hardcoded iOS version regardless, the example above is the minimum to expose the issue.

You can see in the MyView example, we're calling the if modified on Circle, the if modifier method performs a run-time checking using #available and calls the appropriate "transform" closure. This guarantees at run-time the code circle.fill(.cyan) will only be called on devices running iOS 15.

Yet when I build the compiler is having none of that and is throwing the error "'cyan' is only available in iOS 15.0 or newer".

I would kind of expect the compiler to use static analysis and know that circle.fill(.cyan) is in a closure that is already guarded by an #available directive.

// Compiler forced workaround
struct MyView2: View {

    var body: some View {
        Circle()
            .if_iOS15 { circle in
                guard #available(iOS 15, *) else { return circle.eraseToAnyView() }
                return circle.fill(.cyan).eraseToAnyView()
            } else: { circle in
                return circle.fill(Color.blue).eraseToAnyView()
            }
    }
}

extension View {
    func eraseToAnyView() -> AnyView {
        return AnyView(self)
    }
}

The compiler forces me to add a guard to the offending code as shown in MyView2 above even though it's guaranteed to be safe.

This really complicates my example as now I've got three paths in my version checking logic:

  1. if iOS 15 then, if NOT iOS 15 return unmodified View (type erased)
  2. if iOS 15 then, if iOS 15 call iOS 15 API (cyan fill)
  3. if NOT iOS 15 then, call older version API (blue fill)
        Circle() // -> Circle
            .if_iOS15 { circle in 
                guard #available(iOS 15, *) else { return circle // -> Circle } // Error: Cannot convert return expression of type 'Circle' to return type 'some View'
                return circle.fill(.cyan) // -> some View
            } else: { circle in
                return circle.fill(Color.blue) // -> some View
            } -> ?

SwiftUI super complicates this due to @ViewBuilder having to return some View which means on the completely redundant but compiler enforced guard #available(iOS 15, *) else { return circle.eraseToAnyView() } we have to erase Circle to AnyView and then erase the other instances so that they all appear as the same concrete type, this wouldn't be needed at all if the redundant guard wasn't there as the other two instances return some View.

In what IMHO should be a perfectly working example this wouldn't be an issue:

        Circle() // -> Circle
            .if_iOS15 { circle in
                circle.fill(.cyan) // -> some View
            } else: { circle in
                circle.fill(Color.blue) // -> some View
            } // -> Circle

I kind of if feel the "is only available in iOS 15.0 or newer" error I'm getting is a false negative as I've already guarded against calling that API on iOS 15 it's just the compiler doesn't seem smart enough to agree with me.

It seems very unintuitive that a run-time statement like if #available(...) is being forced on me in such a limiting way at compile time for no good reason that I can see.

Is this a known limitation, is there any way I can force the compiler to give me a break when I've complied already by adding a run-time check?

1 Like

Unfortunately, it looks like you've run into a fundamental limitation in the Swift language: The compiler isn't currently smart enough to tell that the trueTransform closure in if_iOS15 will only be executed on iOS 15. In principle, it should be possible for the compiler to determine this such that the function would work as you would expect it to. You'll have to file a feature request.

This is false. You do not have to use opaque return types with @ViewBuilder. You just have to use types that conform to View. Furthermore, you can use multiple different types in a @ViewBuilder function. Consider the following view extension:

extension View {

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

}

Notice that the trueContent and falseContent use different placeholder types. This is what you should've done in your if_iOS15 view extension instead of using the Concrete placeholder type in both trueTransform and falseTransform. It prevents you from having to use eraseToAnyView.

This part makes absolutely no sense. You've already established that the if_iOS15 method doesn't apply the availability check to the content in the trueTransform closure. So, then, why are you using it at all? It's completely useless.

Instead, you should use the following extension:

extension View {
    
    func modify<Content: View>(
        @ViewBuilder _ transform: (Self) -> Content
    ) -> some View {
        return transform(self)
    }

}

Now, you can do the following:

Circle()
    .modify { circle in
        if #available(iOS 15, *) {
            circle.fill(Color.cyan)
        }
        else {
            // notice that this expression has a different type than
            // the one above
            circle.fill(Color.primary)
                .shadow(radius: 5)
        }
    }

Notice that the transform closure is annotated with @ViewBuilder, which prevents you from having to use the same types in the if branch and else branch of the closure.

Finally, note that the following is possible without any custom extensions:

struct ContentView: View {
   
    var body: some View {

        if #available(iOS 15, *) {
            AsyncImage(url: imageURL)
        }
        else {
            Image("image")
        }

    }
    
}

See also Adding SwiftUI’s ViewBuilder attribute to functions | Swift by Sundell

That's what I thought, was hoping I was wrong. Is there a specific process for opening a feature request, I'm not really a compiler expert just an end-user so I don't have a solution just the problem.

Of course, not sure why I didn't think of that. Thanks!

Yes it makes no sense, that is why I was illustrating it :grinning:, my point was IMHO it should be possible to do what I wanted to achieve here, that is, create an "if OS modifier" but I can't because I would need to do OS check again in the closure body.

I didn't know at the time of designing my API that the #availability run-time check wouldn't be possible even though it would have been safe because of an over-sensitive compiler. In my mind the compiler should be there to help yet in this instance it's a hindrance and unintuitive because I've already covered the code paths in question with a run-time #availability check. My point here was to illustrate a use-case of needing this behaviour because this won't work in SwiftUI:

    Circle() // -> Circle
    #if #available(iOS 15, *)
        .fill(.cyan) // -> some View
    #else
        .fill(Color.blue)
    #endif

I'm aware of other ways of doing this but I wanted something more elegant, the original aim was to make a reusable modifier that would let you switch on OS version so you can conditionally apply one modifier or the other.

There are plenty of examples on the web using the ternary operator to switch the modifier argument values but very few solutions around making entire modifiers optional based on boolean logic.

Yep, I'm aware of that but doesn't help me here as I don't want to switch between different View I want to apply a View modifier conditionally to prevent having to duplicate of View code and remain DRY.

Implemented in Swift 5.5: #if for postfix member expressions

Edit: actually no, #available is not compatible with #if.

And here lies the issue, it's not a compile-time scenario it's a run-time condition that is enforced by the compiler. Makes no sense to me that the compiler should be failing because I've not satisfied some run-time code, let me get it wrong and crash instead of enforcing compile-time checks like this.

#available isn’t just a run-time check; the entire point is that it converts a run-time condition into a compile-time check. You can think of this the same way as types: you can’t just assume an arbitrary UIView is a UITableView; you have to downcast.

2 Likes

Sure but I’m pretty certain the compiler can’t tell which iOS version my code is running on at compile-time so the compiler error is surely nothing more than nannying?

It’s a compile-time check that I’ve implemented a run-time check. But as I’ve pointed out in my example it’s flawed and limiting.

@jrose It should still be possible in principle to improve the compiler such that the if_iOS15 works, correct? The compiler should be able to guarantee that the trueTransform closure parameter is only executed when iOS 15 is available because it is wrapped in an if #available(iOS 15, *) condition. Therefore, the body of this closure should be allowed to use iOS 15 APIs without an additional check.

I don’t think a check that prevents a crash at runtime is “nannying”; it’s the entire point of non-syntactic warnings and errors, and all of type-checking that isn’t for optimization purposes. The point of #available is that you will never get a crash related to an API being missing. Compared to the state of things before, that’s amazing.

1 Like

Yes and no. The compiler doesn’t generally reason across function boundaries so that changing code in a function body doesn’t result in errors elsewhere. You could have a language that did do that, or invent some kind of annotation for the function arguments to adjust this, but it’s not in Swift today. (I kind of like that idea though: put @available on a closure parameter and then it can only be called when checked, like @available on the whole function. It gets complicated with functions-as-values, though.)

3 Likes

Asked and answered I think

I previously considered such feature. Is a feature like this possible?

@ViewBuilder
func iOS15_swipeActions<@available(iOS 15, *) T: View>(
    edge: @available(iOS 15, *) @autoclosure () -> HorizontalEdge = .trailing, 
    allowsFullSwipe: Bool = true, 
    content: @available(iOS 15, *) () -> T
) -> some View {
    // T, edge, and content must be used available context
    if #available(iOS 15, *) {
        self.swipeActions(edge: edge(), allowsFullSwipe: allowsFullSwipe, content: content)
    } else {
        self
    }
}

I think we almost already have this. A function parameter is basically a local variable, and while it is an error to use @available on a local variable, if you skim over the error it seems to be treated correctly with subsequent code:

func test() {
	@available(iOS 99, *)
	var i = 0 // Error: Stored properties cannot be marked potentially unavailable with '@available'

	i += 1 // Error: 'i' is only available in iOS 99 or newer
	// with a fixit to add `if #available` that correctly quells the error
}

And what happens if you add @available to a parameter? Basically the same thing:

func test2(
	@available(iOS 99, *) i: Int // '@available' attribute cannot be applied to this declaration
) -> Int {
	return i // Error: 'i' is only available in iOS 99 or newer
	// with a fixit to add `if #available` that correctly quells the error
}

So the mechanics seem to be already in place. Someone would need to remove the error at the declaration site and check that it does not enable other problematic behaviors... and then write an evolution proposal.


Edit: forgot to mention, everything is fine at the call site too:

func test3() {
	_ = test2(i: 1) // no error or warning here
}

... although not really: the availability does not propagate to the argument. If this argument was a closure calling an iOS 99 function it'd still ask you to add #available there. Maybe I was overoptimistic.

Maybe @available for normal parameter would have some restriction, though?

@available(iOS 99, *) var value: Int {
    print("evaluated") // or other process that can be called only in iOS 99 or later
    return 42
}
test2(i: value)      // it should be error, since `value` evaluated at call time

If it were a closure, they don't have such restrictions, since the values aren't evaluated at call-site, but in the if #available context.

func test4(
	@available(iOS 99, *) @autoclosure i: () -> Int
) {
    if #available(iOS 99, *) {
        print(i())
    }
}
test4(i: value)      // it would be possible
4 Likes

Yeah, the availability here has to propagate out to the call site for a closure parameter, or it's not solving the problem. (Clever use of @autoclosure; I'm not sure whether or not I like it.) I think the tricky bit is what happens when you don't immediately call the function:

func runIfPossible(_ body: @available(iOS 99, *) () -> Void) {
  if #available(iOS 99, *) { body() }
}

let run1: (@available(iOS 99, *) () -> Void) = runIfPossible // do we need to support this?
let run2: (() -> Void) = runIfPossible // removing the guarantee is safe
let run3: (@available(iOS 77, *) () -> Void) = runIfPossible // what about *relaxing* the guarantee?

let castRun = runIfPossible as Any as? (() -> Void) // is it the same type at run time?

The simplest answer is that run1 and run3 are not allowed, and that castRun does work. That is, closure parameter availability would only be available for function declarations, not arbitrary function values. This is similar to what happens with autoclosures:

func testLazy(_ value: @autoclosure () -> String) { print(value()) }

let test1: (() -> String) -> Void = testLazy // okay
let test2: (String) -> Void = testLazy // error

The main downside of all this that I can see is that now there's an availability check being done implicitly; today you can look at your code for the immediate enclosing / guarding @available or #available and know what APIs you can call without further checks. But that ship kind of already sailed with result builders. [EDIT: got "explicitly" and "implicitly" backwards.]

2 Likes