Alexito's World

A world of coding 💻, by Alejandro Martinez

Backport SwiftUI modifiers

Every year SwiftUI improves and gets new modifiers that widen the capabilities of the framework. Sadly, as responsible developers, we can't use any of that because our users take some time to update and, as opposed to the Android ecosystem where Jetpack Compose is just a library embedded in every app, we need to wait for our users to be at the required minimum OS version to enjoy those fancy new tools.

Or do we?

There are some APIs that are easy to backport if we disregard the old versions of the operative system. This may sound awful but is a valid option that is worth considering. If you know you have a very little percentage of users stuck in the old version but you can't get rid of it because of some policy, you know the impact is very minimal. And if time has already passed and dropping that version is on the horizon, you have more reason to plan for the future and not get stuck in the past. No matter what, we should make sure that users in the old version don't get a bad experience. It may be not perfect and modern, but they still should be able to use the app properly. It's a case-by-case decision that you have to take, but if you do, here is how to pull it off.

Porting SwiftUI modifiers

We will focus on SwiftUI modifiers. Other features can also be backported, but they will need slightly different techniques. The one for modifiers is actually quite simple, but it always depends on the functionality itself.

I've used this technique for a while, and it works very well. For example the scrollDismissesKeyboard(_:) modifier is a good candidate. It's a new functionality that users in old versions won't miss because they wouldn't have access to it before (well, unless your app was UIKit, again, case by case).

The first step is to copy the modifier function signature in your own View extension:

extension View {
    /// docs...
    @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
    public func scrollDismissesKeyboard(_ mode: ScrollDismissesKeyboardMode) -> some View
}

I also recommend copying over the documentation so the users of this function will get all the info they need. Just as if it was the legitimate function from the framework.

Conceptually the implementation of the function is very simple: we check in which OS version we are and we call the SwiftUI function when available and fallback to something else when not. This applies to all modifiers you want to port, but there are two things to consider.

Fallback on old OS

What do you do as a fallback? That's the first question to consider and it will depend on the feature, its complexity, how much worth it is for you and a multitude of other aspects, technical and non-technical. This is the "case by case" part of this work.

In this example, the fallback is simply to do nothing. And that works for many cases, really. To implement this, we just need to return self in the old version so our modifier does nothing with the view.

if #available(iOS 16.0, *) {
    self
        .scrollDismissesKeyboard(mode)
} else {
    self
}

That's what we want, easy, right? Well, remember that for this to work, we need to opt in this function into the ViewBuilder DSL. Check out ViewBuilder vs. AnyView for more details about this.

If your modifier didn't take any parameters, that would be all you needed, but that's rarely the case.

Backport parameters

Most modifiers take some parameters to adjust its functionality. Those types are often new and thus not present in old versions, so we need to port those too.

The process for that is similar, just copy the existing types into your own file.

public struct ScrollDismissesKeyboardMode {
    public static var automatic: ScrollDismissesKeyboardMode { get }
    public static var immediately: ScrollDismissesKeyboardMode { get }
    public static var interactively: ScrollDismissesKeyboardMode { get }
    public static var never: ScrollDismissesKeyboardMode { get }
}

Now that's a fine way to start, but I find it significantly easier to work with an enum. That way, you don't have to worry about internal implementations or storing any data. Furthermore, I also recommend prefixing the name of the type so is distinct from the SwiftUI one. This is not strictly necessary, but I've found that makes the compiler way happier as it helps distinguish the functions easily. It's also a hint to the user of the function that there are some shenanigans going on.

So let's convert that type to an enum instead:

public enum MyScrollDismissesKeyboardMode {
    case automatic
    case immediately
    case interactively
    case never
}

This makes it so the usage side can use the same dot syntax and names as with the original function while using our custom types. But we still need to give SwiftUI its own types, so I always add a property to do the conversion.

// MyScrollDismissesKeyboardMode
    @available(iOS 16.0, *)
    var swiftUI: ScrollDismissesKeyboardMode {
        switch self {
        case .automatic: return .automatic
        case .immediately: return .immediately
        case .interactively: return .interactively
        case .never: return .never
        }
    }

This property is only available in the new OS since when we call it we will be in the correct version.

Now we can switch the function to use the new type.

   @ViewBuilder func scrollDismissesKeyboard(
      _ mode: MyScrollDismissesKeyboardMode
  ) -> some View {
      if #available(iOS 16.0, *) {
          self
              .scrollDismissesKeyboard(mode.swiftUI)
      } else {
          self
      }
  }

And that's the function fully back ported. We take our custom parameter, and if we are in the new version, convert it to the SwiftUI one and call the real modifier. Otherwise, we just return the view unmodified.

Using our function

With this, the usage side would look exactly as with the real code.

SomeView()
  .scrollDismissesKeyboard(.immediately)

For that to work you just need to make sure your function is in your module, or if it's in a separate package (which I always recommend) you need to import it in your file.

This works because your code always takes precedence over system frameworks. This means the compiler will pick your custom function and types before SwiftUI's.

Obsoleted

Another very useful tip is to use an availability annotation to ensure the compiler will remind you to remove this code when your minimum target matches the one that introduced the modifier. For this, we can add an @available with the obsoleted parameter.

@available(iOS, obsoleted: 16.0, message: "SwiftUI.View.scrollDismissesKeyboard is available on iOS 16.")

This is very helpful and makes the compiler work for you.

Pick your battles

Just like deciding what to do as a fallback based on tradeoffs, choosing which parameters and features to support is important too.

For example, if we want to port presentationDetents(_:) we will find out is a bit more complex. The custom detent needs some generic work that might not be trivial, so when you see that sort of complexity you can decide to just ignore it. Remember that this code is for you. It's likely that you are not trying to make a full featured framework for the entire world, so if you don't need some functionality right now, just skip it. By the time you need it, you will use the native solution already.

So in this case, you may decide to simplify the detents you offer:

public enum MyPresentationDetent: Hashable {
    case medium
    case large
    case fraction(_ fraction: CGFloat)
    case height(_ height: CGFloat)
    // no custom detent supported
}

More than back-porting

This technique is not only useful to back port functionality to older OSs but also to improve functionality and make it work the way you want. You can use the same technique, ignoring the version checks, and that will give you a way to offer more reusable parameters and options and to have a reusable place to include extra logic, all of that while keeping the same syntax as the system.

This is beneficial because it allows your code to be easily adjusted when Apple adds new options natively, saving you from having to completely refactor multiple views.

Conclusion

This technique is just another tool on your belt. It's up to you to decide when is appropriate to use it, but I recommend you to think about it. There are plenty of times when we could use modern APIs with little problem while keeping compatibility with older systems.

Just have it in mind, analyze the tradeoffs and decide.

If you liked this article please consider supporting me