iosswiftxcodeuipresentationcontroller

iOS 13 style UIPresentationController without relying on snapshots?


iOS 13 seems to use a new UIPresentationController for presenting modal view controllers, but one that does not rely on taking snapshots of the presenting view controller (as most / all libraries out there do). The presenting view controller is 'live' and continues to display animations / changes while the modal view controller is showing above a transparent / tinted background.

I'm able to replicate this easily (as the aim is to make a backward compatible version for iOS 10 / 11 / 12 etc) by using a CGAffineTransform on the presenting view controller's view, however frequently while rotating the device, the presenting view begins to de-shape and grow out of bounds seemingly because the system updates its frame while there's an active transform applied to it.

According to the documentation, frame is undefined when there's a transform applied to the view. Given the system seems to be modifying the frame and not me, how do I solve this without ending up with hacky solutions where I'm updating the presenting view's bounds? I need this presentation controller to remain generic since the presenting controller could be any shape or form, and won't necessarily be a full-screen view.

Here's what I have so far - it's a simple UIPresentationController subclass, which seems to work fine, however rotating the device and then dismissing the presented view controller seems to de-shape the presenting view controller's bounds (becomes too wide or shrinks, depending on whether you presented the modal controller while in landscape / portrait)

class SheetPresentationController: UIPresentationController {
  override var frameOfPresentedViewInContainerView: CGRect {
    return CGRect(x: 40, y: containerView!.bounds.height / 2, width: containerView!.bounds.width-80, height: containerView!.bounds.height / 2)
  }

  override func containerViewWillLayoutSubviews() {
    super.containerViewWillLayoutSubviews()

    if let _ = presentingViewController.transitionCoordinator {
      // We're transitioning - don't touch the frame yet as it'll
      // clash with our transform
    } else {
      self.presentedView?.frame = self.frameOfPresentedViewInContainerView
    }
  }

  override func presentationTransitionWillBegin() {
    super.presentationTransitionWillBegin()

    containerView?.backgroundColor = .clear

    if let coordinator = presentingViewController.transitionCoordinator {
      coordinator.animate(alongsideTransition: { [weak self] _ in
        self?.containerView?.backgroundColor = UIColor.black.withAlphaComponent(0.3)

        // Scale the presenting view
        self?.presentingViewController.view.layer.cornerRadius = 16

        self?.presentingViewController.view.transform = CGAffineTransform.init(scaleX: 0.9, y: 0.9)
        }, completion: nil)
    }
  }

  override func dismissalTransitionWillBegin() {
    if let coordinator = presentingViewController.transitionCoordinator {
      coordinator.animate(alongsideTransition: { [weak self] _ in
        self?.containerView?.backgroundColor = .clear

        self?.presentingViewController.view.layer.cornerRadius = 0
        self?.presentingViewController.view.transform = .identity
        }, completion: nil)
    }
  }
}

And the Presenting Animation controller:

import UIKit

final class PresentingAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
      return
    }

    let springTiming = UISpringTimingParameters(dampingRatio: 1.0, initialVelocity: CGVector(dx:1.0, dy: 1.0))
    let animator: UIViewPropertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: springTiming)

    let containerView = transitionContext.containerView
    containerView.addSubview(presentedViewController.view)

    let finalFrameForPresentedView = transitionContext.finalFrame(for: presentedViewController)
    presentedViewController.view.frame = finalFrameForPresentedView

    // Move it below the screen so it slides up
    presentedViewController.view.frame.origin.y = containerView.bounds.height

    animator.addAnimations {
      presentedViewController.view.frame = finalFrameForPresentedView      
    }

    animator.addCompletion { (animationPosition) in
      if animationPosition == .end {
        transitionContext.completeTransition(true)
      }
    }

    animator.startAnimation()
  }

  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.6
  }
}

As well as the dismissing animation controller:

import UIKit

final class DismissingAnimationController: NSObject, UIViewControllerAnimatedTransitioning {

  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    guard let presentedViewController = transitionContext.viewController(forKey: .from) else {
      return
    }

    guard let presentingViewController = transitionContext.viewController(forKey: .to) else {
      return
    }

    let finalFrameForPresentedView = transitionContext.finalFrame(for: presentedViewController)

    let containerView = transitionContext.containerView
    let offscreenFrame = CGRect(x: finalFrameForPresentedView.minX, y: containerView.bounds.height, width: finalFrameForPresentedView.width, height: finalFrameForPresentedView.height)

    let springTiming = UISpringTimingParameters(dampingRatio: 1.0, initialVelocity: CGVector(dx:1.0, dy: 1.0))
    let animator: UIViewPropertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: springTiming)

    animator.addAnimations {
      presentedViewController.view.frame = offscreenFrame
    }

    animator.addCompletion { (position) in
      if position == .end {
        // Complete transition        
        transitionContext.completeTransition(true)
      }
    }

    animator.startAnimation()
  }

  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.6
  }
}

Solution

  • Okay I figured it out. It seems iOS 13 does NOT use a scale transform. The moment you do that, as explained, rotating the device will modify the frame of the presenting view and since you've got a transform applied to the view already, the view will resize in unexpected ways and the transform will no longer be valid.

    The solution is to instead use a z-axis perspective, which will give you the exact same result, but doing so will survive rotations etc since all you're doing is moving the view back into 3D space (Z-axis), thus effectively zooming it out. Here's the transform that did this for me (Swift):

      func calculatePerspectiveTransform() -> CATransform3D {
        let eyePosition:Float = 10.0;
        var contentTransform:CATransform3D = CATransform3DIdentity
        contentTransform.m34 = CGFloat(-1/eyePosition)
        contentTransform = CATransform3DTranslate(contentTransform, 0, 0, -2)
        return contentTransform
      }
    

    Here's an article explaining how this works: https://whackylabs.com/uikit/2014/10/29/add-some-perspective-to-your-uiviews/

    In your UIPresenterController, you would need to do the following too in order to handle this transform across rotations properly:

      override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
    
        // Reset transform before we rotate and then apply it again during rotation
        if let presentingView = presentingViewController.view {
          presentingView.layer.transform = CATransform3DIdentity
        }
    
        coordinator.animate(alongsideTransition: { [weak self] (context) in
          if let presentingView = self?.presentingViewController.view {
            presentingView.layer.transform = self?.calculatePerspectiveTransform() ?? CATransform3DIdentity
          }
        })
      }