Apple tried to offer a head start on writing SwiftUI apps by providing us with some very nice looking tutorials. The documentation however is still severely lacking and the framework still seems in full development, noticeable by broken/missing functionality and some pretty big changes in the past betas.
When I started rewriting CameraControl for SwiftUI I quickly ran into issues with some of the built-in functionality, most notable: modal views. Although we did get some incremental improvements during the past few betas the functionality for displaying modal views still seems unstable and lacking in functionality.
So I decided to roll my own solution, as both a learning exercise and as backup if Apple doesn’t fill the gaps in missing functionality before the final release of iOS 13. I’m pretty happy with the resulting BetterSheet library and it also gave me a deeper understanding of SwiftUI. In this article I will share some of my findings.
SwiftUI offers a few ways to provide data to siblings in the view hierarchy. Of course you can pass data to other views during initialisation, however there are two other interesting ways to share data with siblings: environment values and environment objects.
Environment values offer a simple way to pass values/objects down the view hierarchy. SwiftUI offers a wide range of built-in environment values. Some of these are set automatically, others you need to set yourself.
To define your own environment value you first need to create an environment key:
To make the new environment key available you have to create an extension on the EnvironmentValues class:
The environment key has a simple defaultValue property which also implicitly defines the value type.
To retrieve the value for an environment key you can use the @Environment property wrapper:
Because you’ve defined a default value for your environment key, the environment value should always be available. However, you can of course set it to a different value:
If you set an environment value in the view hierarchy it will be available to the view and all of its children. If you specify a different environment value for one of the children than this value will only be available for this child view and its children etc.
Environment objects in a lot of ways work similar to environment values, but the environment key is the object’s class and the class is required to implement the ObservableObject protocol.
Let's look at an example:
In this example we have a GameState class that implements the ObservableObject protocol (in earlier versions of SwiftUI this protocol was part of SwiftUI instead of Combine and was called BindableObject). We use the new @Published property wrapper to create instance variables for the current game level and score. The @Published property wrapper will automatically trigger the auto-generated willChange publisher from its willSet.
The GameView creates an instance of the GameState class and uses the @ObservedObject property delegate to listen for changes and update the highscore in its view accordingly. However we also make the game state available as an environment object.
This means our GameProgressView can access the shared game progress object using the @EnvironmentObject property wrapper. This property wrapper looks at the specified property type and uses this to retrieve the environment object from the environment and will also listen to changes so that it can update its view accordingly.
Be aware that your app will crash with a fatal error if your view expects an environment object which has not been set!
If you’ve done some experimenting with SwiftUI you might have wondered how view modifiers like navigationBarTitle get their data delivered to the top NavigationView. The answer is: using a preference. Like how environment values and objects let you deliver values down the view hierarchy, preferences let you deliver values up in the view hierarchy.
Let’s look at an example. To make a preference available you first need to create a preference key:
The definition of a preference key consists of a few things. First you need to define the value type for the preference. In this example I’ve used a simple Bool. Next you need to define a default value. This is the value that is used if you access this preference and no value has been set in the view hierarchy. And finally you need to implement the reduce method which is used to combine values for the preference that are set at different levels in the view hierarchy. In case of the GameProgressHiddenPreferenceKey its value is true if somewhere in the view hierarchy it has been set to true.
To set a preference value you can use the preference modifier:
Later we will look at a technique to prettify / simplify the usage of our preference key.
To listen for preference changes you can use the onPreferenceChange view modifier:
Preferences can be a very powerful tool, but they are mainly useful for generic view components like NavigationView that can contain complex view hierarchies themselves.
Although our custom preference works, the syntax for setting a preference value is a bit convoluted. Let’s improve the API for our preference:
This allows us to set our preference using a simple view modifier:
Although functionality-wise this counts as a view modifier, we did not technically create a view modifier yet. To create a true view modifier we need to implement the ViewModifier protocol:
To use this modifier you need to use the modifier view modifier on your view:
Just as with the preference we can improve on this by creating an extension on View:
After which we can just use the following code again:
As you can see creating a view modifier for a simple preference change is not really useful. You can just write the functionality in the View extension. However, a view modifier also gives you access to environment values and objects and allows you to define your own state, just like for a view. This allows for far more powerful modifiers.
While writing BetterSheet I’ve run into some gotchas when creating and using (my own) view modifiers. The most important one is that if your modifier body function doesn’t change the view hierarchy, it won’t be called!
Now you probably think, why would you create a modifier that doesn’t do anything? Well, initially I tried to present a view controller from a view modifier based on the value of a Bool binding. As the view controller presented a different view hierarchy there was no need for my view modifier to modify the view on which it was called. But SwiftUI is somehow capable of detecting that your function returns the same value as it is passing, without calling the function, so it simply doesn’t call your function at all! Later I learned this wasn’t the correct way of presenting my view controller and I switched to setting a preference instead.
But the story doesn’t end there, some of the built-in modifier functions don’t change your view hierarchy either. For example, if we would create a view modifier that only calls the onPreferenceChange view modifier this doesn’t change the view hierarchy, and as such SwiftUI won’t call our function body. The difficulty here that it isn’t always clear which modifiers change the view hierarchy, and which don’t. So this is something to be aware of when writing your own modifiers!
Displaying (modal) sheets has gone through quite some changes in the iOS 13 betas. In beta 1 we were supposed to use a PresentationButton view or the less known presentation(_:) modifier. In beta 3 PresentationButton was renamed to PresentationLink to be more consistent with NavigationLink. PresentationLink was later deprecated and removed and the presentation modifier has now been replaced by the current sheet(isPresented:onDismiss:content:) and sheet(item:onDismiss:content:) modifiers which are more in line with how alerts and action sheets are shown.
Although PresentationLink has been deprecated, it is quite easy to create our own version:
This also demonstrates how the sheet modifier is used. You give it a Bool binding and, depending on the value of the Bool binding, it will open a sheet with the destination view.
An important thing to note, our PresentationLink (and the old one that was part of SwiftUI) requires a constructed view for the destination. The sheet modifier however uses a closure for the destination view. The latter allows the destination view to be constructed just-in-time. This is probably the main reason why Apple decided to deprecate the PresentationLink view.
The sheet modifier comes in two flavors. We already looked at the sheet(isPresented:onDismiss:content:) view modifier, but there is also a sheet(item:onDismiss:content:) view modifier. The latter requires a binding to an optional item value. The selected value will be passed to the destination closure so the destination view can be different depending on the selected value.
The sheet(item:onDismiss:content:) view modifier also supports automatically dismissing and presenting a new destination view if the value changes. However in the current beta of iOS 13 (beta 6) this functionality doesn’t behave the way it should. It currently first updates the destination view for the new value, then dismisses the sheet and then presents the same sheet again. If it behaves that way it could just as well skip the dismiss/present step and simply update the destination view. It would be better if it first dismisses the current sheet and then displays a new sheet with a newly generated destination sheet. This is one of the things I fixed with BetterSheet.
Some other short-comings of the current SwiftUI modal sheet implementation which I tried to address with BetterSheet:
- Sheets don’t always open a second time. This has been an issue since the first betas. For some reason sometimes after displaying and dismissing a sheet it can’t be opened a second time. Especially when you have a complex view hierarchy and your binding doesn’t only trigger showing the sheet but also modifies the view hierarchy itself.
- You can only have one sheet modifier per view. It seems Apple uses preferences internally and setting a preference a second time on the exact same view results in overwriting the previous value.
- You can’t prevent a user from using swipe-to-dismiss on a modal sheet. Modals in iOS 13 are not full screen anymore and allow you to use swipe-to-dismiss. Apple added a new property isModalInPresentation to UIViewController which can be set to true if you want to prevent the user from dismissing your modal using a swipe gesture. However this functionality is currently not exposed in SwiftUI. This means you can’t force your user to choose what needs to be done on dismiss (e.g. saving or rolling back changes).
The following example shows how BetterSheet allows you to have more than one betterSheet view modifier on the same view and how it allows you to have more control on dismissing sheets:
This example only allows you to dismiss the sheet with a swipe if you didn’t make any changes. If you try to dismiss the sheet after you did make changes it will display an action sheet where you can choose what action needs to be taken.
It also uses an item binding to display a different destination sheet depending on the selected item. This can also be done with the normal sheet modifier, however, as mentioned before, it doesn’t behave properly if your value changes when the sheet is still being displayed.
I still hope Apple will address the mentioned issues and add the missing features to the default sheet modifiers before the final release of iOS 13. In the meantime, feel free to use BetterSheet (MIT license). BetterSheet is available both as Swift Package and as pod.
You can find the complete code for the game example used above here.