Last active
November 12, 2019 15:07
-
-
Save elmodos/6062b1496e5f27d485456799fc784f27 to your computer and use it in GitHub Desktop.
iOS UIViewController modal Interactive dismisser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class EmailContentsViewController: UIViewController { | |
private var interactiveDismisser: InteractiveContentDismisser? | |
init(...) { | |
super.init(nibName: "EmailContentsViewController", bundle: nil) | |
self.modalPresentationStyle = .custom | |
self.transitioningDelegate = self | |
} | |
override func viewDidAppear(_ animated: Bool) { | |
super.viewDidAppear(animated) | |
self.transitioningDelegate = self.interactiveDismisser | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
let dismisser = self.createInteractiveDismisser() | |
self.view.addGestureRecognizer(dismisser.gestureRecognizer) | |
self.interactiveDismisser = dismisser | |
} | |
private func createInteractiveDismisser() -> InteractiveContentDismisser { | |
let dismisser = InteractiveContentDismisser(direction: .down) | |
dismisser.getDismissableDimension = { [weak self] in | |
return self?.view.frame.height ?? 0 | |
} | |
dismisser.handlerDismissAnimated = { [weak self] in | |
self?.coordinator?.emailContentsSceneClose(self!) | |
} | |
dismisser.handlerDismissFinish = nil | |
dismisser.handlerDismissCancel = { [weak self] in | |
self?.layoutContentOnscreen() | |
} | |
return dismisser | |
} | |
} | |
extension EmailContentsViewController: UIViewControllerTransitioningDelegate { | |
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
return DynamicContentTransitionPresenter() | |
} | |
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
return DynamicContentTransitionDismisser() | |
} | |
} | |
extension EmailContentsViewController: DynamicContentTransitinable { | |
func layoutContentOnscreen() { | |
self.viewDimmBackground?.alpha = 1 | |
make lyaout when everything is at finalized onscreen state | |
} | |
func layoutContentOffscreen() { | |
self.viewDimmBackground?.alpha = 0 | |
self.attachView(toBeVisible: false) | |
make lyaout when everything is at finalized offscreen state | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
public protocol DynamicContentTransitinable { | |
func layoutContentOnscreen() | |
func layoutContentOffscreen() | |
} | |
public class DynamicContentTransitioner: NSObject, UIViewControllerAnimatedTransitioning { | |
var animationDuration: TimeInterval | |
private(set) var animationOptions: UIView.AnimationOptions | |
public init(animationDuration: TimeInterval = 0.4, animationOptions: UIView.AnimationOptions) { | |
self.animationDuration = animationDuration | |
self.animationOptions = animationOptions | |
super.init() | |
} | |
public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { | |
return self.animationDuration | |
} | |
public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { | |
transitionContext.completeTransition(false) | |
assert(false, "Override required") | |
} | |
} | |
public class DynamicContentTransitionPresenter: DynamicContentTransitioner { | |
public init(animationDuration: TimeInterval = 0.4) { | |
super.init(animationDuration: animationDuration, animationOptions: .curveLinear) | |
} | |
public override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { | |
guard | |
let toViewController = transitionContext.viewController(forKey: .to) | |
else { | |
transitionContext.completeTransition(false) | |
return | |
} | |
if let toView = transitionContext.view(forKey: .to) { | |
toView.frame = transitionContext.finalFrame(for: toViewController) | |
transitionContext.containerView.addSubview(toView) | |
} | |
let fromViewController = transitionContext.viewController(forKey: .from) as? DynamicContentTransitinable | |
let initializeState = { | |
if transitionContext.presentationStyle == .fullScreen { | |
fromViewController?.layoutContentOnscreen() | |
} | |
(toViewController as? DynamicContentTransitinable)?.layoutContentOffscreen() | |
} | |
let finalizeState = { | |
if transitionContext.presentationStyle == .fullScreen { | |
fromViewController?.layoutContentOffscreen() | |
} | |
(toViewController as? DynamicContentTransitinable)?.layoutContentOnscreen() | |
} | |
guard transitionContext.isAnimated else { | |
finalizeState() | |
transitionContext.completeTransition(true) | |
return | |
} | |
initializeState() | |
UIView.animate( | |
withDuration: self.animationDuration, | |
delay: 0, | |
usingSpringWithDamping: 1, | |
initialSpringVelocity: 0, | |
options: [self.animationOptions], | |
animations: { | |
finalizeState() | |
}, | |
completion: { _ in | |
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) | |
}) | |
} | |
} | |
public class DynamicContentTransitionDismisser: DynamicContentTransitioner { | |
public init(animationDuration: TimeInterval = 0.4) { | |
super.init(animationDuration: animationDuration, animationOptions: .curveLinear) | |
} | |
public override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { | |
guard | |
let toViewController = transitionContext.viewController(forKey: .to), | |
let fromViewController = transitionContext.viewController(forKey: .from) | |
else { | |
transitionContext.completeTransition(false) | |
return | |
} | |
if let toView = transitionContext.view(forKey: .to) { | |
toView.frame = transitionContext.finalFrame(for: toViewController) | |
transitionContext.containerView.addSubview(toView) | |
} | |
if let fromView = transitionContext.view(forKey: .from) { | |
fromView.frame = transitionContext.initialFrame(for: fromViewController) | |
transitionContext.containerView.addSubview(fromView) | |
} | |
let initializeState = { | |
(fromViewController as? DynamicContentTransitinable)?.layoutContentOnscreen() | |
if transitionContext.presentationStyle == .fullScreen { | |
(toViewController as? DynamicContentTransitinable)?.layoutContentOffscreen() | |
} | |
} | |
let finalizeState = { | |
(fromViewController as? DynamicContentTransitinable)?.layoutContentOffscreen() | |
if transitionContext.presentationStyle == .fullScreen { | |
(toViewController as? DynamicContentTransitinable)?.layoutContentOnscreen() | |
} | |
} | |
guard transitionContext.isAnimated else { | |
finalizeState() | |
transitionContext.completeTransition(true) | |
return | |
} | |
initializeState() | |
UIView.animate( | |
withDuration: self.animationDuration, | |
delay: 0, | |
usingSpringWithDamping: 1, | |
initialSpringVelocity: 0, | |
options: [self.animationOptions], | |
animations: { | |
finalizeState() | |
}, | |
completion: { _ in | |
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) | |
}) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
public class InteractiveContentDismisser: NSObject { | |
public enum Direction { | |
// swiftlint:disable identifier_name | |
case up | |
// swiftlint:enable identifier_name | |
case down | |
case left | |
case right | |
func directionalMultiplier() -> CGFloat { | |
return [.up, .left].contains(self) | |
? -1 | |
: 1 | |
} | |
func isHorizontal() -> Bool { | |
return [.left, .right].contains(self) | |
} | |
} | |
public let direction: Direction | |
public var getDismissableDimension: (() -> CGFloat)? | |
public var handlerDismissAnimated: (() -> Void)? | |
public var handlerDismissFinish: (() -> Void)? | |
public var handlerDismissCancel: (() -> Void)? | |
private var interactionController: UIPercentDrivenInteractiveTransition? | |
public private(set) lazy var gestureRecognizer: UIGestureRecognizer = { | |
let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) | |
gestureRecognizer.maximumNumberOfTouches = 1 | |
return gestureRecognizer | |
}() | |
public init(direction: Direction) { | |
self.direction = direction | |
super.init() | |
} | |
} | |
extension InteractiveContentDismisser { | |
@objc private func onPan(_ recognizer: UIPanGestureRecognizer) { | |
let translationPoint = recognizer.translation(in: recognizer.view) | |
let velocityPoint = recognizer.velocity(in: recognizer.view) | |
let isHor = self.direction.isHorizontal() | |
let multiplier = self.direction.directionalMultiplier() | |
let translation = multiplier * (isHor ? translationPoint.x : translationPoint.y) | |
let velocity = multiplier * (isHor ? velocityPoint.x : velocityPoint.y) | |
if self.getDismissableDimension == nil { | |
Log.error("Not set: getDismissableDimension") | |
} | |
var percent = translation / max(1, (self.getDismissableDimension?() ?? 1)) | |
if percent < 0 { percent = 0 } | |
if percent > 1 { percent = 1 } | |
Log.debug("Percent: \(percent), velocity \(velocity)") | |
switch recognizer.state { | |
case .began: | |
Log.debug("began") | |
self.interactionController = UIPercentDrivenInteractiveTransition() | |
self.handlerDismissAnimated?() | |
if self.handlerDismissAnimated == nil { | |
Log.error("Not set: handlerDismissAnimated") | |
} | |
case .changed: | |
Log.debug("changed") | |
self.interactionController?.update(percent) | |
case .ended: | |
Log.debug("ended") | |
if percent > 0.5 || velocity > 0 { | |
Log.debug("finish") | |
self.interactionController?.finish() | |
self.handlerDismissFinish?() | |
} else { | |
Log.debug("cancel") | |
self.interactionController?.cancel() | |
self.handlerDismissCancel?() | |
} | |
self.interactionController = nil | |
default: () | |
} | |
} | |
} | |
extension InteractiveContentDismisser: UIViewControllerTransitioningDelegate { | |
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { | |
return DynamicContentTransitionDismisser() | |
} | |
public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { | |
return self.interactionController | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment