Resizable FormSheet Modals

Here, I talk about a modular solution for resizing view controllers in FormSheet Modals

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:

  1. That it is possible to adjust the size of the formSheet modal by setting preferredContentSize of the View controller to be displayed before the modal is presented.
  2. And that when a new View Controller with a different preferredContentSize is presented in the same modal (In our case, we had a UINavigationController 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 the layoutSubviews() 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

Resources

Source code