SwiftUI: using view modifiers between different iOS versions without #available
Asked Answered
H

5

19

I use the following code snippet (in Xcode 13 Beta 5 and deployment target set to 14.0) to apply view modifiers conditionally according to iOS version:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .modifyFor(iOS14: {
                $0.onAppear {
                    //do some stuff
                }
            }, iOS15: {
                $0.task { //<---- Error: 'task(priority:_:)' is only available in iOS 15.0 or newer
                    //do some stuff
                }
            })
    }
}

struct CompatibleView<Input: View,
                      Output14: View,
                      Output15: View>: View {
    var content: Input
    var iOS14modifier: ((Input) -> Output14)?
    var iOS15modifier: ((Input) -> Output15)?
    
   @ViewBuilder var body: some View {
        if #available(iOS 15, *) {
            if let modifier = iOS15modifier {
                 modifier(content)
            }
            else { content }
        }
        else {
            if let modifier = iOS14modifier {
                 modifier(content)
            }
            else { content }
        }
    }
}

extension View {
    func modifyFor<T: View, U: View>(iOS14: ((Self) -> T)? = nil,
                                     iOS15: ((Self) -> U)? = nil) -> some View {
         CompatibleView(content: self,
                                  iOS14modifier: iOS14,
                                  iOS15modifier: iOS15)
    }
}

this code works great as long as I don't use iOS 15's view modifiers, but if I want to use any of those modifiers (like Task for ex.) then I need to use the #available directive which's an option I don't wanna opt in, because my codebase is large, there are many parts that should adopt the new iOS 15 modifiers and by using #available everywhere in the code will make it looks like a dish of Lasagna.

how to make this piece of code compiles in a clean way and without using the #available check ?

Harless answered 23/8, 2021 at 11:48 Comment(5)
I am wondering how your codes build in xCode or compiles! In same time you are returning () -> View or (View) -> View! How could be not an issue?!Redeemer
copy-paste it as is (but remove the .task modifier) and it compiles.Harless
@JAHelia: see this thread for a possible solution: developer.apple.com/forums/thread/652827.Anaemic
Maybe you can consider creating an extension for OS checks.Ocker
Check this .if(.iOS18) {}Titer
V
44

The best solution for so far I've figured out is to add simple modify extension function for view and use that. It's useful if availability check for modifier is needed only in one place. If needed in more than one place, then create new modifier function.

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

And using it would be:

Text("Good")
    .modify {
        if #available(iOS 15.0, *) {
            $0.badge(2)
        } else {
            // Fallback on earlier versions
        }
    }

EDIT:

@ViewBuilder
func modify(@ViewBuilder _ transform: (Self) -> (some View)?) -> some View {
    if let view = transform(self), !(view is EmptyView) {
        view
    } else {
        self
    }
}

This allows us not to define fallback if not required and the view will stay untouchable.

Text("Good")
    .modify {
        if #available(iOS 15.0, *) {
            $0.badge(2)
        }
    }
Vav answered 21/2, 2022 at 9:24 Comment(0)
A
9

There is no way to do this without 'if #available', but there is a way to structure it in a somewhat clean way.

Define your own View Modifier on a wrapper View:

struct Backport<Content> {
    let content: Content
}

extension View {
    var backport: Backport<Self> { Backport(content: self) }
}

extension Backport where Content: View {
    @ViewBuilder func badge(_ count: Int) -> some View {
        if #available(iOS 15, *) {
            content.badge(count)
        } else {
            content
        }
    }
}

You can then use these like this:

TabView {
    Color.yellow
        .tabItem {
            Label("Example", systemImage: "hand.raised")
        }
        .backport.badge(5)
}

Blog post about it: Using iOS-15-only View modifiers in older iOS versions

Aland answered 9/10, 2021 at 10:51 Comment(2)
While this seems like it should work, it still crashes for me (eg. when trying to conditionally iOS 16 APIs on iOS 15) just like the version without .backport :(. https://mcmap.net/q/665648/-code-behind-available-ios-16-0-check-is-called-on-ios-15-causing-crash-on-symbol-not-found-in-viewmodifier/38729Josejosee
the problem of this solution is you need to call backport before each modifier which is incompatible with old iOS versionsSandry
O
1

You can create a simple extension on View with @ViewBuilder

fileprivate extension View {
        @ViewBuilder
        var tabBarTintColor: some View {
            if #available(iOS 16, *) {
                self.tint(.red)
            } else {
                self.accentColor(.red)
            }
        }
    }

To use it just have it chained with your existing view

TabView() 
.tabBarTintColor
Oaken answered 22/12, 2022 at 0:5 Comment(1)
@Sandry you can just remove the fileprivate on the extension and it will be available internally in the module or you can even make it publicOaken
W
-2

There is no point because even if you did back-port a modifier named task (which is how this problem is normally solved) you won’t be able to use all the magic of async/await inside which is what it was designed for. If you have a good reason for not targeting iOS 15 (I don’t know any good ones) then just continue to use onAppear as normal and either standard dispatch queue async or Combine in an @StateObject.

Wellchosen answered 24/8, 2021 at 11:14 Comment(7)
the point is: I want to leverage iOS 15's modifiers in my iOS 14 project, such as .listRowSeparator and tens of other modifiers, so that when time comes to ditch iOS 14 support that will be a breeze. Currently I want to support both OS'es + full power of 15Harless
Changing style is different than task vs onAppear. You can run into this easily with target differences such as showing a list in a watch vs iOS. I use a BridgeView for this. It keeps all the ugly in the Bridge and the rest is reusable across targetsIssykkul
@loremipsum the OP wants this done in the same iOS target.Wellchosen
@Harless just target iOS 15. The tiny number of users that stay on iOS 14 can still download an old version of the app.Wellchosen
@Wellchosen I just mentioned it as a similar use case where the same action is required when using a .listRowSeparator and differentiating between iOS 14 and iOS 15 and picking .listStyle(CarouselListStyle()) for watch and .listStyle(PlainListStyle()) for a phone. It can be handled in almost the exact same way. I had an answer posted that demonstrated this but deleted it because it is an alternative for the user not a solution for the problem. .task and onAppear are too different to be handled in the same way.Issykkul
@loremipsum it's a little simpler than your answer. Simply do #if os(watchOS) .listStyle(CarouselListStyle()) #else .listStyle(PlainListStyle()) #endifWellchosen
@Wellchosen this is called #if for postfix member expressions which's only available in iOS 15 and Swift 5.5Harless
R
-5

There is no logical use case for that modifier for the issue you are trying to solve! You have no idea, how many times your app would check your condition about availability of iOS15 in each render! Maybe 1000 of times! Insane number of control which is totally bad idea! instead use deferent Views for each scenarios like this, it would checked just one time:

WindowGroup {
    
    if #available(iOS 15, *) {
        
        ContentView_For_iOS15()
        
    }
    else {
        
        ContentView_For_No_iOS15()
        
    }

}
Redeemer answered 25/8, 2021 at 4:1 Comment(4)
and if your view is 200 lines of code, you will end up maintaining ~400 lines of codeHarless
There is not such a big difference between iOS 15 and 14, mostly you would copy and paste the code and small edit on codes for what you want.Redeemer
Is it really a performance issue?Forjudge
Yes, if you use the other answer you make SwiftUI to check every single time about availability of a version! In my answer SwiftUI would check it just for one time! The other person just complained about having codes for same thing 2 times, but he is sacrificing the performance instead having some few codes more, which having more few codes would not effect the performance. Because in each codes SwiftUI would not check for availability of a version and it would just read the codes and run it.Redeemer

© 2022 - 2024 — McMap. All rights reserved.