Last week, I needed to implement a resizing functionality for a modal displayed on iPad using the formSheet
modal presentation style. Basically,
We had a navigation controller presented modally, and needed a way to expand the modal to fill the screen or remain the default formSheet
size based on what View Controller was being displayed.
After lots of searching, it became clear to me that the only logical way to do this was to use a custom
modal presentation style instead, and
then subclass UIPresentationController
. This seemed like a bit too much just to get the concept of resizing on a formSheet
modal. Also, by using a custom
presentation style,
we would have to give up the free functionality provided when a formSheet
is used. For example, when a keyboard is displayed, a modal presented using formSheet
adjusts it's origin.y
, moving it to the top of the screen in order to ensure that most of the modal isn't covered by the keyboard.
After investigating a bit further, I discovered two note-worthy facts:
- That it is possible to adjust the size of the
formSheet
modal by settingpreferredContentSize
of the View controller to be displayed before the modal is presented. - And that when a new View Controller with a different
preferredContentSize
is presented in the same modal (In our case, we had aUINavigationController
as the container, and view controllers were pushed and popped as desired), the modal size doesn't change, but if the device's orientation changes, the size of the modal is then updated during thelayoutSubviews()
pass triggered by the orientation change.
So all I needed to do was to find a way to trigger a layout pass from the right superView, and this would then cause the modal to resize using the preferredContentSize
of the new View Controller being displayed. This also had to be done in a maintainable way in case there was a need to support different sizes. This rest of this blog post covers the solution I came up with after a few iterations.
I've always been a big fan of separating presentation logic into a separate class. I normally call such classes Routers
. They are also commonly known as Presenters
. The main benefit of this particular separation of concern is the lack of presentation side effects in a View Controller. Also, the router can be separately tested in order to ensure that the correct View Controllers are displayed based on particular events. I choose to have this functionality in
the router. The reasoning here is that View Controllers shouldn't care about what size they are presented in. For the most part, View Controllers should be adaptive to different size classes.
Breaking the solution into parts, we first want to have a means of detecting which view controllers need to be resized, and then we need a way to resize said controllers. Enter Resizing Policies to handle the first part.
Resizing Policies
We will define a resizing policy as a protocol that provides functionality for inferring whether a controller should be resized.
enum ControllerSizeType {
case formSheet
case fullScreen
}
protocol ResizingPolicy {
func sizeType(for vc: UIViewController) -> ControllerSizeType
}
struct ModalResizingPolicy: ResizingPolicy {
func sizeType(for vc: UIViewController) -> ControllerSizeType {
// return size policy for view controller
}
}
In the snippet above, we have a ModalResizingPolicy
which conforms to ResizingPolicy
. This is where we will return the kind of size change that needs to occur for the current View Controller being displayed.
Now that we have a resizing policy, we need to use it to resize the View Controller in some way. With this in mind, we will define another protocol called Resizer
. Objects/structures conforming to this protocol must provide functionality for resizing a particular ViewController
given a particular ResizingPolicy
.
protocol Resizer {
func resize(_ vc: UIViewController,
using policy: ResizingPolicy)
}
class NavigationModalResizer: Resizer {
func resize(_ vc: UIViewController,
using policy: ResizingPolicy) {
// Perform modal resizing here
}
}
Putting it all together
Using the ResizingPolicy
and the Resizer
, we can now implement the intended functionality as illustrated below:
struct ModalResizingPolicy: ResizingPolicy {
func sizeType(for vc: UIViewController) -> ControllerSizeType {
switch vc {
case is VC: return .formSheet
case is OtherVC: return .fullScreen
default: return .formSheet
}
}
}
class NavigationModalResizer: Resizer {
weak var navController: UINavigationController?
private var lastSize: ControllerSizeType
private var originalCornerRadius: CGFloat?
init(navController: UINavigationController) {
self.navController = navController
// Assume size starts as regular formSheet
lastSize = .formSheet
}
func resize(
_ vc: UIViewController,
using policy: ResizingPolicy
) {
guard
let navController = navController,
navController.viewControllers.contains(vc)
else { return }
// Obtain original corner radius of form sheet
guard let originalCornerRadius = originalCornerRadius else {
self.originalCornerRadius = navController
.presentationController?
.presentedView?.layer.cornerRadius
resize(vc, using: policy)
return
}
let newSize = policy.sizeType(for: vc)
guard newSize != lastSize else { return }
lastSize = newSize
var contentSize = preferredContentSize(for: newSize)
// account for nav bar
contentSize.height -=
navController.navigationBar.isHidden ?
.zero : navController.navigationBar.frame.height
// animate resize process
let animator = UIViewPropertyAnimator(
duration: 0.5,
curve: .easeIn
) {
navController.preferredContentSize = contentSize
navController.presentationController?.presentedView?.layer.cornerRadius = newSize == .fullScreen ? 0.0 : originalCornerRadius
// Manually trigger a layout pass to apply new
let containerView = navController
.presentationController?.containerView
containerView?.setNeedsLayout()
containerView?.layoutIfNeeded()
}
animator.startAnimation()
}
private func preferredContentSize(
for sizeType: ControllerSizeType
) -> CGSize {
switch sizeType {
case .formSheet:
return CGSize(width: 540.0, height: 620.0)
case .fullScreen:
return UIScreen.main.bounds.size
}
}
}
// MARK: - Router
class Router: NSObject, UINavigationControllerDelegate {
let baseViewController: UIViewController
private let navController: UINavigationController
private var resizer: Resizer
private var policy: ResizingPolicy
var otherVC: OtherVC { return OtherVC() }
init(baseViewController: UIViewController) {
self.baseViewController = baseViewController
navController = UINavigationController()
resizer = NavigationModalResizer(
navController: navController
)
policy = ModalResizingPolicy()
super.init()
}
func begin() {
let vc = VC()
navController.setViewControllers([vc], animated: true)
navController.modalPresentationStyle = .formSheet
navController.delegate = self
let otherVC = self.otherVC
vc.onButtonTapped = { [navController] in
navController.pushViewController(otherVC, animated: true)
}
baseViewController.present(navController,
animated: true,
completion: nil)
}
// MARK: UINavigationControllerDelegate
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
// perform resizing after the navigation controller
// has displayed the new topViewController
resizer.resize(viewController: viewController, using: policy)
}
}
The Router
invokes the resize
function on it's resizer
after the navigationController
has displayed the new top view controller. In the resize function, we first ensure that the viewController to be resized is actually contained in the navigation controller's viewControllers
stack. We also hold on to the original corner radius for the form sheet. Doing this ensures that we use the same corner radius when moving from fullScreen
back to formSheet
size types. Finally, we animated the new content size change, by setting the preferredContentSize
of the navigationController
to the new size. I also noticed that setting preferredContentSize
doesn't notify the system that a layout pass is needed, so this was done manually by invoking setNeedsLayout
.
Conclusion
This post was as a result of me unable to find a proper way to implement the functionality described above (via Google searching). Instead of implementing my own custom PresentationController
, this implementation gives the best of both worlds. I got to keep the features provided by the formSheet
presentation style, and was able to provide resizing functionality in a neat and self-contained manner. There are some minor kinks here that should be ironed out, like hiding the status bar when we go full screen or expanding the size of the navigation bar to account for the status bar, but these are beyond the scope of this blog post.
Thanks for taking the time 🙏🏿
Let me know what you think about this solution and if you have a different way of solving this problem, I'm be happy to hear it via twitter or mail