UIKit Dynamics
UIKit Dynamics is a full real-world physics engine integrated into UIKit. It allows you to create interfaces that feel real by adding behaviors such as gravity, attachments, collision and forces. You define the physical traits that you would like your interface elements to adopt, and the dynamics engine takes care of the rest.
The Falling Square
Section titled “The Falling Square”Lets draw a square in the middle of our view and make it fall to the bottom and stop at the bottom edge collising with the screen bottom boundary.
@IBOutlet var animationView: UIView!var squareView:UIView!var collision: UICollisionBehavior!var animator: UIDynamicAnimator!var gravity: UIGravityBehavior!
override func viewDidLoad() { super.viewDidLoad() let squareSize = CGSize(width: 30.0, height: 30.0) let centerPoint = CGPoint(x: self.animationView.bounds.midX - (squareSize.width/2), y: self.animationView.bounds.midY - (squareSize.height/2)) let frame = CGRect(origin: centerPoint, size: squareSize) squareView = UIView(frame: frame) squareView.backgroundColor = UIColor.orangeColor() animationView.addSubview(squareView) animator = UIDynamicAnimator(referenceView: view) gravity = UIGravityBehavior(items: [squareView]) animator.addBehavior(gravity) collision = UICollisionBehavior(items: [square]) collision.translatesReferenceBoundsIntoBoundary = true animator.addBehavior(collision)}Flick View Based on Gesture Velocity
Section titled “Flick View Based on Gesture Velocity”This example shows how to have a view track a pan gesture and depart in a physics-based manner.
class ViewController: UIViewController{ // Adjust to change speed of view from flick let magnitudeMultiplier: CGFloat = 0.0008
lazy var dynamicAnimator: UIDynamicAnimator = { let dynamicAnimator = UIDynamicAnimator(referenceView: self.view) return dynamicAnimator }()
lazy var gravity: UIGravityBehavior = { let gravity = UIGravityBehavior(items: [self.orangeView]) return gravity }()
lazy var collision: UICollisionBehavior = { let collision = UICollisionBehavior(items: [self.orangeView]) collision.translatesReferenceBoundsIntoBoundary = true return collision }()
lazy var orangeView: UIView = { let widthHeight: CGFloat = 40.0 let orangeView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: widthHeight, height: widthHeight)) orangeView.backgroundColor = UIColor.orange self.view.addSubview(orangeView) return orangeView }()
lazy var panGesture: UIPanGestureRecognizer = { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(sender:))) return panGesture }()
lazy var attachment: UIAttachmentBehavior = { let attachment = UIAttachmentBehavior(item: self.orangeView, attachedToAnchor: .zero) return attachment }()
override func viewDidLoad() { super.viewDidLoad() dynamicAnimator.addBehavior(gravity) dynamicAnimator.addBehavior(collision) orangeView.addGestureRecognizer(panGesture) }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() orangeView.center = view.center dynamicAnimator.updateItem(usingCurrentState: orangeView) }
func handlePan(sender: UIPanGestureRecognizer) { let location = sender.location(in: view) let velocity = sender.velocity(in: view) let magnitude = sqrt((velocity.x * velocity.x) + (velocity.y * velocity.y)) switch sender.state { case .began: attachment.anchorPoint = location dynamicAnimator.addBehavior(attachment) case .changed: attachment.anchorPoint = location case .cancelled, .ended, .failed, .possible: let push = UIPushBehavior(items: [self.orangeView], mode: .instantaneous) push.pushDirection = CGVector(dx: velocity.x, dy: velocity.y) push.magnitude = magnitude * magnitudeMultiplier dynamicAnimator.removeBehavior(attachment) dynamicAnimator.addBehavior(push) } }}Objective-C
Section titled “Objective-C”@interface ViewController ()
@property (nonatomic, assign) CGFloat magnitudeMultiplier;@property (nonatomic, strong) UIDynamicAnimator *dynamicAnimator;@property (nonatomic, strong) UIGravityBehavior *gravity;@property (nonatomic, strong) UICollisionBehavior *collision;@property (nonatomic, strong) UIView *orangeView;@property (nonatomic, strong) UIPanGestureRecognizer *panGesture;@property (nonatomic, strong) UIAttachmentBehavior *attachment;
@end
@implementation ViewController
- (void)viewDidLoad{ [super viewDidLoad]; [self.dynamicAnimator addBehavior:self.gravity]; [self.dynamicAnimator addBehavior:self.collision]; [self.orangeView addGestureRecognizer:self.panGesture]; // Adjust to change speed of view from flick self.magnitudeMultiplier = 0.0008f;}
- (void)viewDidLayoutSubviews{ [super viewDidLayoutSubviews]; self.orangeView.center = self.view.center; [self.dynamicAnimator updateItemUsingCurrentState:self.orangeView];}
- (void)handlePan:(UIPanGestureRecognizer *)sender{ CGPoint location = [sender locationInView:self.view]; CGPoint velocity = [sender velocityInView:self.view]; CGFloat magnitude = sqrt((velocity.x * velocity.x) + (velocity.y * velocity.y)); if (sender.state == UIGestureRecognizerStateBegan) { self.attachment.anchorPoint = location; [self.dynamicAnimator addBehavior:self.attachment]; } else if (sender.state == UIGestureRecognizerStateChanged) { self.attachment.anchorPoint = location; } else if (sender.state == UIGestureRecognizerStateCancelled || sender.state == UIGestureRecognizerStateEnded || sender.state == UIGestureRecognizerStateFailed || sender.state == UIGestureRecognizerStatePossible) { UIPushBehavior *push = [[UIPushBehavior alloc] initWithItems:@[self.orangeView] mode:UIPushBehaviorModeInstantaneous]; push.pushDirection = CGVectorMake(velocity.x, velocity.y); push.magnitude = magnitude * self.magnitudeMultiplier; [self.dynamicAnimator removeBehavior:self.attachment]; [self.dynamicAnimator addBehavior:push]; }}
#pragma mark - Lazy Init- (UIDynamicAnimator *)dynamicAnimator{ if (!_dynamicAnimator) { _dynamicAnimator = [[UIDynamicAnimator alloc]initWithReferenceView:self.view]; } return _dynamicAnimator;}
- (UIGravityBehavior *)gravity{ if (!_gravity) { _gravity = [[UIGravityBehavior alloc]initWithItems:@[self.orangeView]]; } return _gravity;}
- (UICollisionBehavior *)collision{ if (!_collision) { _collision = [[UICollisionBehavior alloc]initWithItems:@[self.orangeView]]; _collision.translatesReferenceBoundsIntoBoundary = YES; } return _collision;}
- (UIView *)orangeView{ if (!_orangeView) { CGFloat widthHeight = 40.0f; _orangeView = [[UIView alloc]initWithFrame:CGRectMake(0.0, 0.0, widthHeight, widthHeight)]; _orangeView.backgroundColor = [UIColor orangeColor]; [self.view addSubview:_orangeView]; } return _orangeView;}
- (UIPanGestureRecognizer *)panGesture{ if (!_panGesture) { _panGesture = [[UIPanGestureRecognizer alloc]initWithTarget:self action:@selector(handlePan:)]; } return _panGesture;}
- (UIAttachmentBehavior *)attachment{ if (!_attachment) { _attachment = [[UIAttachmentBehavior alloc]initWithItem:self.orangeView attachedToAnchor:CGPointZero]; } return _attachment;}
@end”Sticky Corners” Effect Using UIFieldBehaviors
Section titled “”Sticky Corners” Effect Using UIFieldBehaviors”This example shows how to achieve an effect similar to FaceTime were a view is attracted to point once it enters a particular region, in this case two regions a top and bottom.
class ViewController: UIViewController{ lazy var dynamicAnimator: UIDynamicAnimator = { let dynamicAnimator = UIDynamicAnimator(referenceView: self.view) return dynamicAnimator }()
lazy var collision: UICollisionBehavior = { let collision = UICollisionBehavior(items: [self.orangeView]) collision.translatesReferenceBoundsIntoBoundary = true return collision }()
lazy var fieldBehaviors: [UIFieldBehavior] = { var fieldBehaviors = [UIFieldBehavior]() for _ in 0 ..< 2 { let field = UIFieldBehavior.springField() field.addItem(self.orangeView) fieldBehaviors.append(field) } return fieldBehaviors }()
lazy var itemBehavior: UIDynamicItemBehavior = { let itemBehavior = UIDynamicItemBehavior(items: [self.orangeView]) // Adjust these values to change the "stickiness" of the view itemBehavior.density = 0.01 itemBehavior.resistance = 10 itemBehavior.friction = 0.0 itemBehavior.allowsRotation = false return itemBehavior }()
lazy var orangeView: UIView = { let widthHeight: CGFloat = 40.0 let orangeView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: widthHeight, height: widthHeight)) orangeView.backgroundColor = UIColor.orange self.view.addSubview(orangeView) return orangeView }()
lazy var panGesture: UIPanGestureRecognizer = { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(sender:))) return panGesture }()
lazy var attachment: UIAttachmentBehavior = { let attachment = UIAttachmentBehavior(item: self.orangeView, attachedToAnchor: .zero) return attachment }()
override func viewDidLoad() { super.viewDidLoad() dynamicAnimator.addBehavior(collision) dynamicAnimator.addBehavior(itemBehavior) for field in fieldBehaviors { dynamicAnimator.addBehavior(field) }
orangeView.addGestureRecognizer(panGesture) }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews()
orangeView.center = view.center dynamicAnimator.updateItem(usingCurrentState: orangeView)
for (index, field) in fieldBehaviors.enumerated() { field.position = CGPoint(x: view.bounds .midX, y: view.bounds.height * (0.25 + 0.5 * CGFloat(index))) field.region = UIRegion(size: CGSize(width: view.bounds.width, height: view.bounds.height * 0.5)) } }
func handlePan(sender: UIPanGestureRecognizer) { let location = sender.location(in: view) let velocity = sender.velocity(in: view) switch sender.state { case .began: attachment.anchorPoint = location dynamicAnimator.addBehavior(attachment) case .changed: attachment.anchorPoint = location case .cancelled, .ended, .failed, .possible: itemBehavior.addLinearVelocity(velocity, for: self.orangeView) dynamicAnimator.removeBehavior(attachment) } }}Objective-C
Section titled “Objective-C”@interface ViewController ()
@property (nonatomic, strong) UIDynamicAnimator *dynamicAnimator;@property (nonatomic, strong) UICollisionBehavior *collision;@property (nonatomic, strong) UIAttachmentBehavior *attachment;@property (nonatomic, strong) UIDynamicItemBehavior *itemBehavior;@property (nonatomic, strong) NSArray <UIFieldBehavior *> *fieldBehaviors;@property (nonatomic, strong) UIView *orangeView;@property (nonatomic, strong) UIPanGestureRecognizer *panGesture;
@end
@implementation ViewController
- (void)viewDidLoad{ [super viewDidLoad]; [self.dynamicAnimator addBehavior:self.collision]; [self.dynamicAnimator addBehavior:self.itemBehavior]; for (UIFieldBehavior *field in self.fieldBehaviors) { [self.dynamicAnimator addBehavior:field]; }
[self.orangeView addGestureRecognizer:self.panGesture];}
- (void)viewDidLayoutSubviews{ [super viewDidLayoutSubviews]; self.orangeView.center = self.view.center; [self.dynamicAnimator updateItemUsingCurrentState:self.orangeView];
for (NSInteger i = 0; i < self.fieldBehaviors.count; i++) { UIFieldBehavior *field = self.fieldBehaviors[i]; field.position = CGPointMake(CGRectGetMidX(self.view.bounds), CGRectGetHeight(self.view.bounds) * (0.25f + 0.5f * i)); field.region = [[UIRegion alloc]initWithSize:CGSizeMake(CGRectGetWidth(self.view.bounds), CGRectGetHeight(self.view.bounds) * 0.5)]; }}
- (void)handlePan:(UIPanGestureRecognizer *)sender{ CGPoint location = [sender locationInView:self.view]; CGPoint velocity = [sender velocityInView:self.view]; if (sender.state == UIGestureRecognizerStateBegan) { self.attachment.anchorPoint = location; [self.dynamicAnimator addBehavior:self.attachment]; } else if (sender.state == UIGestureRecognizerStateChanged) { self.attachment.anchorPoint = location; } else if (sender.state == UIGestureRecognizerStateCancelled || sender.state == UIGestureRecognizerStateEnded || sender.state == UIGestureRecognizerStateFailed || sender.state == UIGestureRecognizerStatePossible) { [self.itemBehavior addLinearVelocity:velocity forItem:self.orangeView]; [self.dynamicAnimator removeBehavior:self.attachment]; }}
#pragma mark - Lazy Init- (UIDynamicAnimator *)dynamicAnimator{ if (!_dynamicAnimator) { _dynamicAnimator = [[UIDynamicAnimator alloc]initWithReferenceView:self.view]; } return _dynamicAnimator;}
- (UICollisionBehavior *)collision{ if (!_collision) { _collision = [[UICollisionBehavior alloc]initWithItems:@[self.orangeView]]; _collision.translatesReferenceBoundsIntoBoundary = YES; } return _collision;}
- (NSArray <UIFieldBehavior *> *)fieldBehaviors{ if (!_fieldBehaviors) { NSMutableArray *fields = [[NSMutableArray alloc]init]; for (NSInteger i = 0; i < 2; i++) { UIFieldBehavior *field = [UIFieldBehavior springField]; [field addItem:self.orangeView]; [fields addObject:field]; } _fieldBehaviors = fields; } return _fieldBehaviors;}
- (UIDynamicItemBehavior *)itemBehavior{ if (!_itemBehavior) { _itemBehavior = [[UIDynamicItemBehavior alloc]initWithItems:@[self.orangeView]]; // Adjust these values to change the "stickiness" of the view _itemBehavior.density = 0.01; _itemBehavior.resistance = 10; _itemBehavior.friction = 0.0; _itemBehavior.allowsRotation = NO; } return _itemBehavior;}
- (UIView *)orangeView{ if (!_orangeView) { CGFloat widthHeight = 40.0f; _orangeView = [[UIView alloc]initWithFrame:CGRectMake(0.0, 0.0, widthHeight, widthHeight)]; _orangeView.backgroundColor = [UIColor orangeColor]; [self.view addSubview:_orangeView]; } return _orangeView;}
- (UIPanGestureRecognizer *)panGesture{ if (!_panGesture) { _panGesture = [[UIPanGestureRecognizer alloc]initWithTarget:self action:@selector(handlePan:)]; } return _panGesture;}
- (UIAttachmentBehavior *)attachment{ if (!_attachment) { _attachment = [[UIAttachmentBehavior alloc]initWithItem:self.orangeView attachedToAnchor:CGPointZero]; } return _attachment;}
@endFor more information about UIFieldBehaviors you can see the 2015 WWDC Session “What’s New in UIKit Dynamics and Visual Effects” and accompanying sample code.
UIDynamicBehavior Driven Custom Transition
Section titled “UIDynamicBehavior Driven Custom Transition”This example shows how to create a custom presentation transition that is driven by a composite UIDynamicBehavior. We can start by creating a presenting view controller that will present a modal.
class PresentingViewController: UIViewController{ lazy var button: UIButton = { let button = UIButton() button.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(button) button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true button.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true button.setTitle("Present", for: .normal) button.setTextColor(UIColor.blue, for: .normal)
return button }()
override func viewDidLoad() { super.viewDidLoad() button.addTarget(self, action: #selector(self.didPressPresent), for: .touchUpInside) }
func didPressPresent() { let modal = ModalViewController() modal.view.frame = CGRect(x: 0.0, y: 0.0, width: 200.0, height: 200.0) modal.modalPresentationStyle = .custom modal.transitioningDelegate = modal self.present(modal, animated: true) }}Objective-C
Section titled “Objective-C”@interface PresentingViewController ()@property (nonatomic, strong) UIButton *button;@end
@implementation PresentingViewController
- (void)viewDidLoad{ [super viewDidLoad]; [self.button addTarget:self action:@selector(didPressPresent) forControlEvents:UIControlEventTouchUpInside];}
- (void)didPressPresent{ ModalViewController *modal = [[ModalViewController alloc] init]; modal.view.frame = CGRectMake(0.0, 0.0, 200.0, 200.0); modal.modalPresentationStyle = UIModalPresentationCustom; modal.transitioningDelegate = modal; [self presentViewController:modal animated:YES completion:nil];}
- (UIButton *)button{ if (!_button) { _button = [[UIButton alloc] init]; _button.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:_button]; [_button.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor].active = YES; [_button.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor].active = YES; [_button setTitle:@"Present" forState:UIControlStateNormal]; [_button setTitleColor:[UIColor blueColor] forState:UIControlStateNormal]; } return _button;}
@endWhen the present button is tapped, we create a ModalViewController and set its presentation style to .custom and set its transitionDelegate to itself. This will allow us to vend an animator that will drive its modal transition. We also set modal’s view’s frame so it will be smaller than the full screen.
Let’s now look at ModalViewController:
class ModalViewController: UIViewController{ lazy var button: UIButton = { let button = UIButton() button.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(button) button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true button.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true button.setTitle("Dismiss", for: .normal) button.setTitleColor(.white, for: .normal)
return button }()
override func viewDidLoad() { super.viewDidLoad() button.addTarget(self, action: #selector(self.didPressDismiss), for: .touchUpInside) view.backgroundColor = .red view.layer.cornerRadius = 15.0 }
func didPressDismiss() { dismiss(animated: true) }}
extension ModalViewController: UIViewControllerTransitioningDelegate{ func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return DropOutAnimator(duration: 1.5, isAppearing: true) }
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return DropOutAnimator(duration: 4.0, isAppearing: false) }}Objective-C
Section titled “Objective-C”@interface ModalViewController () <UIViewControllerTransitioningDelegate>@property (nonatomic, strong) UIButton *button;@end
@implementation ModalViewController
- (void)viewDidLoad{ [super viewDidLoad]; [self.button addTarget:self action:@selector(didPressPresent) forControlEvents:UIControlEventTouchUpInside]; self.view.backgroundColor = [UIColor redColor]; self.view.layer.cornerRadius = 15.0f;}
- (void)didPressPresent{ [self dismissViewControllerAnimated:YES completion:nil];}
- (UIButton *)button{ if (!_button) { _button = [[UIButton alloc] init]; _button.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:_button]; [_button.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor].active = YES; [_button.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor].active = YES; [_button setTitle:@"Dismiss" forState:UIControlStateNormal]; [_button setTitleColor:[UIColor blueColor] forState:UIControlStateNormal]; } return _button;}
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source{ return [[DropOutAnimator alloc]initWithDuration: 1.5 appearing:YES];}
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed{ return [[DropOutAnimator alloc] initWithDuration:4.0 appearing:NO];}
@endHere we create the view controller that is presented. Also because ModalViewController is it’s own transitioningDelegate it is also responsible for vending an object that will manage its transition animation. For us that means passing on an instance of our composite UIDynamicBehavior subclass.
Our animator will have two different transitions: one for presenting and one for dismissing. For presenting, the presenting view controller’s view will drop in from above. And for dismissing, the view will seem to swing from a rope and then drop out. Because DropOutAnimator conforms to UIViewControllerAnimatedTransitioning most of this work will be done in its implementation of func animateTransition(using transitionContext: UIViewControllerContextTransitioning).
class DropOutAnimator: UIDynamicBehavior{ let duration: TimeInterval let isAppearing: Bool
var transitionContext: UIViewControllerContextTransitioning? var hasElapsedTimeExceededDuration = false var finishTime: TimeInterval = 0.0 var collisionBehavior: UICollisionBehavior? var attachmentBehavior: UIAttachmentBehavior? var animator: UIDynamicAnimator?
init(duration: TimeInterval = 1.0, isAppearing: Bool) { self.duration = duration self.isAppearing = isAppearing super.init() }}
extension DropOutAnimator: UIViewControllerAnimatedTransitioning{ func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { // Get relevant views and view controllers from transitionContext guard let fromVC = transitionContext.viewController(forKey: .from), let toVC = transitionContext.viewController(forKey: .to), let fromView = fromVC.view, let toView = toVC.view else { return }
let containerView = transitionContext.containerView let duration = self.transitionDuration(using: transitionContext)
// Hold refrence to transitionContext to notify it of completion self.transitionContext = transitionContext
// Create dynamic animator let animator = UIDynamicAnimator(referenceView: containerView) animator.delegate = self self.animator = animator
// Presenting Animation if self.isAppearing { fromView.isUserInteractionEnabled = false
// Position toView just off-screen let fromViewInitialFrame = transitionContext.initialFrame(for: fromVC) var toViewInitialFrame = toView.frame toViewInitialFrame.origin.y -= toViewInitialFrame.height toViewInitialFrame.origin.x = fromViewInitialFrame.width * 0.5 - toViewInitialFrame.width * 0.5 toView.frame = toViewInitialFrame
containerView.addSubview(toView)
// Prevent rotation and adjust bounce let bodyBehavior = UIDynamicItemBehavior(items: [toView]) bodyBehavior.elasticity = 0.7 bodyBehavior.allowsRotation = false
// Add gravity at exaggerated magnitude so animation doesn't seem slow let gravityBehavior = UIGravityBehavior(items: [toView]) gravityBehavior.magnitude = 10.0
// Set collision bounds to include off-screen view and have collision in center // where our final view should come to rest let collisionBehavior = UICollisionBehavior(items: [toView]) let insets = UIEdgeInsets(top: toViewInitialFrame.minY, left: 0.0, bottom: fromViewInitialFrame.height * 0.5 - toViewInitialFrame.height * 0.5, right: 0.0) collisionBehavior.setTranslatesReferenceBoundsIntoBoundary(with: insets) self.collisionBehavior = collisionBehavior
// Keep track of finish time in case we need to end the animator befor the animator pauses self.finishTime = duration + (self.animator?.elapsedTime ?? 0.0)
// Closure that is called after every "tick" of the animator // Check if we exceed duration self.action = { [weak self] in guard let strongSelf = self, (strongSelf.animator?.elapsedTime ?? 0.0) >= strongSelf.finishTime else { return } strongSelf.hasElapsedTimeExceededDuration = true strongSelf.animator?.removeBehavior(strongSelf) }
// `DropOutAnimator` is a composit behavior, so add child behaviors to self self.addChildBehavior(collisionBehavior) self.addChildBehavior(bodyBehavior) self.addChildBehavior(gravityBehavior)
// Add self to dynamic animator self.animator?.addBehavior(self) } // Dismissing Animation else { // Create allow rotation and have a elastic item let bodyBehavior = UIDynamicItemBehavior(items: [fromView]) bodyBehavior.elasticity = 0.8 bodyBehavior.angularResistance = 5.0 bodyBehavior.allowsRotation = true
// Create gravity with exaggerated magnitude let gravityBehavior = UIGravityBehavior(items: [fromView]) gravityBehavior.magnitude = 10.0
// Collision boundary is set to have a floor just below the bottom of the screen let collisionBehavior = UICollisionBehavior(items: [fromView]) let insets = UIEdgeInsets(top: 0.0, left: -1000, bottom: -225, right: -1000) collisionBehavior.setTranslatesReferenceBoundsIntoBoundary(with: insets) self.collisionBehavior = collisionBehavior
// Attachment behavior so view will have effect of hanging from a rope let offset = UIOffset(horizontal: 70.0, vertical: fromView.bounds.height * 0.5) var anchorPoint = CGPoint(x: fromView.bounds.maxX - 40.0, y: fromView.bounds.minY) anchorPoint = containerView.convert(anchorPoint, from: fromView) let attachmentBehavior = UIAttachmentBehavior(item: fromView, offsetFromCenter: offset, attachedToAnchor: anchorPoint) attachmentBehavior.frequency = 3.0 attachmentBehavior.damping = 3.0 self.attachmentBehavior = attachmentBehavior
// `DropOutAnimator` is a composit behavior, so add child behaviors to self self.addChildBehavior(collisionBehavior) self.addChildBehavior(bodyBehavior) self.addChildBehavior(gravityBehavior) self.addChildBehavior(attachmentBehavior)
// Add self to dynamic animator self.animator?.addBehavior(self)
// Animation has two parts part one is hanging from rope. // Part two is bouncying off-screen // Divide duration in two self.finishTime = (2.0 / 3.0) * duration + (self.animator?.elapsedTime ?? 0.0)
// After every "tick" of animator check if past time limit self.action = { [weak self] in guard let strongSelf = self, (strongSelf.animator?.elapsedTime ?? 0.0) >= strongSelf.finishTime else { return } strongSelf.hasElapsedTimeExceededDuration = true strongSelf.animator?.removeBehavior(strongSelf) } }
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { // Return the duration of the animation return self.duration }}
extension DropOutAnimator: UIDynamicAnimatorDelegate{ func dynamicAnimatorDidPause(_ animator: UIDynamicAnimator) { // Animator has reached stasis if self.isAppearing { // Check if we are out of time if self.hasElapsedTimeExceededDuration { // Move to final positions let toView = self.transitionContext?.viewController(forKey: .to)?.view let containerView = self.transitionContext?.containerView toView?.center = containerView?.center ?? .zero self.hasElapsedTimeExceededDuration = false }
// Clean up and call completion self.transitionContext?.completeTransition(!(self.transitionContext?.transitionWasCancelled ?? false)) self.childBehaviors.forEach { self.removeChildBehavior($0) } animator.removeAllBehaviors() self.transitionContext = nil } else { if let attachmentBehavior = self.attachmentBehavior { // If we have an attachment, we are at the end of part one and start part two. self.removeChildBehavior(attachmentBehavior) self.attachmentBehavior = nil animator.addBehavior(self) let duration = self.transitionDuration(using: self.transitionContext) self.finishTime = 1.0 / 3.0 * duration + animator.elapsedTime } else { // Clean up and call completion let fromView = self.transitionContext?.viewController(forKey: .from)?.view let toView = self.transitionContext?.viewController(forKey: .to)?.view fromView?.removeFromSuperview() toView?.isUserInteractionEnabled = true self.transitionContext?.completeTransition(!(self.transitionContext?.transitionWasCancelled ?? false)) self.childBehaviors.forEach { self.removeChildBehavior($0) } animator.removeAllBehaviors() self.transitionContext = nil } }}}Objective-C
Section titled “Objective-C”@interface ObjcDropOutAnimator() <UIDynamicAnimatorDelegate, UIViewControllerAnimatedTransitioning>@property (nonatomic, strong) id<UIViewControllerContextTransitioning> transitionContext;@property (nonatomic, strong) UIDynamicAnimator *animator;@property (nonatomic, assign) NSTimeInterval finishTime;@property (nonatomic, assign) BOOL elapsedTimeExceededDuration;@property (nonatomic, assign, getter=isAppearing) BOOL appearing;@property (nonatomic, assign) NSTimeInterval duration;@property (nonatomic, strong) UIAttachmentBehavior *attachBehavior;@property (nonatomic, strong) UICollisionBehavior * collisionBehavior;
@end
@implementation ObjcDropOutAnimator
- (instancetype)initWithDuration:(NSTimeInterval)duration appearing:(BOOL)appearing{ self = [super init]; if (self) { _duration = duration; _appearing = appearing; } return self;}
- (void) animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext{ // Get relevant views and view controllers from transitionContext UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; UIView *fromView = fromVC.view; UIView *toView = toVC.view;
UIView *containerView = transitionContext.containerView; NSTimeInterval duration = [self transitionDuration:transitionContext];
// Hold refrence to transitionContext to notify it of completion self.transitionContext = transitionContext;
// Create dynamic animator UIDynamicAnimator *animator = [[UIDynamicAnimator alloc]initWithReferenceView:containerView]; animator.delegate = self; self.animator = animator;
// Presenting Animation if (self.isAppearing) { fromView.userInteractionEnabled = NO;
// Position toView just above screen CGRect fromViewInitialFrame = [transitionContext initialFrameForViewController:fromVC]; CGRect toViewInitialFrame = toView.frame; toViewInitialFrame.origin.y -= CGRectGetHeight(toViewInitialFrame); toViewInitialFrame.origin.x = CGRectGetWidth(fromViewInitialFrame) * 0.5 - CGRectGetWidth(toViewInitialFrame) * 0.5; toView.frame = toViewInitialFrame;
[containerView addSubview:toView];
// Prevent rotation and adjust bounce UIDynamicItemBehavior *bodyBehavior = [[UIDynamicItemBehavior alloc]initWithItems:@[toView]]; bodyBehavior.elasticity = 0.7; bodyBehavior.allowsRotation = NO;
// Add gravity at exaggerated magnitude so animation doesn't seem slow UIGravityBehavior *gravityBehavior = [[UIGravityBehavior alloc]initWithItems:@[toView]]; gravityBehavior.magnitude = 10.0f;
// Set collision bounds to include off-screen view and have collision floor in center // where our final view should come to rest UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc]initWithItems:@[toView]]; UIEdgeInsets insets = UIEdgeInsetsMake(CGRectGetMinY(toViewInitialFrame), 0.0, CGRectGetHeight(fromViewInitialFrame) * 0.5 - CGRectGetHeight(toViewInitialFrame) * 0.5, 0.0); [collisionBehavior setTranslatesReferenceBoundsIntoBoundaryWithInsets:insets]; self.collisionBehavior = collisionBehavior;
// Keep track of finish time in case we need to end the animator befor the animator pauses self.finishTime = duration + self.animator.elapsedTime;
// Closure that is called after every "tick" of the animator // Check if we exceed duration __weak ObjcDropOutAnimator *weakSelf = self; self.action = ^{ __strong ObjcDropOutAnimator *strongSelf = weakSelf; if (strongSelf) { if (strongSelf.animator.elapsedTime >= strongSelf.finishTime) { strongSelf.elapsedTimeExceededDuration = YES; [strongSelf.animator removeBehavior:strongSelf]; } } };
// `DropOutAnimator` is a composit behavior, so add child behaviors to self [self addChildBehavior:collisionBehavior]; [self addChildBehavior:bodyBehavior]; [self addChildBehavior:gravityBehavior];
// Add self to dynamic animator [self.animator addBehavior:self]; } // Dismissing Animation else { // Allow rotation and have a elastic item UIDynamicItemBehavior *bodyBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[fromView]]; bodyBehavior.elasticity = 0.8; bodyBehavior.angularResistance = 5.0; bodyBehavior.allowsRotation = YES;
// Create gravity with exaggerated magnitude UIGravityBehavior *gravityBehavior = [[UIGravityBehavior alloc] initWithItems:@[fromView]]; gravityBehavior.magnitude = 10.0f;
// Collision boundary is set to have a floor just below the bottom of the screen UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[fromView]]; UIEdgeInsets insets = UIEdgeInsetsMake(0, -1000, -225, -1000); [collisionBehavior setTranslatesReferenceBoundsIntoBoundaryWithInsets:insets]; self.collisionBehavior = collisionBehavior;
// Attachment behavior so view will have effect of hanging from a rope UIOffset offset = UIOffsetMake(70, -(CGRectGetHeight(fromView.bounds) / 2.0));
CGPoint anchorPoint = CGPointMake(CGRectGetMaxX(fromView.bounds) - 40, CGRectGetMinY(fromView.bounds)); anchorPoint = [containerView convertPoint:anchorPoint fromView:fromView]; UIAttachmentBehavior *attachBehavior = [[UIAttachmentBehavior alloc] initWithItem:fromView offsetFromCenter:offset attachedToAnchor:anchorPoint]; attachBehavior.frequency = 3.0; attachBehavior.damping = 0.3; attachBehavior.length = 40; self.attachBehavior = attachBehavior;
// `DropOutAnimator` is a composit behavior, so add child behaviors to self [self addChildBehavior:collisionBehavior]; [self addChildBehavior:bodyBehavior]; [self addChildBehavior:gravityBehavior]; [self addChildBehavior:attachBehavior];
// Add self to dynamic animator [self.animator addBehavior:self];
// Animation has two parts part one is hanging from rope. // Part two is bouncying off-screen // Divide duration in two self.finishTime = (2./3.) * duration + [self.animator elapsedTime];
// After every "tick" of animator check if past time limit __weak ObjcDropOutAnimator *weakSelf = self; self.action = ^{ __strong ObjcDropOutAnimator *strongSelf = weakSelf; if (strongSelf) { if ([strongSelf.animator elapsedTime] >= strongSelf.finishTime) { strongSelf.elapsedTimeExceededDuration = YES; [strongSelf.animator removeBehavior:strongSelf]; } } }; }}
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext{ return self.duration;}
- (void)dynamicAnimatorDidPause:(UIDynamicAnimator *)animator{ // Animator has reached stasis if (self.isAppearing) { // Check if we are out of time if (self.elapsedTimeExceededDuration) { // Move to final positions UIView *toView = [self.transitionContext viewControllerForKey:UITransitionContextToViewControllerKey].view; UIView *containerView = [self.transitionContext containerView]; toView.center = containerView.center; self.elapsedTimeExceededDuration = NO; }
// Clean up and call completion [self.transitionContext completeTransition:![self.transitionContext transitionWasCancelled]]; for (UIDynamicBehavior *behavior in self.childBehaviors) { [self removeChildBehavior:behavior]; } [animator removeAllBehaviors]; self.transitionContext = nil; } // Dismissing else { if (self.attachBehavior) { // If we have an attachment, we are at the end of part one and start part two. [self removeChildBehavior:self.attachBehavior]; self.attachBehavior = nil; [animator addBehavior:self]; NSTimeInterval duration = [self transitionDuration:self.transitionContext]; self.finishTime = 1./3. * duration + [animator elapsedTime]; } else { // Clean up and call completion UIView *fromView = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey].view; UIView *toView = [self.transitionContext viewControllerForKey:UITransitionContextToViewControllerKey].view; [fromView removeFromSuperview]; toView.userInteractionEnabled = YES;
[self.transitionContext completeTransition:![self.transitionContext transitionWasCancelled]]; for (UIDynamicBehavior *behavior in self.childBehaviors) { [self removeChildBehavior:behavior]; } [animator removeAllBehaviors]; self.transitionContext = nil; } }}As composite behavior, DropOutAnimator, can combine a number of different behaviors to perform its presenting and dismissing animations. DropOutAnimator also demonstrates how to use the action block of a behavior to inspect the locations of its items as well as the time elapsed a technique that can be used to remove views that move offscreen or truncate animations that have yet to reach stasis.
For more information 2013 WWDC Session “Advanced Techniques with UIKit Dynamics” as well as SOLPresentingFun
Shade Transition with Real-World Physics Using UIDynamicBehaviors
Section titled “Shade Transition with Real-World Physics Using UIDynamicBehaviors”This example shows how to make an interactive presentation transition with “real-world” physics similar to iOS’s notifications screen.
To start with, we need a presenting view controller that the shade will appear over. This view controller will also act as our UIViewControllerTransitioningDelegate for our presented view controller and will vend animators for our transition. So we’ll create instances of our interactive animators (one for presenting, one for dismissing). We’ll also create an instance of the shade view controller, which, in this example, is just a view controller with a label. Because we want the same pan gesture to drive the entire interaction we pass references to the presenting view controller and the shade into our interactive animators.
class ViewController: UIViewController{ var presentingAnimator: ShadeAnimator! var dismissingAnimator: ShadeAnimator! let shadeVC = ShadeViewController()
lazy var label: UILabel = { let label = UILabel() label.textColor = .blue label.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(label) label.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true label.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true return label }()
override func viewDidLoad() { super.viewDidLoad() label.text = "Swipe Down From Top" presentingAnimator = ShadeAnimator(isAppearing: true, presentingVC: self, presentedVC: shadeVC, transitionDelegate: self) dismissingAnimator = ShadeAnimator(isAppearing: false, presentingVC: self, presentedVC: shadeVC, transitionDelegate: self) }}extension ViewController: UIViewControllerTransitioningDelegate{ func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return EmptyAnimator() }
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return EmptyAnimator() }
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return presentingAnimator }
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return dismissingAnimator }}Objective-C
Section titled “Objective-C”@interface ObjCViewController () <UIViewControllerTransitioningDelegate>@property (nonatomic, strong) ShadeAnimator *presentingAnimator;@property (nonatomic, strong) ShadeAnimator *dismissingAnimator;@property (nonatomic, strong) UILabel *label;@property (nonatomic, strong) ShadeViewController *shadeVC;@end
@implementation ObjCViewController
- (void)viewDidLoad{ [super viewDidLoad]; self.label.text = @"Swipe Down From Top"; self.shadeVC = [[ShadeViewController alloc] init]; self.presentingAnimator = [[ShadeAnimator alloc] initWithIsAppearing:YES presentingVC:self presentedVC:self.shadeVC transitionDelegate:self]; self.dismissingAnimator = [[ShadeAnimator alloc] initWithIsAppearing:NO presentingVC:self presentedVC:self.shadeVC transitionDelegate:self];}
- (UILabel *)label{ if (!_label) { _label = [[UILabel alloc] init]; _label.textColor = [UIColor blueColor]; _label.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:_label]; [_label.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor].active = YES; [_label.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor].active = YES; } return _label;}
#pragma mark - UIViewControllerTransitioningDelegate
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source{ return [[EmptyAnimator alloc] init];}
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed{ return [[EmptyAnimator alloc] init];}
- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForPresentation:(id<UIViewControllerAnimatedTransitioning>)animator{ return self.presentingAnimator;}
- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator{ return self.dismissingAnimator;}
@endWe want really only ever want to present our shade through an interactive transition but because of how UIViewControllerTransitioningDelegate works if we don’t return a regular animation controller our interactive controller will never be used. Because of that we create an EmptyAnimator class that conforms to UIViewControllerAnimatedTransitioning.
class EmptyAnimator: NSObject{
}
extension EmptyAnimator: UIViewControllerAnimatedTransitioning{ func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.0 }}Objective-C
Section titled “Objective-C”@implementation EmptyAnimator
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext{
}
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext{ return 0.0;}
@endFinally we need to actually create the ShadeAnimator which is a subclass of UIDynamicBehavior which conforms to UIViewControllerInteractiveTransitioning.
class ShadeAnimator: UIDynamicBehavior{ // Whether we are presenting or dismissing let isAppearing: Bool
// The view controller that is not the shade weak var presentingVC: UIViewController?
// The view controller that is the shade weak var presentedVC: UIViewController?
// The delegate will vend the animator weak var transitionDelegate: UIViewControllerTransitioningDelegate?
// Feedback generator for haptics on collisions let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
// The context given to the animator at the start of the transition var transitionContext: UIViewControllerContextTransitioning?
// Time limit of the dynamic part of the animation var finishTime: TimeInterval = 4.0
// The Pan Gesture that drives the transition. Not using EdgePan because triggers Notifications screen lazy var pan: UIPanGestureRecognizer = { let pan = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(sender:))) return pan }()
// The dynamic animator that we add `ShadeAnimator` to lazy var animator: UIDynamicAnimator! = { let animator = UIDynamicAnimator(referenceView: self.transitionContext!.containerView) return animator }()
// init with all of our dependencies init(isAppearing: Bool, presentingVC: UIViewController, presentedVC: UIViewController, transitionDelegate: UIViewControllerTransitioningDelegate) { self.isAppearing = isAppearing self.presentingVC = presentingVC self.presentedVC = presentedVC self.transitionDelegate = transitionDelegate super.init() self.impactFeedbackGenerator.prepare()
if isAppearing { self.presentingVC?.view.addGestureRecognizer(pan) } else { self.presentedVC?.view.addGestureRecognizer(pan) }
}
// Setup and moves shade view controller to just above screen if appearing func setupViewsForTransition(with transitionContext: UIViewControllerContextTransitioning) { // Get relevant views and view controllers from transitionContext guard let fromVC = transitionContext.viewController(forKey: .from), let toVC = transitionContext.viewController(forKey: .to), let toView = toVC.view else { return }
let containerView = transitionContext.containerView
// Hold refrence to transitionContext to notify it of completion self.transitionContext = transitionContext if isAppearing { // Position toView just off-screen let fromViewInitialFrame = transitionContext.initialFrame(for: fromVC) var toViewInitialFrame = toView.frame toViewInitialFrame.origin.y -= toViewInitialFrame.height toViewInitialFrame.origin.x = fromViewInitialFrame.width * 0.5 - toViewInitialFrame.width * 0.5 toView.frame = toViewInitialFrame
containerView.addSubview(toView) } else { fromVC.view.addGestureRecognizer(pan) } }
// Handles the entire interaction from presenting/dismissing to completion func handlePan(sender: UIPanGestureRecognizer) { let location = sender.location(in: transitionContext?.containerView) let velocity = sender.velocity(in: transitionContext?.containerView) let fromVC = transitionContext?.viewController(forKey: .from) let toVC = transitionContext?.viewController(forKey: .to)
let touchStartHeight: CGFloat = 90.0 let touchLocationFromBottom: CGFloat = 20.0
switch sender.state { case .began: let beginLocation = sender.location(in: sender.view) if isAppearing { guard beginLocation.y <= touchStartHeight, let presentedVC = self.presentedVC else { break } presentedVC.modalPresentationStyle = .custom presentedVC.transitioningDelegate = transitionDelegate presentingVC?.present(presentedVC, animated: true) } else { guard beginLocation.y >= (sender.view?.frame.height ?? 0.0) - touchStartHeight else { break } presentedVC?.dismiss(animated: true) } case .changed: guard let view = isAppearing ? toVC?.view : fromVC?.view else { return } UIView.animate(withDuration: 0.2) { view.frame.origin.y = location.y - view.bounds.height + touchLocationFromBottom }
transitionContext?.updateInteractiveTransition(view.frame.maxY / view.frame.height ) case .ended, .cancelled: guard let view = isAppearing ? toVC?.view : fromVC?.view else { return } let isCancelled = isAppearing ? (velocity.y < 0.5 || view.center.y < 0.0) : (velocity.y > 0.5 || view.center.y > 0.0) addAttachmentBehavior(with: view, isCancelled: isCancelled) addCollisionBehavior(with: view) addItemBehavior(with: view)
animator.addBehavior(self) animator.delegate = self
self.action = { [weak self] in guard let strongSelf = self else { return } if strongSelf.animator.elapsedTime > strongSelf.finishTime { strongSelf.animator.removeAllBehaviors() } else { strongSelf.transitionContext?.updateInteractiveTransition(view.frame.maxY / view.frame.height ) } } default: break } }
// Add collision behavior that causes bounce when finished func addCollisionBehavior(with view: UIView) { let collisionBehavior = UICollisionBehavior(items: [view]) let insets = UIEdgeInsets(top: -view.bounds.height, left: 0.0, bottom: 0.0, right: 0.0) collisionBehavior.setTranslatesReferenceBoundsIntoBoundary(with: insets) collisionBehavior.collisionDelegate = self self.addChildBehavior(collisionBehavior) }
// Add attachment behavior that pulls shade either to top or bottom func addAttachmentBehavior(with view: UIView, isCancelled: Bool) { let anchor: CGPoint switch (isAppearing, isCancelled) { case (true, true), (false, false): anchor = CGPoint(x: view.center.x, y: -view.frame.height) case (true, false), (false, true): anchor = CGPoint(x: view.center.x, y: view.frame.height) } let attachmentBehavior = UIAttachmentBehavior(item: view, attachedToAnchor: anchor) attachmentBehavior.damping = 0.1 attachmentBehavior.frequency = 3.0 attachmentBehavior.length = 0.5 * view.frame.height self.addChildBehavior(attachmentBehavior) }
// Makes view more bouncy func addItemBehavior(with view: UIView) { let itemBehavior = UIDynamicItemBehavior(items: [view]) itemBehavior.allowsRotation = false itemBehavior.elasticity = 0.6 self.addChildBehavior(itemBehavior) }
}extension ShadeAnimator: UIDynamicAnimatorDelegate{ // Determines transition has ended func dynamicAnimatorDidPause(_ animator: UIDynamicAnimator) { guard let transitionContext = self.transitionContext else { return } let fromVC = transitionContext.viewController(forKey: .from) let toVC = transitionContext.viewController(forKey: .to) guard let view = isAppearing ? toVC?.view : fromVC?.view else { return } switch (view.center.y < 0.0, isAppearing) { case (true, true), (true, false): view.removeFromSuperview() transitionContext.finishInteractiveTransition() transitionContext.completeTransition(!isAppearing) case (false, true): toVC?.view.frame = transitionContext.finalFrame(for: toVC!) transitionContext.finishInteractiveTransition() transitionContext.completeTransition(true) case (false, false): fromVC?.view.frame = transitionContext.initialFrame(for: fromVC!) transitionContext.cancelInteractiveTransition() transitionContext.completeTransition(false) } childBehaviors.forEach { removeChildBehavior($0) } animator.removeAllBehaviors() self.animator = nil self.transitionContext = nil }}extension ShadeAnimator: UICollisionBehaviorDelegate{ // Triggers haptics func collisionBehavior(_ behavior: UICollisionBehavior, beganContactFor item: UIDynamicItem, withBoundaryIdentifier identifier: NSCopying?, at p: CGPoint) { guard p.y > 0.0 else { return } impactFeedbackGenerator.impactOccurred() }}extension ShadeAnimator: UIViewControllerInteractiveTransitioning{ // Starts transition func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { setupViewsForTransition(with: transitionContext) }}Objective-C
Section titled “Objective-C”@interface ShadeAnimator() <UIDynamicAnimatorDelegate, UICollisionBehaviorDelegate>@property (nonatomic, assign) BOOL isAppearing;@property (nonatomic, weak) UIViewController *presentingVC;@property (nonatomic, weak) UIViewController *presentedVC;@property (nonatomic, weak) NSObject<UIViewControllerTransitioningDelegate> *transitionDelegate;@property (nonatomic, strong) UIImpactFeedbackGenerator *impactFeedbackGenerator;@property (nonatomic, strong) id<UIViewControllerContextTransitioning> transitionContext;@property (nonatomic, assign) NSTimeInterval finishTime;@property (nonatomic, strong) UIPanGestureRecognizer *pan;@property (nonatomic, strong) UIDynamicAnimator *animator;@end
@implementation ShadeAnimator
- (instancetype)initWithIsAppearing:(BOOL)isAppearing presentingVC:(UIViewController *)presentingVC presentedVC:(UIViewController *)presentedVC transitionDelegate:(id<UIViewControllerTransitioningDelegate>)transitionDelegate{ self = [super init]; if (self) { _isAppearing = isAppearing; _presentingVC = presentingVC; _presentedVC = presentedVC; _transitionDelegate = transitionDelegate; _impactFeedbackGenerator = [[UIImpactFeedbackGenerator alloc]initWithStyle:UIImpactFeedbackStyleLight]; [_impactFeedbackGenerator prepare]; if (_isAppearing) { [_presentingVC.view addGestureRecognizer:self.pan]; } else { [_presentedVC.view addGestureRecognizer:self.pan]; } } return self;}
#pragma mark - Lazy Init- (UIPanGestureRecognizer *)pan{ if (!_pan) { _pan = [[UIPanGestureRecognizer alloc]initWithTarget:self action:@selector(handlePan:)]; } return _pan;}
- (UIDynamicAnimator *)animator{ if (!_animator) { _animator = [[UIDynamicAnimator alloc]initWithReferenceView:self.transitionContext.containerView]; } return _animator;}
#pragma mark - Setup- (void)setupViewForTransitionWithContext:(id<UIViewControllerContextTransitioning>)transitionContext{ UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; UIView *toView = toVC.view; UIView *containerView = transitionContext.containerView; self.transitionContext = transitionContext; if (self.isAppearing) { CGRect fromViewInitialFrame = [transitionContext initialFrameForViewController:fromVC]; CGRect toViewInitialFrame = toView.frame; toViewInitialFrame.origin.y -= CGRectGetHeight(toViewInitialFrame); toViewInitialFrame.origin.x = CGRectGetWidth(fromViewInitialFrame) * 0.5 - CGRectGetWidth(toViewInitialFrame) * 0.5;
[containerView addSubview:toView]; } else { [fromVC.view addGestureRecognizer:self.pan]; }}
#pragma mark - Gesture- (void)handlePan:(UIPanGestureRecognizer *)sender{ CGPoint location = [sender locationInView:self.transitionContext.containerView]; CGPoint velocity = [sender velocityInView:self.transitionContext.containerView]; UIViewController *fromVC = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; UIViewController *toVC = [self.transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
CGFloat touchStartHeight = 90.0; CGFloat touchLocationFromBottom = 20.0;
if (sender.state == UIGestureRecognizerStateBegan) { CGPoint beginLocation = [sender locationInView:sender.view]; if (self.isAppearing) { if (beginLocation.y <= touchStartHeight) { self.presentedVC.modalPresentationStyle = UIModalPresentationCustom; self.presentedVC.transitioningDelegate = self.transitionDelegate; [self.presentingVC presentViewController:self.presentedVC animated:YES completion:nil]; } } else { if (beginLocation.y >= [sender locationInView:sender.view].y - touchStartHeight) { [self.presentedVC dismissViewControllerAnimated:true completion:nil]; } } } else if (sender.state == UIGestureRecognizerStateChanged) { UIView *view = self.isAppearing ? toVC.view : fromVC.view; [UIView animateWithDuration:0.2 animations:^{ CGRect frame = view.frame; frame.origin.y = location.y - CGRectGetHeight(view.bounds) + touchLocationFromBottom; view.frame = frame; }]; [self.transitionContext updateInteractiveTransition:CGRectGetMaxY(view.frame) / CGRectGetHeight(view.frame)]; } else if (sender.state == UIGestureRecognizerStateEnded || sender.state == UIGestureRecognizerStateCancelled) { UIView *view = self.isAppearing ? toVC.view : fromVC.view; BOOL isCancelled = self.isAppearing ? (velocity.y < 0.5 || view.center.y < 0.0) : (velocity.y > 0.5 || view.center.y > 0.0); [self addAttachmentBehaviorWithView:view isCancelled:isCancelled]; [self addCollisionBehaviorWithView:view]; [self addItemBehaviorWithView:view];
[self.animator addBehavior:self]; self.animator.delegate = self;
__weak ShadeAnimator *weakSelf = self; self.action = ^{ if (weakSelf.animator.elapsedTime > weakSelf.finishTime) { [weakSelf.animator removeAllBehaviors]; } else { [weakSelf.transitionContext updateInteractiveTransition:CGRectGetMaxY(view.frame) / CGRectGetHeight(view.frame)]; } }; }}
#pragma mark - UIViewControllerInteractiveTransitioning- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext{ [self setupViewForTransitionWithContext:transitionContext];}
#pragma mark - Behaviors- (void)addCollisionBehaviorWithView:(UIView *)view{ UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc]initWithItems:@[view]]; UIEdgeInsets insets = UIEdgeInsetsMake(-CGRectGetHeight(view.bounds), 0.0, 0.0, 0.0); [collisionBehavior setTranslatesReferenceBoundsIntoBoundaryWithInsets:insets]; collisionBehavior.collisionDelegate = self; [self addChildBehavior:collisionBehavior];}
- (void)addItemBehaviorWithView:(UIView *)view{ UIDynamicItemBehavior *itemBehavior = [[UIDynamicItemBehavior alloc]initWithItems:@[view]]; itemBehavior.allowsRotation = NO; itemBehavior.elasticity = 0.6; [self addChildBehavior:itemBehavior];}
- (void)addAttachmentBehaviorWithView:(UIView *)view isCancelled:(BOOL)isCancelled{ CGPoint anchor; if ((self.isAppearing && isCancelled) || (!self.isAppearing && isCancelled)) { anchor = CGPointMake(view.center.x, -CGRectGetHeight(view.frame)); } else { anchor = CGPointMake(view.center.x, -CGRectGetHeight(view.frame)); } UIAttachmentBehavior *attachmentBehavior = [[UIAttachmentBehavior alloc]initWithItem:view attachedToAnchor:anchor]; attachmentBehavior.damping = 0.1; attachmentBehavior.frequency = 3.0; attachmentBehavior.length = 0.5 * CGRectGetHeight(view.frame); [self addChildBehavior:attachmentBehavior];}
#pragma mark - UICollisionBehaviorDelegate- (void)collisionBehavior:(UICollisionBehavior *)behavior beganContactForItem:(id<UIDynamicItem>)item withBoundaryIdentifier:(id<NSCopying>)identifier atPoint:(CGPoint)p{ if (p.y > 0.0) { [self.impactFeedbackGenerator impactOccurred]; }}
#pragma mark - UIDynamicAnimatorDelegate- (void)dynamicAnimatorDidPause:(UIDynamicAnimator *)animator{ UIViewController *fromVC = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; UIViewController *toVC = [self.transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; UIView *view = self.isAppearing ? toVC.view : fromVC.view; if (view.center.y < 0.0 && (self.isAppearing || !self.isAppearing)) { [view removeFromSuperview]; [self.transitionContext finishInteractiveTransition]; [self.transitionContext completeTransition:!self.isAppearing]; } else if (view.center.y >= 0.0 && self.isAppearing) { toVC.view.frame = [self.transitionContext finalFrameForViewController:toVC]; [self.transitionContext finishInteractiveTransition]; [self.transitionContext completeTransition:YES]; } else { fromVC.view.frame = [self.transitionContext initialFrameForViewController:fromVC]; [self.transitionContext cancelInteractiveTransition]; [self.transitionContext completeTransition:NO]; } for (UIDynamicBehavior *behavior in self.childBehaviors) { [self removeChildBehavior:behavior]; } [animator removeAllBehaviors]; self.animator = nil; self.transitionContext = nil;}
@endThe animator triggers the start of the transition when the pan gesture begins. And simply moves the view as the gesture changes. But when the gesture ends that is when UIDynamicBehaviors determines if the transition should be completed or cancelled. To do so it uses an attachment and collision behavior. For more information see the 2013 WWDC Session “Advanced Techniques with UIKit Dynamics.
Map Dynamic Animation Position Changes to Bounds
Section titled “Map Dynamic Animation Position Changes to Bounds”This example shows how to customize the UIDynamicItem protocol to map position changes of a view being dynamically animated to bounds changes to create a UIButton that expands and contracts in a elastic fashion.
To start we need to create a new protocol that implements UIDynamicItem but that also has a settable and gettable bounds property.
protocol ResizableDynamicItem: UIDynamicItem{ var bounds: CGRect { set get }}extension UIView: ResizableDynamicItem {}Objective-C
Section titled “Objective-C”@protocol ResizableDynamicItem <UIDynamicItem>@property (nonatomic, readwrite) CGRect bounds;@endWe’ll then create a wrapper object that will wrap a UIDynamicItem but will map center changes to the item’s width and height. We will also provide passthroughs for bounds and transform of the underlying item. This will cause any changes the dynamic animator makes to the center x and y values of the underlying item will be applied to the items width and height.
final class PositionToBoundsMapping: NSObject, UIDynamicItem{ var target: ResizableDynamicItem
init(target: ResizableDynamicItem) { self.target = target super.init() }
var bounds: CGRect { get { return self.target.bounds } }
var center: CGPoint { get { return CGPoint(x: self.target.bounds.width, y: self.target.bounds.height) }
set { self.target.bounds = CGRect(x: 0.0, y: 0.0, width: newValue.x, height: newValue.y) } }
var transform: CGAffineTransform { get { return self.target.transform }
set { self.target.transform = newValue } }}Objective-C
Section titled “Objective-C”@interface PositionToBoundsMapping ()@property (nonatomic, strong) id<ResizableDynamicItem> target;@end
@implementation PositionToBoundsMapping
- (instancetype)initWithTarget:(id<ResizableDynamicItem>)target{ self = [super init]; if (self) { _target = target; } return self;}
- (CGRect)bounds{ return self.target.bounds;}
- (CGPoint)center{ return CGPointMake(self.target.bounds.size.width, self.target.bounds.size.height);}
- (void)setCenter:(CGPoint)center{ self.target.bounds = CGRectMake(0, 0, center.x, center.y);}
- (CGAffineTransform)transform{ return self.target.transform;}
- (void)setTransform:(CGAffineTransform)transform{ self.target.transform = transform;}
@endFinally, we’ll create a UIViewController that will have a button. When the button is pressed we will create PositionToBoundsMapping with the button as the wrapped dynamic item. We create a UIAttachmentBehavior to it’s current position then add an instantaneous UIPushBehavior to it. However because we have mapped changes its bounds, the button does not move but rather grows and shrinks.
final class ViewController: UIViewController{ lazy var button: UIButton = { let button = UIButton(frame: CGRect(x: 0.0, y: 0.0, width: 300.0, height: 200.0)) button.backgroundColor = .red button.layer.cornerRadius = 15.0 button.setTitle("Tap Me", for: .normal) self.view.addSubview(button) return button }()
var buttonBounds = CGRect.zero var animator: UIDynamicAnimator?
override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white button.addTarget(self, action: #selector(self.didPressButton(sender:)), for: .touchUpInside) buttonBounds = button.bounds }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() button.center = view.center }
func didPressButton(sender: UIButton) { // Reset bounds so if button is press twice in a row, previous changes don't propogate button.bounds = buttonBounds let animator = UIDynamicAnimator(referenceView: view)
// Create mapping let buttonBoundsDynamicItem = PositionToBoundsMapping(target: button)
// Add Attachment behavior let attachmentBehavior = UIAttachmentBehavior(item: buttonBoundsDynamicItem, attachedToAnchor: buttonBoundsDynamicItem.center)
// Higher frequency faster oscillation attachmentBehavior.frequency = 2.0
// Lower damping longer oscillation lasts attachmentBehavior.damping = 0.1 animator.addBehavior(attachmentBehavior)
let pushBehavior = UIPushBehavior(items: [buttonBoundsDynamicItem], mode: .instantaneous)
// Change angle to determine how much height/ width should change 45° means heigh:width is 1:1 pushBehavior.angle = .pi / 4.0
// Larger magnitude means bigger change pushBehavior.magnitude = 30.0 animator.addBehavior(pushBehavior) pushBehavior.active = true
// Hold refrence so animator is not released self.animator = animator }}Objective-C
Section titled “Objective-C”@interface ViewController ()@property (nonatomic, strong) UIButton *button;@property (nonatomic, assign) CGRect buttonBounds;@property (nonatomic, strong) UIDynamicAnimator *animator;@end
@implementation ViewController
- (void)viewDidLoad{ [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; [self.button addTarget:self action:@selector(didTapButton:) forControlEvents:UIControlEventTouchUpInside]; self.buttonBounds = self.button.bounds;}
- (void)viewDidLayoutSubviews{ [super viewDidLayoutSubviews]; self.button.center = self.view.center;}
- (UIButton *)button{ if (!_button) { _button = [[UIButton alloc]initWithFrame:CGRectMake(0.0, 0.0, 200.0, 200.0)]; _button.backgroundColor = [UIColor redColor]; _button.layer.cornerRadius = 15.0; [_button setTitle:@"Tap Me" forState:UIControlStateNormal]; [self.view addSubview:_button]; } return _button;}
- (void)didTapButton:(id)sender{ self.button.bounds = self.buttonBounds; UIDynamicAnimator *animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view]; PositionToBoundsMapping *buttonBoundsDynamicItem = [[PositionToBoundsMapping alloc]initWithTarget:sender]; UIAttachmentBehavior *attachmentBehavior = [[UIAttachmentBehavior alloc]initWithItem:buttonBoundsDynamicItem attachedToAnchor:buttonBoundsDynamicItem.center]; [attachmentBehavior setFrequency:2.0]; [attachmentBehavior setDamping:0.3]; [animator addBehavior:attachmentBehavior];
UIPushBehavior *pushBehavior = [[UIPushBehavior alloc] initWithItems:@[buttonBoundsDynamicItem] mode:UIPushBehaviorModeInstantaneous]; pushBehavior.angle = M_PI_4; pushBehavior.magnitude = 2.0; [animator addBehavior:pushBehavior];
[pushBehavior setActive:TRUE];
self.animator = animator;}
@endFor more information see UIKit Dynamics Catalog
Remarks
Section titled “Remarks”An import thing to keep in mind when using UIKit Dynamics is views that are positioned by the animator cannot readily be positioned by other common iOS layout methods.
Newcomers to UIKit Dynamics often struggle with this important caveat. Placing constraints on a view that is also an item of a UIDynamicBehavior will likely cause confusion as a both the auto layout engine and the dynamic animator engine fight over the appropriate position. Similarly, attempting to set the frame directly of a view being controlled by the animator will typically result in jittery animation and unexpected placement. Adding a view as an item to a UIDynamicBehavior means that the animator will take on the responsibility of positioning a view and as such changes of view positions should be implemented through the animator.
A view’s frame that is being updated by a dynamic animator can be set, but that should be immediately followed by messaging the animator to update the animator’s internal model of the view hierarchy. For example, if I have UILabel, label that is an item of a UIGravityBehavior I can move it to the top of the screen to watch it fall again by saying:
label.frame = CGRect(x: 0.0, y: 0.0, width: label.intrinsicContentSize.width, height: label.intrinsicContentSize.height)dynamicAnimator.updateItem(usingCurrentState: label)Objective-C
Section titled “Objective-C”self.label.frame = CGRectMake(0.0, 0.0, self.label.intrinsicContentSize.width, self.label.intrinsicContentSize.height);[self.dynamicAnimator updateItemUsingCurrentState: self.label];After which the animator will apply the gravity behavior from the label’s new location.
Another common technique is to use UIDynamicBehaviors to position views. For example if positioning a view under a touch event is desired, creating a UIAttachmentBehavior and updating its anchorPoint in either touchesMoved or a UIGestureRecognizer’s action is an effective strategy.





