Silver Challenge solution


#1

This one has taken me a while – a bit of a journey. It looked so simple! But I have learned a lot from, I think(!) doing it thoroughly.
I did complete the challenge quite quickly, creating a new UILabel in the IBAction for the button press and animating incoming/outgoing labels by their frame values. This worked fine.
Then I turned my phone to landscape and it all went wrong. I tried turning off AutoLayout, and putting in a bunch of conditionals for device orientation etc. Was getting very messy.
So I did a bit of reading and discovered you could animate NSLayoutConstraint constants. Refactored the whole thing, it now seems so simple and works great. But as always would welcome suggestions…
I really felt I learnt a lot doing this, so if you want to also, don’t read mine, just google animation/autolayout/constraints or something and work it out!

In the Xib I added two new labels on top of the existing ones. To all the labels I added constraints for vertical placement and horizontal alignment within the container (centered i.e. constant=0). Then I ctrl dragged from the horizontal alignment constraints in the Xib outline to BNRQuizViewController.m class extension like this…

#import "BNRQuizViewController.h"


@interface BNRQuizViewController ()

@property (nonatomic, assign) int currentQuestionIndex;
@property (nonatomic, copy) NSArray *answers;
@property (nonatomic, copy) NSArray *questions;

@property (nonatomic, weak) IBOutlet UILabel *questionLabel;
@property (nonatomic, weak) IBOutlet UILabel *answerLabel;
@property (weak, nonatomic) IBOutlet UILabel *nQuestionLabel;
@property (weak, nonatomic) IBOutlet UILabel *nAnswerLabel;

@property (weak, nonatomic) IBOutlet NSLayoutConstraint *qLabelConstraint;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *aLabelConstraint;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *nQLabelConstraint;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *nALabelConstraint;


@end

@implementation BNRQuizViewController

…creating properties for the horizontal alignment of all four labels.

The nQuestionLabel and nAnswerLabel are labels to come in from the left with the next questions/answers.
In viewDidLoad I set their initial positions.

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.nQLabelConstraint.constant=self.view.frame.size.width+self.nQuestionLabel.frame.size.width;
    self.nALabelConstraint.constant=self.view.frame.size.width+self.nAnswerLabel.frame.size.width;
    
    self.aLabelConstraint.constant=0;
    self.qLabelConstraint.constant=0;
    
}

Then everything else happens in the IBAction for pressing the buttons.
Basically I set up the nAnswerLabel with the new question ( and alpha=0).
Set the new (target) value for its horizontal alignment constraint and a (target)value for the current question’ label,
the new answer label (which will be ???) and the current answer label.

Then in the animation I just animate layoutSubviews, letting the animation interpolate the whole thing.
I put the animation for the alpha values in a separate animation, as I didn’t want to spring animate them.
All the constraint values/text labels are reset as they should be in the completion blocks.
Here it is,

- (IBAction)showQuestionButtonPressed:(UIButton *)sender
{
    
// Step to the next question
    self.currentQuestionIndex++;
    // Am I pas the last question?
    if (self.currentQuestionIndex == [self.questions count]) {
        // Go back to the first question
        self.currentQuestionIndex = 0;
    }
    
// Get the string at the index in the questions array
    NSString *newquestion=self.questions[self.currentQuestionIndex];
    
    
//Assign the next question to the newQuestionLabel
    self.nQuestionLabel.text=newquestion;
//Size it to fit its content
    [self.nQuestionLabel sizeToFit];
//Set its alpha to 0
    self.nQuestionLabel.alpha=0;
 
    
//Assign ??? to the newAnswerLAbel
    self.nAnswerLabel.text=@"???";
//Size it to fit its content
    [self.nAnswerLabel sizeToFit];
//Set its alpha to 0
    self.nAnswerLabel.alpha=0;
    
//Set new values (targets) for constraint constants
    self.qLabelConstraint.constant=0-self.view.frame.size.width-self.questionLabel.frame.size.width/2.0;
    
    self.aLabelConstraint.constant=0-self.view.frame.size.width-self.questionLabel.frame.size.width/2.0;
    
    self.nQLabelConstraint.constant=0;
    
    self.nALabelConstraint.constant=0;
    
    
//Animate layoutSubviews
    [UIView animateWithDuration:1.0
                          delay:0
         usingSpringWithDamping:0.8
          initialSpringVelocity:0
                        options:UIViewAnimationOptionCurveEaseIn
                     animations:^{
                         
                         [self.view layoutSubviews];
                         
                                        }completion:^(BOOL finished){
                                            
                                //Reset constraint constants and label texts
                                            self.questionLabel.text=self.nQuestionLabel.text;
                                            self.answerLabel.text=@"???";
                                            
                                            self.nQLabelConstraint.constant=self.view.frame.size.width+self.nQuestionLabel.frame.size.width;
                                            self.nALabelConstraint.constant=self.view.frame.size.width+self.nAnswerLabel.frame.size.width;
                                            
                                            self.aLabelConstraint.constant=0;
                                            self.qLabelConstraint.constant=0;

                     }];
    
//Animate alpha values
    [UIView animateWithDuration:1.0
                          delay:0
                        options:UIViewAnimationOptionCurveEaseOut
                     animations:^{
                         self.nQuestionLabel.alpha=1.0;
                         self.questionLabel.alpha=0.0;
                         self.nAnswerLabel.alpha=1.0;
                         self.answerLabel.alpha=0.0;
                         
                     }completion:^(BOOL finished){
                         self.answerLabel.alpha=1.0;
                         self.questionLabel.alpha=1.0;
                     }];
   

The animation for the answer button is very similar,

- (IBAction)showAnswerButtonPressed:(UIButton *)sender
{
//Assign new answer to the nAnswerLabel
    self.nAnswerLabel.text=self.answers[self.currentQuestionIndex];
    //Size it to fit its content
    [self.nAnswerLabel sizeToFit];
    self.nAnswerLabel.alpha=0.0;
    
//set target constraint constant
    self.nALabelConstraint.constant=0;
    self.aLabelConstraint.constant=0-self.view.frame.size.width-self.questionLabel.frame.size.width/2.0;
    
//Animate layoutSubviews
    [UIView animateWithDuration:1.0
                          delay:0
         usingSpringWithDamping:0.8
          initialSpringVelocity:0
                        options:UIViewAnimationOptionCurveEaseIn
                     animations:^{
                         
                         [self.view layoutSubviews];
                     
                     }completion:^(BOOL finished){
                 //Reset constraints and labels
                         self.answerLabel.text=self.nAnswerLabel.text;
                         self.aLabelConstraint.constant=0;
                         self.nALabelConstraint.constant=self.view.frame.size.width+self.nAnswerLabel.frame.size.width;

                     }];
    
//Animate alpha changes
    
    [UIView animateWithDuration:1.0
                          delay:0
                        options:UIViewAnimationOptionCurveEaseOut
                     animations:^{
                         self.nAnswerLabel.alpha=1.0;
                         self.answerLabel.alpha=0.0;
                         
                     }completion:^(BOOL finished){
                         self.answerLabel.alpha=1.0;
                         self.questionLabel.alpha=1.0;
                     }];

  }

It seems to work great with auto layout now.
I did have in also disabling of the buttons during animation, but removed it here for simplicity, also because pressing buttons during animation doesn’t mess it up now like it did before I found this method.

All comments welcome!


#2

Thanks for posting pmac. I’ve gone for a completely different programatic approach.

  • I deleted the 2 labels from the XIB file and corresponding IBOutlet, then added them programatically. (If you leave them in and add them programatically, weird things happen as the positions aren’t exactly the same).
  • I added two more UIlabels to display the old values that whizz off the screen, since the old and new text needs to be displayed simultaneously. When the animation completes, I update the “old” labels to the text of the “current” labels.

My original Quiz app hasn’t been universalised, constrained, or adjusted for rotation, so I haven’t bothered accounting for any of that, although it wouldn’t be that hard to do. I guess the interface builder method would be easier in that case.

This is my BNRQuizViewController.m

#import "BNRQuizViewController.h"

@interface BNRQuizViewController ()

@property (nonatomic) int currentQuestionIndex;
@property (nonatomic, copy) NSArray *questions;
@property (nonatomic, copy) NSArray *answers;
@property (nonatomic) UILabel *questionLabel;
@property (nonatomic) UILabel *answerLabel;
@property (nonatomic) UILabel *questionLabelOld;
@property (nonatomic) UILabel *answerLabelOld;

@end

@implementation BNRQuizViewController

- (instancetype)initWithNibName:(NSString *)nibNameOrNil
                         bundle:(NSBundle *)nibBundleOrNil
{
    // Call the init method implenented by the superclass
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    
    if (self) {
        // Create two arrays filled with questions and answers and make the pointers point to them
        
        self.questions = @[@"From what is cognac made?",
                           @"What is 7+7?",
                           @"What is the capital of Vermont?"];
        
        self.answers = @[@"Grapes",
                         @"14",
                         @"Montpelier"];
        }
    
    self.questionLabel = [[UILabel alloc] init];
    self.questionLabelOld = [[UILabel alloc] init];
    self.answerLabel = [[UILabel alloc] init];
    self.answerLabelOld = [[UILabel alloc] init];
    [self.view addSubview:self.questionLabel];
    [self.view addSubview:self.questionLabelOld];
    [self.view addSubview:self.answerLabel];
    [self.view addSubview:self.answerLabelOld];
    
    // Return the address of the new object
    return self;
    
}

- (IBAction)showQuestion:(id)sender
{
    // Step to the next question
    self.currentQuestionIndex++;
    
    // Am I past the last question
    if (self.currentQuestionIndex == [self.questions count]) {
        
        // Go back to the first question
        self.currentQuestionIndex = 0;
    }
    
    // Get the string at that index in the questions array
    NSString *question = self.questions[self.currentQuestionIndex];
    
    // Create a block to simplify updating all of the properties of each label
    void (^labelUpdate)(UILabel *, CGRect, CGFloat) = ^(UILabel *label, CGRect rect, CGFloat alpha) {
        label.textAlignment = NSTextAlignmentCenter;
        label.frame = rect;
        label.alpha = alpha;
    };
    
    labelUpdate(self.questionLabel, CGRectMake(-240, 70, 240, 30), 0.0);
    labelUpdate(self.questionLabelOld, CGRectMake(40, 70, 240, 30), 1.0);
    labelUpdate(self.answerLabel, CGRectMake(-240, 300, 240, 30), 0.0);
    labelUpdate(self.answerLabelOld, CGRectMake(40, 300, 240, 30), 1.0);
    
    self.questionLabel.text = question;
    
    [UIView animateWithDuration:1.0
                          delay:0.0
                        options:UIViewAnimationOptionCurveEaseOut
                     animations:^{
                         
                         labelUpdate(self.questionLabel, CGRectMake(40, 70, 240, 30), 1.0);
                         labelUpdate(self.questionLabelOld, CGRectMake(self.view.frame.size.width + 240, 70, 240, 30), 0.0);
                         labelUpdate(self.answerLabel, CGRectMake(40, 300, 240, 30), 1.0);
                         labelUpdate(self.answerLabelOld, CGRectMake(self.view.frame.size.width + 240, 300, 240, 30), 0.0);

                     }
                     completion:^ (BOOL finished) {
                         self.questionLabelOld.text = self.questionLabel.text;
                         self.answerLabelOld.text = self.answerLabel.text;
                     }];
    // Reset the answer label
    self.answerLabel.text = @"???";
    
}

- (IBAction)showAnswer:(id)sender
{
    // What is the answer to the curent question?
    NSString *answer = self.answers[self.currentQuestionIndex];
    
    // Display it in the answer label
    self.answerLabel.text = answer;
    self.answerLabelOld.text = answer;
}

@end

#3

I used animateWithDuration to fly in and out the labels.


- (IBAction)showQuestion:(id)sender
{
    ...
    NSString *question = self.questions[self.currentQuestionIndex];    
    [self animateLabel:self.questionLabel withNewText];
    [self animateLabel:self.answerLabel withNewText:@"???"];
}

- (IBAction)showAnswer:(id)sender
{
    NSString *answer = self.answers[self.currentQuestionIndex];
    [self animateLabel:self.answerLabel withNewText:answer];
}

-(void)animateLabel:(UILabel *)label
        withNewText:(NSString *)text
{
    CGFloat windowWidth = [[UIScreen mainScreen] bounds].size.width;
    CGRect origFrame = label.frame;
    
    [UIView animateWithDuration:0.25 delay:0.0 options:0 animations:^{
            label.alpha = 0;
            CGRect newFrame = origFrame;
            newFrame.origin.x = windowWidth;
            label.frame = newFrame;
        } completion:^(BOOL finished) {
            label.text = text;
            CGRect newFrame = origFrame;
            newFrame.origin.x = -windowWidth;
            label.frame = newFrame;
            [UIView animateWithDuration:0.25 delay:0.0 options:0 animations:^{
                label.alpha = 1;
                label.frame = origFrame;
            } completion:NULL];
        }];
}

#4

I managed to make this work in basically three steps:

  1. Add two new IBOutlet properties to BNRQuizViewController.m (one for each of the outgoing labels)
  2. Create duplicate objects of the question and answer labels in the XIB, and connect them to the new IBOutlet properties.
  3. Add keyframe animations for the incoming and outgoing labels.

BNRQuizViewController.m

#import "BNRQuizViewController.h"

@interface BNRQuizViewController ()

@property (nonatomic) int currentQuestionIndex;

@property (nonatomic, copy) NSArray *questions;
@property (nonatomic, copy) NSArray *answers;

@property (nonatomic, weak) IBOutlet UILabel *questionLabel;
@property (nonatomic, weak) IBOutlet UILabel *outgoingQuestionLabel;
@property (nonatomic, weak) IBOutlet UILabel *answerLabel;
@property (nonatomic, weak) IBOutlet UILabel *outgoingAnswerLabel;

@end

@implementation BNRQuizViewController

- (instancetype)initWithNibName:(NSString *)nibNameOrNil
                         bundle:(NSBundle *)nibBundleOrNil
{
    // Call the init method implemented by the superclass
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    
    if (self) {
        // Create two arrays filled with questions and answers
        // and make the pointers point to them
        
        self.questions = @[@"From what is cognac made?",
                           @"What is 7+7?",
                           @"What is the capital of Vermont?"];
        
        self.answers = @[@"Grapes",
                         @"14",
                         @"Montpelier"];
    }
    
    // Return the address of the new object
    return self;
}

- (IBAction)showQuestion:(id)sender
{
    // Immediately set outgoing question to current question index
    NSString *outQuestion = self.questions[self.currentQuestionIndex];
    self.outgoingQuestionLabel.text = outQuestion;
    
    // Step to the next question
    self.currentQuestionIndex++;
    
    // Am I past the last question?
    if (self.currentQuestionIndex == [self.questions count]) {
        
        // Go back to the first question
        self.currentQuestionIndex = 0;
    }
    
    // Get the string at that index in the questions array
    NSString *question = self.questions[self.currentQuestionIndex];
    
    
    // Create keyframe animation for outgoing question to slight off
    [UIView animateKeyframesWithDuration:1.0 delay:0.0 options:0 animations:^{
        [UIView addKeyframeWithRelativeStartTime:0 relativeDuration:0.0 animations:^{
            self.outgoingQuestionLabel.alpha = 1.0;
            self.outgoingQuestionLabel.center = CGPointMake(160,92);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:0.5 animations:^{
            self.outgoingQuestionLabel.center = CGPointMake(500,92);
            self.outgoingQuestionLabel.alpha = 0.3;
        }];
    }completion:NULL];
    
    
    // Create keyframe animation for current question to slide in
    [UIView animateKeyframesWithDuration:1.0 delay:0.0 options:0 animations:^{
        [UIView addKeyframeWithRelativeStartTime:0 relativeDuration:0.0 animations:^{
            self.questionLabel.alpha = 0.3;
            self.questionLabel.center = CGPointMake(0,92);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:0.5 animations:^{
            self.questionLabel.center = CGPointMake(160,92);
            self.questionLabel.alpha = 1.0;
        }];
    }completion:NULL];
    
    // Display the string in the question label
    self.questionLabel.text = question;
    
    // Reset the answer label
    self.answerLabel.text = @"???";
}

- (IBAction)showAnswer:(id)sender
{
    // Immediately set outgoing answer to current question index
    NSString *outAnswer = self.answerLabel.text;
    self.outgoingAnswerLabel.text = outAnswer;
    
    // What is the answer to the current question?
    NSString *answer = self.answers[self.currentQuestionIndex];
    
    // Display it in the answer label
    self.answerLabel.text = answer;
    
    // Create keyframe animation for outgoing answer to slight off
    [UIView animateKeyframesWithDuration:1.0 delay:0.0 options:0 animations:^{
        [UIView addKeyframeWithRelativeStartTime:0 relativeDuration:0.0 animations:^{
            self.outgoingAnswerLabel.alpha = 1.0;
            self.outgoingAnswerLabel.center = CGPointMake(160,378);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:0.5 animations:^{
            self.outgoingAnswerLabel.center = CGPointMake(500,378);
            self.outgoingAnswerLabel.alpha = 0.3;
        }];
    }completion:NULL];
    
    
    // Create keyframe animation for current answer to slide in
    [UIView animateKeyframesWithDuration:1.0 delay:0.0 options:0 animations:^{
        [UIView addKeyframeWithRelativeStartTime:0 relativeDuration:0.0 animations:^{
            self.answerLabel.alpha = 0.3;
            self.answerLabel.center = CGPointMake(0,378);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:0.5 animations:^{
            self.answerLabel.center = CGPointMake(160,378);
            self.answerLabel.alpha = 1.0;
        }];
    }completion:NULL];
}

@end