Monday, February 3, 2014

Modal Views in iOS 7 Storyboards


Walkthrough to make a secondary scene in a storyboard act as an animated modal dialog, similar to the Alert view.


Prerequisites:

1. Create a single-view app project.
2. In Project Settings/General/Deployment Info make sure the storyboard name (Main.storyboard) appears in Main Interface popover.


3. Add/remove remove view controllers (VCs) to the storyboard as needed.
4. To make a VC the very first controller - click it to highlight blue, then in Attributes Inspector check the Initial Scene. This will set origin arrow.

5. Create a modal Manual Segue ("seg-way"). In the IB zoom out by double-click on an empty space. Then Ctrl-drag from the presenting VC to the modal VC. Select "modal" in a dialog.

6. To make sure the transition is animated select the segue, check the Animates attribute in the Attribute Inspector.


Now, to use the default animation provided by Xcode - do the following:

1. Add a new View Controller (VC), set it as a CustomClass/Class attribute in Identity Inspector for the presenting view (from-view). Add a button and a tap event handler to the presenting view.

//
//  ALYViewController.m
//

#import "ALYViewController.h"
#import "ALYZoomAnimator.h"

NSString *const MySegueID = @"MySegueID";

@interface ALYViewController ()
@property (strong, nonatomic) ALYZoomAnimator *zoomAnimator;

- (IBAction)showModalViewTapped:(id)sender;
@end


@implementation ALYViewController

- (IBAction)showModalViewTapped:(id)sender {
    [self performSegueWithIdentifier: MySegueID sender: nil];
}
@end

2. Do the same for the modal view, except the code will be dismissing this dialog.
//
//  ALYModalViewController.m
//

#import "ALYModalViewController.h"

@interface ALYModalViewController ()

- (IBAction)dismissTapped:(id)sender;
@end

@implementation ALYModalViewController

- (IBAction)dismissTapped:(id)sender {
    [self dismissViewControllerAnimated: YES completion: nil];
}
@end

3. Run the app. Click the button to reveal the modal dialog.


To add a custom transition with added benefit of controlling size and appearance of the modal dialog (for interactivity, cancellation, etc. in later posts), do the following:

1. Implement <UIViewControllerTransitioningDelegate> and <UIViewControllerAnimatedTransitioning> in a separate animator class. This will handle in/out transitions for a modal dialog. Unfortunately there is no context property to tell which transition is being initiated, in or out. One workaround is to record a VC being presented and then compare it against the to-VC of the transitionContext.

//
//  ALYZoomAnimator.h
//

#import <Foundation/Foundation.h>

@interface ALYZoomAnimator : NSObject <UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning>

@end 

//
//  ALYZoomAnimator.m
//

#import "ALYZoomAnimator.h"

#define IN_DURATION 1.0
#define OUT_DURATION 0.3

@interface ALYZoomAnimator()
@property (weak, nonatomic) UIViewController *presentedVC;

@end


@implementation ALYZoomAnimator

// Returns true while the IN animation is active to reveal the modal sub-dialog.
- (BOOL) isBeingPresented: (id<UIViewControllerContextTransitioning>) transitionContext
{
 UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    return toVC == self.presentedVC;
}


#pragma mark - <UIViewControllerTransitioningDelegate>

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source
{
    self.presentedVC = presented;
    return self;
}

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed
{
    return self;
}


#pragma mark - UIViewControllerAnimatedTransitioning

- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext
{
    return [self isBeingPresented: transitionContext] ? IN_DURATION : OUT_DURATION;
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
    UIView *container = transitionContext.containerView;
 
 UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *fromView = fromVC.view;
    UIView *toView = toVC.view;
    
    CGRect cb = container.bounds;
    CGRect startFrame = CGRectMake(cb.size.width / 2, cb.size.height / 2 - 50, 0, 0);
 CGRect endFrame = CGRectInset(startFrame, -100, -100);
    UIView *snapshot;
    
 if ([self isBeingPresented: transitionContext]) {
        fromView.userInteractionEnabled = NO;
        fromView.tintColor = [UIColor grayColor];
        toView.frame = endFrame;
  snapshot = [toView snapshotViewAfterScreenUpdates:YES];
  snapshot.frame = startFrame;
        snapshot.alpha = 0.0;
        [container addSubview: snapshot];
        [UIView animateWithDuration: [self transitionDuration: transitionContext]
                              delay: 0
             usingSpringWithDamping: 500 initialSpringVelocity: 15
                            options: 0
                         animations: ^{
                             fromView.tintAdjustmentMode = UIViewTintAdjustmentModeDimmed;
                             snapshot.frame = endFrame;
                             snapshot.alpha = 1.0;
                         }
                         completion: ^(BOOL finished) {
                             toView.frame = endFrame;
                             [container addSubview: toView];
                             [snapshot removeFromSuperview];
                             [transitionContext completeTransition:YES];
                         }];
 } else {
  snapshot = [fromView snapshotViewAfterScreenUpdates:YES];
        snapshot.frame = endFrame;
        [container addSubview: snapshot];
        [fromView removeFromSuperview];
        [UIView animateWithDuration: [self transitionDuration: transitionContext]
                              delay: 0
             usingSpringWithDamping: 500 initialSpringVelocity: 15
                            options: 0
                         animations: ^{
                             toView.tintAdjustmentMode = UIViewTintAdjustmentModeNormal;
                             snapshot.frame = startFrame;
                             snapshot.alpha = 0.0;
                         }
                         completion: ^(BOOL finished) {
                             [snapshot removeFromSuperview];
                             toView.userInteractionEnabled = YES;
                             [transitionContext completeTransition:YES];
                         }];
 }
}

@end


2. Provide -prepareForSegue method in the presenting VC.

//
//  ALYViewController.m
//

#import "ALYViewController.h"
#import "ALYZoomAnimator.h"

NSString *const MySegueID = @"MySegueID";

@interface ALYViewController ()
@property (strong, nonatomic) ALYZoomAnimator *zoomAnimator;

- (IBAction)showModalViewTapped:(id)sender;
@end


@implementation ALYViewController

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if (![segue.identifier isEqualToString: MySegueID]) {
        return;
    }
    if (!self.zoomAnimator) {
        self.zoomAnimator = [[ALYZoomAnimator alloc] init];
    }
    
    [segue.destinationViewController setTransitioningDelegate: self.zoomAnimator];
    [segue.destinationViewController setModalPresentationStyle: UIModalPresentationCustom];
}

- (IBAction)showModalViewTapped:(id)sender {
    [self performSegueWithIdentifier: MySegueID sender: nil];
}
@end

3. Run the app. Observe the modal dialog animations, transparency, presenting view in the background providing a context to help users keep track of their position in app. This is unlike the normal transitions that will hide the presenting view.