2

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
  }
}
strangetimes
  • 4,953
  • 1
  • 34
  • 62

2 Answers2

5

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
      }
    })
  }
chunkyguy
  • 3,509
  • 1
  • 29
  • 34
strangetimes
  • 4,953
  • 1
  • 34
  • 62
0

Custom presentations are a tricky part of UIKit. Here's what comes to mind, no guarantees ;-)

I would suggest you either try to "commit" the animation on the presenting view - so in the presentationTransitionDidEnd(Bool) callback remove the transform and set appropriate constraints on the presenting view that match what the transform did. Or you could also just animate the constraint changes to mimic a transform.

Presumably you will get a viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) call back to manage the ongoing presentation if a rotation occurs.

glotcha
  • 558
  • 6
  • 13
  • Yeah these are the 'hacky' solutions I'm trying to avoid. No amount of constraints can't mimic the beautiful scaling you get using a `CALayer` / `View` transform, so that isn't feasible. I also don't want to mess with the presenting view's frame because the presenter does not own it and should not assume what shape / size / form the presenting view takes. – strangetimes Jul 16 '19 at 17:01
  • Well to be fair, iOS 13 does it so well, so I’m sure there’s a cleaner way out of this. Perhaps they are moving the presenting view into an intermediary view they own and scale and then move it back when done. Hard to tell. – strangetimes Jul 17 '19 at 11:35