Search code examples
iosswift3swift4uipresentationcontroller

Trying to mimic the Mail.app compose animation keeping a layer in view


I have been trying for a while but I cannot figure out how to create a Compose animation seen in the iOS 10+ when you can drag the new composed email down, then it stays on the bottom and the rest of the app is normally accessed, then when you tap it, it re-shows.

I have created a sample project in which I have a UIViewController that presents another UIViewController which has a UIPanGestureRecognizer in it's UINavigationController that fires the pangesture state analyzer.

I can indeed drag to dismiss it , but I cannot find a way to keep it frame.

Bellow there's a print screen of what I'm trying to accomplish and then my used code to where I'm stuck at.

enter image description here

UIViewController that is the presentingViewController class

//
//  ViewController.swift
//  dismissLayerTest
//
//  Created by Ivan Cantarino on 27/09/17.
//  Copyright © 2017 Ivan Cantarino. All rights reserved.
//

import UIKit

class ViewController: UIViewController, UIViewControllerTransitioningDelegate {


    @objc let interactor = Interactor()

    lazy var presentButton: UIButton = {
        let b = UIButton(type: .custom)
        b.setTitle("Present", for: .normal)
        b.setTitleColor(.black, for: .normal)
        b.addTarget(self, action: #selector(didTapPresentButton), for: .touchUpInside)
        return b
    }()

    lazy var testbutton: UIButton = {
        let b = UIButton(type: .custom)
        b.setTitle("test", for: .normal)
        b.setTitleColor(.black, for: .normal)
        b.addTarget(self, action: #selector(test), for: .touchUpInside)
        return b
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        view.backgroundColor = .white
        view.addSubview(presentButton)
        presentButton.anchor(top: nil, left: nil, bottom: nil, right: nil, paddingTop: 0, paddinfLeft: 0, paddingBottom: 0, paddingRight: 0, width: 100, height: 100)
        presentButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        presentButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true

        view.addSubview(testbutton)
        testbutton.anchor(top: nil, left: nil, bottom: presentButton.topAnchor, right: nil, paddingTop: 0, paddinfLeft: 0, paddingBottom: 100, paddingRight: 0, width: 100, height: 100)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @objc func didTapPresentButton() {
        let presentedVC = PresentedViewController()
        let navController = UINavigationController(rootViewController: presentedVC)

        navController.transitioningDelegate = self
        presentedVC.interactor = interactor // new
        navController.modalPresentationStyle = .custom
        navController.view.layer.masksToBounds = true

        present(navController, animated: true, completion: nil)

    }

    @objc func test() {
        print("test")
    }

    // Handles the presenting animation
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimationForPresentor()
    }


    // Handles the dismissing animation
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimationForDismisser()
    }


    // interaction controller, only for dismissing the view;
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactor.hasStarted ? interactor : nil
    }

    // delegate do custom modal presentation style
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
            return CustomPresentationController(presentedViewController: presented, presenting: presenting)
        }

}

UIViewController 2 that is the presentedViewController

import Foundation
import UIKit


class PresentedViewController: UIViewController, UIViewControllerTransitioningDelegate, UIGestureRecognizerDelegate {



    @objc var interactor: Interactor? = nil
    @objc var panGr = UIPanGestureRecognizer()
    @objc var panTapRecon = UITapGestureRecognizer()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green

        let leftB = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didTapCancel))
        navigationItem.leftBarButtonItem = leftB

        panGr = UIPanGestureRecognizer(target: self, action: #selector(handleGesture))
        navigationController?.navigationBar.addGestureRecognizer(panGr)

        panTapRecon = UITapGestureRecognizer(target: self, action: #selector(handleNavControllerTapGR))
        navigationController?.navigationBar.addGestureRecognizer(panTapRecon)
    }

    @objc func didTapCancel() {
        guard let interactor = interactor else { return }
        interactorFinish(interactor: interactor)
        dismiss(animated: true, completion: nil)
    }

    @objc func handleNavControllerTapGR(_ sender: UITapGestureRecognizer) {
        print("tap detected")
    }


    // Swipe gesture recognizer handler
    @objc func handleGesture(_ sender: UIPanGestureRecognizer) {

        //percentThreshold: This variable sets how far down the user has to drag
        //in order to trigger the modal dismissal. In this case, it’s set to 40%.
        let percentThreshold:CGFloat = 0.30

        // convert y-position to downward pull progress (percentage)
        let translation = sender.translation(in: view)
        let verticalMovement = translation.y / view.bounds.height
        let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
        let downwardMovementPercent = fminf(downwardMovement, 1.0)
        let progress = CGFloat(downwardMovementPercent)

        guard let interactor = interactor else { return }

        switch sender.state {

        case .began:
            interactor.hasStarted = true
            self.dismiss(animated: true, completion: nil)

        case .changed:

            // alterar se o tamanho do presentigViewController (MainTabBarController) for alterado no background
            let scaleX = 0.95 + (progress * (1 - 0.95))
            let scaleY = 0.95 + (progress * (1 - 0.95))

            // Não deixa ultrapassar os 100% de scale (tamanho original)
            if (scaleX > 1 && scaleY > 1) { return }
            presentingViewController?.view.transform = CGAffineTransform.identity.scaledBy(x: scaleX, y: scaleY);
            presentingViewController?.view.layer.masksToBounds = true

            interactor.shouldFinish = progress > percentThreshold
            interactor.update(progress)

        case .cancelled:
            interactor.hasStarted = false
            interactor.cancel()

        case .ended:
            interactor.hasStarted = false
            if (interactor.shouldFinish) {
                interactorFinish(interactor: interactor)
            } else {

                // repõe o MainTabBarController na posição dele atrás do NewPostController
                UIView.animate(withDuration: 0.5, animations: {
                    self.presentingViewController?.view.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95);
                    self.presentingViewController?.view.layer.masksToBounds = true
                    let c = UIColor.black.withAlphaComponent(0.4)
                    let shadowView = self.presentingViewController?.view.viewWithTag(999)
                    shadowView?.backgroundColor = c
                })
                interactor.cancel()
            }

        default: break
        }
    }


    @objc func interactorFinish(interactor: Interactor) {
        removeShadow()
        interactor.finish()
    }

    // remove a shadow view
    @objc func removeShadow() {
        UIView.animate(withDuration: 0.2, animations: {
            self.presentingViewController?.view.transform = CGAffineTransform.identity.scaledBy(x: 1.0, y: 1.0);
            self.presentingViewController?.view.layer.masksToBounds = true

        }) { _ in
        }
    }
}

Here's an Helper file that has the custom presentations:

//
//  Helper.swift
//  dismissLayerTest
//
//  Created by Ivan Cantarino on 27/09/17.
//  Copyright © 2017 Ivan Cantarino. All rights reserved.
//

import Foundation
import UIKit

class Interactor: UIPercentDrivenInteractiveTransition {
    @objc var hasStarted = false
    @objc var shouldFinish = false
}


extension UIView {
    @objc func anchor(top: NSLayoutYAxisAnchor?, left: NSLayoutXAxisAnchor?, bottom: NSLayoutYAxisAnchor?, right: NSLayoutXAxisAnchor?, paddingTop: CGFloat, paddinfLeft: CGFloat, paddingBottom: CGFloat, paddingRight: CGFloat, width: CGFloat, height: CGFloat) {
        translatesAutoresizingMaskIntoConstraints = false
        if let top = top {
            topAnchor.constraint(equalTo: top, constant: paddingTop).isActive = true
        }
        if let left = left {
            leftAnchor.constraint(equalTo: left, constant: paddinfLeft).isActive = true
        }
        if let bottom = bottom {
            bottomAnchor.constraint(equalTo: bottom, constant: -paddingBottom).isActive = true
        }
        if let right = right {
            rightAnchor.constraint(equalTo: right, constant: -paddingRight).isActive = true
        }
        if width != 0 {
            widthAnchor.constraint(equalToConstant: width).isActive = true
        }
        if height != 0 {
            heightAnchor.constraint(equalToConstant: height).isActive = true
        }
    }

    @objc func roundCorners(corners:UIRectCorner, radius: CGFloat) {
        let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
        let mask = CAShapeLayer()
        mask.path = path.cgPath
        self.layer.mask = mask
    }
}



class CustomAnimationForDismisser: NSObject, UIViewControllerAnimatedTransitioning {

    // Tempo da animação
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.27
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // Get the set of relevant objects.
        let containerView = transitionContext.containerView
        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {
            print("Returning animateTransition VC")
            return
        }
        // from view só existe no dismiss
        guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
            print("Failed to instantiate fromView: CustomAnimationForDismisser()")
            return
        }
        // Set up some variables for the animation.
        let containerFrame: CGRect = containerView.frame
        var fromViewFinalFrame: CGRect = transitionContext.finalFrame(for: fromVC)
        fromViewFinalFrame = CGRect(x: 0, y: containerFrame.size.height, width: containerFrame.size.width, height: containerFrame.size.height)

        // Animate using the animator's own duration value.
        UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
            fromView.frame = fromViewFinalFrame
        }) { (finished) in
            let success = !(transitionContext.transitionWasCancelled)
            // Notify UIKit that the transition has finished
            transitionContext.completeTransition(success)
        }
    }
}



class CustomAnimationForPresentor: NSObject, UIViewControllerAnimatedTransitioning {
    // Tempo da animação
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.2
    }
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // Get the set of relevant objects.
        let containerView = transitionContext.containerView

        // obtém os VCs para não o perder na apresentação (default desaparece por trás)
        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {//, let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
            print("Returning animateTransition VC")
            return
        }
        // gets the view of the presented object
        guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else { return }

        // Set up animation parameters.
        toView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height)

        // Always add the "to" view to the container.
        containerView.addSubview(toView)

        // Animate using the animator's own duration value.
        UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseOut, animations: {
            // Zooms out da MainTabBarController - o VC
            fromVC.view.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
            // propriedades declaradas no CustomPresentationController() // Anima o presented view
            toView.transform = .identity
        }, completion: { (finished) in
            let success = !(transitionContext.transitionWasCancelled)
            // So it avoids view stacks and overlap issues
            if (!success) { toView.removeFromSuperview() }
            // Notify UIKit that the transition has finished
            transitionContext.completeTransition(success)
        })
    }
}

class CustomPresentationController: UIPresentationController {
    override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController!) {
        super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
    }

    // Tamanho desejado para o NewPostController
    override var frameOfPresentedViewInContainerView: CGRect {
        guard let containerBounds = containerView?.bounds else {
            print("Failed to instantiate container bounds: CustomPresentationController")
            return .zero
        }
        return CGRect(x: 0.0, y: 0.0, width: containerBounds.width, height: containerBounds.height)
    }
    // Garante que o frame do view controller a mostrar, se mantém conforme desenhado na função frameOfPresentedViewInContainerView
    override func containerViewWillLayoutSubviews() {
        presentedView?.frame = frameOfPresentedViewInContainerView
    }
}

This desired effect can also be seen in other apps, such like Music app, Stack Exchange/Overflow iOS App

Does anyone have a hint on how can this be accomplished? I feel like I'm really close to achieve it, but I can't find a way to keep the dismissed view with a layer on screen.

The project above can be found here

Thank you very much. Regards.


Solution

  • I would suggest that Apple (in the animated screen gif you have so helpfully provided) is not using a presented view controller. If it were, the presenting view controller would not be able to shrink its view — and on dismissal, the presented view controller's view would completely disappear.

    I would say that underlying this interface is a parent view controller with multiple child view controllers (or maybe just a normal view controller with two child views). Thus, we can display the two child views wherever and however we like. Your animated gif shows two possible arrangements of the two child views: overlapping, and one above the other with the second view just barely visible from the bottom of the screen.