9
\$\begingroup\$

I would like to display a 3...2...1 countdown on my iOS app (before to take a picture) but I didn't find an elegant solution. I don't want to make a Countdown app with an NSTimer. For me the important is to keep all code in the same method, use a system like Block.

self.infoLabel.text   = [NSString stringWithFormat:@"3"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

self.infoLabel.text = [NSString stringWithFormat:@"2"];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    self.infoLabel.text = [NSString stringWithFormat:@"1"];

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.infoLabel.hidden = YES;


            // DO WHAT I WANT AFTER 3 SECs
        });
    });
});
});

Do you have a better solution? t works okay, but it's not really elegant link up dispatch_after like I did.

\$\endgroup\$
  • \$\begingroup\$ Insistence upon keeping all of the code in a single method is probably the wrong attitude, generally speaking. \$\endgroup\$ – nhgrif Apr 17 '15 at 11:14
  • \$\begingroup\$ Look like a recursive way ? nhgrif \$\endgroup\$ – Léo Derbois Apr 17 '15 at 11:29
9
\$\begingroup\$

The problem with any sort of timer in which each "tick" is a measurement from the previous "tick" is that in software, these "ticks" are not guaranteed to be exact. This means that if your first tick is off by a tenth of a second, every remaining tick will be off by that amount or more. Your second tick is scheduled based on your first tick. This means the actual amount of total time passed is in exact, and the more ticks you have, the more error prone your total time is going to be.

So, for a start, I want to show you what a countdown object class might look like. Then I'll walk through how we'd use this.

CRCountdown.h

typedef void (^CRCountdownCompletion)(void);

@interface CRCountdown : NSObject

- (void)startCountdownWithInterval:(NSTimeInterval)interval 
                             ticks:(NSUInteger)ticks 
                        completion:(CRCountdownCompletion)completion;

@property (readonly) NSUInteger ticksRemaining;
@property (readonly) NSTimeInterval interval;

@end

CRCountdown.m

@interface CRCountdown()

@property NSTimer *timer;
@property (readwrite) NSTimeInterval interval;
@property (copy) CRCountdownCompletion completion;

@end

@implementation CRCountdown

- (void)startCountdownWithInterval:(NSTimeInterval)interval ticks:(NSUInteger)ticks completion:(CRCountdownCompletion)completion {
    self.completion = completion;
    self.interval = interval;
    self.timer = [NSTimer scheduledTimerWithTimeInterval:(interval * ticks) target:self selector:@selector(countdownComplete:) userInfo:nil repeats:NO];
}

- (void)stopCountdown {
    [self.timer invalidate];
}

- (NSUInteger)ticksRemaining {
    if (self.timer.isValid) {
        NSTimeInterval timeRemaining = [self.timer.fireDate timeIntervalSinceDate:[NSDate date]];
        return timeRemaining / self.interval;
    } else {
        return 0;
    }
}

- (void)countdownComplete:(NSTimer *)timer {
    if (self.completion) {
        self.completion();
    }
}

@end

Now, this will guarantee that our completion event happens at as accurate a time as possible. We still can't guarantee that it will happen precisely at a specific moment, but the margin of error is drastically reduced by eliminating the possibility of compounding the error for each one of our "update ticks".

Instead, we set up a repeating NSTimer that's in charge of calling some code to update the UI. This UI update code can look at ticksRemaining (and if necessary, multiply this by interval) to determine what it should update the UI to represent.

It's not even necessary to pass anything for the completion argument, as our interested object will already be polling ticksRemaining, so whenever this returns 0, we know that the timer is complete (complete-ish anyway).

But we can go one step further with our Countdown object and provide a means for including an update block that is fired on each tick, if we want. For this, however, we're going to want two NSTimer's ticking away.

First, in our header, let's add another block typedef:

typedef void (^CRCountdownUpdate)(NSUInteger);

We also need to change our startCountdown... method to take an argument of this type:

- (void)startCountdownWithInterval:(NSTimerInterval)interval
                             ticks:(NSUInteger)ticks
                            update:(CRCountdownUpdate)update
                        completion:(CRCountdownCompletion)completion;

This means adding two more properties in our .m, an updateTimer (NSTimer *) and a CRCountdownUpdate:

@property NSTimer *updateTimer;
@property (copy) CRCountdownUpdate update;

We need to modify our countdownComplete: method so it will invalidate the updateTimer (so update never fires after completion):

- (void)countdownComplete:(NSTimer *)timer {
    [self.updateTimer invalidate];
    if (self.completion) {
        self.completion();
    }
}

And add an update method to handle the update ticks:

- (void)countdownUpdate:(NSTimer *)timer {
    if (self.update) {
        self.update(self.ticksRemaining);
    }
}

The final step is modifying our startCountdown... method to match how we updated the header and to take care of setting up the update timer:

- (void)startCountdownWithInterval:(NSTimeInterval)interval ticks:(NSUInteger)ticks completion:(CRCountdownCompletion)completion update:(CRCountdownUpdate)update {
    self.completion = completion;
    self.update = update;
    self.interval = interval;
    self.timer = [NSTimer scheduledTimerWithTimeInterval:(interval * ticks) target:self selector:@selector(countdownComplete:) userInfo:nil repeats:NO];
    if (self.update) {
        self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(countdownUpdate:) userInfo:nil repeats:YES];
    }
}

Now, using this object is as simple as this:

CRCountdownUpdate update = ^(NSUInteger ticks) {
    self.infoLabel.text = [NSString stringWithFormat:@"%lu", (unsigned long)ticks];
};

CRCountdownCompletion completion = ^{
    self.infoLabel.hidden = YES;
    // do whatever else you want
};

CRCountdown *countdown = [[CRCountdown alloc] init];
[countdown startCountdownWithInterval:1.0
                                ticks:3
                               update:update
                           completion:completion];

Final version of the class I'm suggesting looks like this:

CRCountdown.h

typedef void (^CRCountdownCompletion)(void);
typedef void (^CRCountdownUpdate)(NSUInteger);

@interface CRCountdown : NSObject

- (void)startCountdownWithInterval:(NSTimeInterval)interval ticks:(NSUInteger)ticks completion:(CRCountdownCompletion)completion update:(CRCountdownUpdate)update;
@property (readonly) NSUInteger ticksRemaining;
@property (readonly) NSTimeInterval interval;

@end

CRCountdown.m

@interface CRCountdown()

@property NSTimer *timer;
@property NSTimer *updateTimer;
@property (readwrite) NSTimeInterval interval;
@property (copy) CRCountdownCompletion completion;
@property (copy) CRCountdownUpdate update;

@end

@implementation CRCountdown

- (void)startCountdownWithInterval:(NSTimeInterval)interval ticks:(NSUInteger)ticks completion:(CRCountdownCompletion)completion update:(CRCountdownUpdate)update {
    self.completion = completion;
    self.update = update;
    self.interval = interval;
    self.timer = [NSTimer scheduledTimerWithTimeInterval:(interval * ticks) target:self selector:@selector(countdownComplete:) userInfo:nil repeats:NO];
    if (self.update) {
        self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(countdownUpdate:) userInfo:nil repeats:YES];
    }
}

- (void)stopCountdown {
    [self.updateTimer invalidate];
    [self.timer invalidate];
}

- (NSUInteger)ticksRemaining {
    if (self.timer.isValid) {
        NSTimeInterval timeRemaining = [self.timer.fireDate timeIntervalSinceDate:[NSDate date]];
        return timeRemaining / self.interval;
    } else {
        return 0;
    }
}

- (void)countdownUpdate:(NSTimer *)timer {
    if (self.update) {
        self.update(self.ticksRemaining);
        NSString *s = [NSString stringWithFormat:@"%lu", (unsigned long)self.ticksRemaining];
    }
}

- (void)countdownComplete:(NSTimer *)timer {
    [self.updateTimer invalidate];
    if (self.completion) {
        self.completion();
    }

    self.update = nil;
    self.completion = nil;
}

@end

This of course, could also be done with delegation rather than blocks, which might be an appropriate approach.

I've created a github repo with both the block-based and delegation-based approaches. It can be found here.

\$\endgroup\$
  • \$\begingroup\$ Woah ! Very nice response, good explanation and reusable solution ! I like it ! Did you make Cocoapod with just this Class ? Or somebody else ? 'cause it can be really useful ! \$\endgroup\$ – Léo Derbois Apr 19 '15 at 16:18
  • \$\begingroup\$ No, I just typed it out in Xcode and copypasta'd it into here. \$\endgroup\$ – nhgrif Apr 19 '15 at 16:19
  • \$\begingroup\$ Do you allow me to create a Pod ? Or If you prefer, can you make it ? (of course I will tell that is your ideas if I make it) \$\endgroup\$ – Léo Derbois Apr 19 '15 at 16:24
  • \$\begingroup\$ @LéoDerbois Do you mind helping me learn how to do it? Come join me in chat. \$\endgroup\$ – nhgrif Apr 19 '15 at 16:28
  • \$\begingroup\$ I haven't enough reputation to chat. But you can read official doc here and this article on OSXBricks can help too. \$\endgroup\$ – Léo Derbois Apr 19 '15 at 16:55
1
\$\begingroup\$

Start your countdown on a click or in any method and set there your label and call the method:

self.infoLabel.text = [NSString stringWithFormat:@"3"];
[self performSelector:@selector(countDownMethod) withObject:nil afterDelay:1.0];

Now perform and change text in method like this:

-(void)countDownMethod{

if([self.infoLabel.text isEqualToString:@"3"]){

    self.infoLabel.text   = [NSString stringWithFormat:@"2"];

    [self performSelector:@selector(countDownMethod) withObject:nil afterDelay:1.0];

}else if([self.infoLabel.text isEqualToString:@"2"]){

    self.infoLabel.text   = [NSString stringWithFormat:@"1"];

    [self performSelector:@selector(countDownMethod) withObject:nil afterDelay:1.0];

}else{

    self.infoLabel.hidden = YES;

}

}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.