Credits: Apple Inc. (WWDC`21 session 10063)

Customise and resize sheets in SwiftUI

Eugene Dudnyk
Nerd For Tech
Published in
5 min readOct 1, 2021

--

The title of this story is similar to WWDC`21 session 10063, except that on WWDC there was “UIKit” instead of “SwiftUI” in the title.

And, you guessed it, we will make a sandwich.

To ensure that you can follow the further material which is laid out in this story, I highly recommend watching the aforementioned WWDC session first.

Problem Statement

  1. SwiftUI is too opinionated (it really is). Especially about view controllers, and especially about the presented ones. The .sheet(...) , .fullScreenCover(...) and .popover(...) modifiers are the very basic simplification of what UIKit has to offer on iOS 15.
  2. SwiftUI has no guidelines about interfacing with UIKit for presentation of SwiftUI Views via UIKit APIs. I guess that’s because the management of the presentation state in UIKit is a mess (you’ll see later why) and Apple wanted to obscure it with the limited opinionated API in SwiftUI.

Use-cases which are NOT supported in SwiftUI

  • Customisation of detents for .sheet(...) modifier to configure resizable sheets
  • Customisation of detents for .popover(...) modifier to configure resizable sheets in which the popover adapts to when the horizontal size class of the scene is compact.
  • Modal presentation style .custom, custom interactive transitions and custom sizing of the presented view controller
  • Presentation “over current context”
  • Different presentation styles for the same type of the source of truth (there are three modifiers .sheet(item: ...), .popover(item: ...), .fullScreenCover(item: ...) in SwiftUI instead of a single one parametrised with the presentation style). Let’s say we have 2 items, for the first one we want to show .sheet, and for the second one we want to show .fullScreenCover just after the .sheet is dismissed. Even if we make a journey into a rabbit hole of applying the second modifier after the first one, SwiftUI won’t show us what we want. For the second item it will reuse the presentation style of the first one, i.e. it will present sheet for the second time instead of the full screen cover, not paying attention to a different modifier
  • Customisation of the preferred properties of the presented view controller, like:
public var definesPresentationContext
public
var disablesAutomaticKeyboardDismissal
public var focusGroupIdentifier
public var isModalInPresentation
public
var modalPresentationCapturesStatusBarAppearance
public
var modalTransitionStyle
public
var preferredContentSize
public var preferredScreenEdgesDeferringSystemGestures
public var preferredStatusBarStyle
public var preferredStatusBarUpdateAnimation
public var prefersPointerLocked
public var prefersHomeIndicatorAutoHidden
public var providesPresentationContextTransitionStyle
public
var restoresFocusAfterTransition

If any of these use-cases are yours, welcome to the club — this story is exactly for you!

SwiftUI has no guidelines about interfacing with UIKit for presentation of SwiftUI Views

Having found my use-cases in the aforementioned list, I realised that the only solution opportunity which remains open is to make a sandwich. The bottom and the top slices of the bread would represent SwiftUI presenteR View and SwiftUI presenteD View, and the tastiest part in the middle which glues them together and controls the presentation will be represented by the UIKit.

But is that possible? I suggest you to take couple of minutes before you continue reading, and to think how you would implement such a sandwich.

Sandwich Solution

Requirements

  1. Similar API to the one offered by SwiftUI (i.e. .sheet(...)), but without the mentioned limitations — we’ll need two modifiers for presentation, one controlled by a Bool isPresented , and the other one controlled by the optional Identifiable item. Also, since SwiftUI does not allow to instantiate DismissAction struct, we’ll need an analogue of the built-in isPresented and dismiss environment values, but our custom ones will reflect and control the state of our presentation.
  2. The lifecycle of the bottom sandwich part — of the SwiftUI View where the logic of presentation is handled, should incorporate and correctly handle the messy lifecycle of the nested UIKit view controller presentation graph. As you may know, presentations in UIKit can be nested, the presented view controller can present the next one on top of itself, so it’s important that we dismiss the correct view controller when needed.

I’ve built a library called SheeKit, which implements these requirements.

It is inspired by UIKitPresentationModifier library by Mauricio T Zaquia, but has a few very important differences and enhancements.

The key pillars of the implementation

  1. Allow to customize the preferred presented view controller parameters via UIViewControllerProxy
The snippet of the presented view controller payload

2. Support all modal presentation styles which are compatible with SwiftUI and don’t remove the presenting view controller from the UIWindow

The snippet of supported modal presentation styles

3. Support all UISheetPresentationController properties in SheetProperties

The snippet of the sheet presentation controller payload

4. Serve all these powerful features through the simplest possible SwiftUI API

The snippet of presentation modifiers — the entry points into the presentation

Interfacing with UIKit presentations

The hardest part …

After many trials and errors, the conclusion to which I arrived is the following:

The only thing in SwiftUI which is capable to manage any UIViewController-related lifecycles is UIViewControllerRepresentable

It sounds obvious, but for me it was not the first, and not even the second idea I tried. Maybe, because no-one will immediately think of creating the redundant UIViewController that does not do anything except being the SwiftUI-managed child of its parent, which also Presents. And apparently, that ended up being the most crucial feature of the whole implementation.

In order to handle callbacks about interactive dismiss (which is a part of the presentation lifecycle), we’ll also need to have the delegate of the UIPresentationController — and the role of Coordinator of UIViewControllerRepresentable suites best for this. AdaptiveDelegate instance will be our Coordinator.

The snippet of the initial idea behind interfacing with UIKit

Know what you dismiss

In UIKit some of the tools to control the presentation state, are ambiguous.

  1. When you call .dismiss(animated:completion:) method of the view controller, it can:
  • dismiss the nested presentedViewController if there is one
  • dismiss the callee or its parent if it is presented

Both cases can happen if you call dismiss(animated:completion:) twice on the same callee.

2. If you presented the view controller VC2 from the view controller VC1 in over-current-context presentation mode, and VC1.definesPresentationContext == false, VC1.presentedViewController will be nil , but VC2.presentingViewController will not be nil.

This is an ambiguous mess, but we somehow have to deal with it. Fortunately, we can attribute the hosting controller we presented (sheetHost), with the Item.ID which identifies correspondence between the state of the SheetPresenterControllerRepresentable in the bottom sandwich part, and the currently presented view controller. Then, the decision about the need of dismissing sheetHost goes as follows:

  • When we present the sheet host, we assign the item identifier to it:
sheetHost.itemId = SheetPresenterControllerRepresentable.item.id
  • When the identifier of the currently presented view controller sheetHost.itemId matches to the SheetPresenterControllerRepresentable.item.id — we do nothing
  • When the identifier of the currently presented view controller sheetHost.itemId is non-nil and does not match the SheetPresenterControllerRepresentable.item.id — we nullify sheetHost.itemId and dismiss sheetHost
  • We never call dismiss on sheetHost.presentingViewController when sheetHost.itemId == nil — this ensures that we dismiss the right controller at the right time.
Interfacing with UIKit — the snippet of presented hosting controller which hosts the sheet content
Interfacing with UIKit — the snippet of presentation and dismissal logic in SheetPresenterControllerRepresentable

And, finally, the modifier which injects the SheetPresenterControllerRepresentable into the SwiftUI View graph

Interfacing with UIKit — the snippet of presentation and dismissal logic in SheetPresenterControllerRepresentable

There are additional proofs of readiness for presentation to be made, like waiting for presenter.view to get the window, or adding the presenter into a nearest parent we can find in case SwiftUI injects only the view of the presenter into the view hierarchy, but does not inject the presenter into the view controller hierarchy, which was the case for iOS 13. I will not cover those additional checks in this story, because it happened to be already pretty long. If you are curious, you can see the full implementation in the SheeKit repository.

I recorded the video that demonstrates the SheeKit capabilities with the demo app.

The Demo

Thanks for reading! Don’t hesitate to let me know what you think of SheeKit.

--

--

Eugene Dudnyk
Nerd For Tech

iOS / Fullstack Design System Developer with more than 10 years of iOS development experience.