Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4384

How To Make an App Like RunKeeper: Part 2

$
0
0

32_custom_callout

This is the second and final part of a tutorial that teaches you how to create an app like RunKeeper, complete with color-coded maps and badges!

In the first section of the tutorial, you created an app that:

  • Uses Core Location to track your route
  • Continually maps your path and reports your average pace as you run
  • Shows a map of your route when the run is complete; the map line is color-coded so that the slower portions are red and the faster portions are green.

That app is great for recording and displaying data, but to spark that motivation, sometimes you just need a little more of a nudge than a pretty map can provide.

So in this section, you’ll complete the demo MoonRunner app with a badge system that embodies the concept that fitness is a fun and progress-based achievement. Here’s how it works:

  • A list maps out checkpoints of increasing distance to motivate the user.
  • As the user runs, they can see a thumbnail of the next upcoming badge and the remaining distance to it.
  • The first time a user has a run reaching a certain checkpoint, the app awards a badge and notes that run’s average speed.
  • From there, silver and gold versions of the badge are awarded for reaching the checkpoint again at a proportionally faster speed.
  • The post-run map displays a dot at each checkpoint along the path, with a custom callout showing the badge name and image.

Getting Started

Your creativity is the limit when it comes to the theme your badges might take. But I just finished watching the new Cosmos television series, so I was inspired to make all these badges planets, moons, or other milestones in our Solar System. How cosmic!

42_thats_no_moon

You already downloaded the MoonRunner resource pack in part one and added it to your project. Notice that the pack also contains quite a few images and a file named badges.txt. Open badges.txt. Notice that it contains a large JSON array of badge objects. Each badge object contains:

  • A name
  • A piece of cool/interesting information about the badge
  • The distance in meters to achieve this badge
  • The filename of the corresponding image in the resource pack

The badges go all the way from 0 meters — hey, you have to start somewhere — up to the length of a full marathon. Of course, some people choose to go even further on ultra-marathons, and you can consider those ambitious runners as having entered interstellar space.

So the first task is to parse the JSON text into an array of objects. Select File\New\File and the iOS\Cocoa Touch\Objective-C class to create a class Badge that extends NSObject.

Then edit Badge.h to match the following:

#import <Foundation/Foundation.h>
 
@interface Badge : NSObject
 
@property (strong, nonatomic) NSString *name;
@property (strong, nonatomic) NSString *imageName;
@property (strong, nonatomic) NSString *information;
@property float distance;
 
@end

Now you have your Badge object, and it’s time to parse the source JSON. Create a new class BadgeController extending NSObject, and edit the header as follows:

#import <Foundation/Foundation.h>
 
@interface BadgeController : NSObject
 
+ (BadgeController *)defaultController;
 
@end

This class will have a single instance, created once and accessed with defaultController. Open BadgeController.m. Replace the file contents with the following code:

#import "BadgeController.h"
#import "Badge.h"
 
@interface BadgeController ()
 
@property (strong, nonatomic) NSArray *badges;
 
@end
 
@implementation BadgeController
 
+ (BadgeController *)defaultController
{   
    static BadgeController *controller = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        controller = [[BadgeController alloc] init];
        controller.badges = [self badgeArray];
    });
 
    return controller;
}
 
+ (NSArray *)badgeArray
{
    NSString *filePath = [[NSBundle mainBundle] pathForResource:@"badges" ofType:@"txt"];
    NSString *jsonContent = [NSString stringWithContentsOfFile:filePath usedEncoding:nil error:nil];
    NSData *data = [jsonContent dataUsingEncoding:NSUTF8StringEncoding];
    NSArray *badgeDicts = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
 
    NSMutableArray *badgeObjects = [NSMutableArray array];
 
    for (NSDictionary *badgeDict in badgeDicts) {
        [badgeObjects addObject:[self badgeForDictionary:badgeDict]];
    }
 
    return badgeObjects;
}
 
+ (Badge *)badgeForDictionary:(NSDictionary *)dictionary
{
    Badge *badge = [Badge new];
    badge.name = [dictionary objectForKey:@"name"];
    badge.information = [dictionary objectForKey:@"information"];
    badge.imageName = [dictionary objectForKey:@"imageName"];
    badge.distance = [[dictionary objectForKey:@"distance"] floatValue];
    return badge;
}
 
@end

There are three methods here that all work together:

  1. defaultController is publicly accessible, delivers a single instance of the controller, and makes sure the parsing operation happens only once.
  2. badgeArray extracts an array from the text file and creates an object from each element of the array.
  3. badgeForDictionary: does the actual mapping of JSON keys to the Badge object.

And with that, you’re all set to retrieve the badge data. Time to get that data incorporated into your app!

The Badge Storyboards

What’s the good of winning all these badges if you can’t keep them in a nice glass case and marvel at them every once in a while? Or maybe you’re a little more modest and you just say you want to “keep track” of them. :]

In any case, it’s time to add some UI to your storyboards to view badges. Open Main.storyboard. Drag a new table view controller onto the storyboard and control-drag from the Badges button on the Home Screen to create a push segue:

24_segue_badgetable

Next, select the table view inside the table view controller you just added and open the size inspector. Increase the Row Height of the table to 80 points. Then select the prototype cell in the table view and open the attributes inspector. Set the Style to Custom and Identifier to BadgeCell.

Inside the cell, set the customary MoonRunner black background and add a large UIImageView on the left side. Add two smaller UIImageViews with the spaceship-gold and spaceship-silver assets just to its right. Then add two UILabels. Use the screenshot below to help you design the cell:

25_badge_cell

Each badge will occupy one of these cells, with its image on the left and a description of when you earned the badge, or what you need to do to earn it. The two small UIImageViews are for the silver and gold spaceship icons, in case the user earned those levels.

Next, drag in a new view controller (normal one, not table view controller this time) onto the storyboard. Then control-drag from the table view cell in the table view controller you just added to this new view controller, and select push selection segue.

Then style this new screen to look like this:

26_badge_detail_view

On this detail screen, you’ll see:

  • A large UIImageView to show off the badge image — some of these photos from space are stunning
  • A small UIButton on top of the UIImageView, using the info image as a background.
  • A UILabel for the badge Name
  • A UILabel for the badge Distance
  • A UILabel for the date the badge was Earned
  • A UILabel for the Best average pace for this distance
  • A UILabel for the date the user earned the Silver version of the badge (or the pace needed to earn it, if they have yet to do so)
  • The same for the Gold version of the badge
  • Two small UIImageViews with the spaceship-gold and spaceship-silver assets.

The badge information is important because it gives the users a little context, placing their accomplishment in a sort of story or map. The badge images, however small, serve as an extra little reward.

Earning The Badge

You’ve already created the Badge object, and now you need an object to store when a badge was earned. This object will associate a Badge with the various Run objects, if any, where the user achieved versions of this badge.

Click File\New\File. Select iOS\Cocoa Touch\Objective-C class. Call the class BadgeEarnStatus, extending NSObject, and save the file. Then open BadgeEarnStatus.h and replace its contents with the following:

#import <Foundation/Foundation.h>
 
@class Badge;
@class Run;
 
@interface BadgeEarnStatus : NSObject
 
@property (strong, nonatomic) Badge *badge;
@property (strong, nonatomic) Run *earnRun;
@property (strong, nonatomic) Run *silverRun;
@property (strong, nonatomic) Run *goldRun;
@property (strong, nonatomic) Run *bestRun;
 
@end

Then open BadgeEarnStatus.m and add the following imports at the top of the file:

#import "Badge.h"
#import "Run.h"

Now that you have an easy way to associate a Badge with a Run, it’s time to build the logic to make those associations. Open BadgeController.h and add the following constants at the top of the file:

extern float const silverMultiplier;
extern float const goldMultiplier;

Then add the following instance method to the interface:

- (NSArray *)earnStatusesForRuns:(NSArray *)runArray;

The constants silverMultiplier and goldMultiplier will be available to other classes. They are the factors by which a user has to speed up to earn those versions of the badge.

Open BadgeController.m and add the following imports and constant definitions at the top of the file:

#import "BadgeEarnStatus.h"
#import "Run.h"
 
float const silverMultiplier = 1.05; // 5% speed increase
float const goldMultiplier = 1.10; // 10% speed increase

Then, add the following method to the implementation:

- (NSArray *)earnStatusesForRuns:(NSArray *)runs {
    NSMutableArray *earnStatuses = [NSMutableArray array];
 
    for (Badge *badge in self.badges) {
 
        BadgeEarnStatus *earnStatus = [BadgeEarnStatus new];
        earnStatus.badge = badge;
 
        for (Run *run in runs) {
 
            if (run.distance.floatValue > badge.distance) {
 
                // this is when the badge was first earned
                if (!earnStatus.earnRun) {
                    earnStatus.earnRun = run;
                }
 
                double earnRunSpeed = earnStatus.earnRun.distance.doubleValue / earnStatus.earnRun.duration.doubleValue;
                double runSpeed = run.distance.doubleValue / run.duration.doubleValue;
 
                // does it deserve silver?
                if (!earnStatus.silverRun
                    && runSpeed > earnRunSpeed * silverMultiplier) {
 
                    earnStatus.silverRun = run;
                }
 
                // does it deserve gold?
                if (!earnStatus.goldRun
                    && runSpeed > earnRunSpeed * goldMultiplier) {
 
                    earnStatus.goldRun = run;
                }
 
                // is it the best for this distance?
                if (!earnStatus.bestRun) {
                    earnStatus.bestRun = run;
 
                } else {
                    double bestRunSpeed = earnStatus.bestRun.distance.doubleValue / earnStatus.bestRun.duration.doubleValue;
 
                    if (runSpeed > bestRunSpeed) {
                        earnStatus.bestRun = run;
                    }
                }
            }
        }
 
        [earnStatuses addObject:earnStatus];
    }
 
    return earnStatuses;
}

This method compares all the user’s runs to the distance requirements for each badge, making the associations and returning all the BadgeEarnStatus objects in an array.

To do this, the first time a user reaches the badge it finds a earnRunSpeed, which becomes a reference to see if the user improved enough to qualify for the silver or gold versions.

Think of it this way – you’ll still have a chance at earning the gold version of the Mars badge, even if your friend is much faster every time. As long as you’re both making progress, you both win.

35_making_progress

Note: You can see this method uses the overall average speed of a run. For a little extra challenge, try working with more intensive math to also use portions of a run to calculate speeds. For example, your fastest mile could have been in the middle of a two-mile run with a slow beginning and end.

Displaying the Badges

Now it’s time to bring all this badge logic and UI together for the user. You’re going to create two view controllers and one custom table cell in order to link the storyboards with the badge data.

First, create a new class called BadgeCell extending UITableViewCell. Open BadgeCell.h and make it look like this:

#import <UIKit/UIKit.h>
 
@interface BadgeCell : UITableViewCell
 
@property (nonatomic, weak) IBOutlet UILabel *nameLabel;
@property (nonatomic, weak) IBOutlet UILabel *descLabel;
@property (nonatomic, weak) IBOutlet UIImageView *badgeImageView;
@property (nonatomic, weak) IBOutlet UIImageView *silverImageView;
@property (nonatomic, weak) IBOutlet UIImageView *goldImageView;
 
@end

Now you have a custom cell to use in the table view controller for badges you added earlier.

Next, create a class named BadgesTableViewController extending UITableViewController. Open BadgesTableViewController.h and make it look like this:

#import <UIKit/UIKit.h>
 
@interface BadgesTableViewController : UITableViewController
 
@property (strong, nonatomic) NSArray *earnStatusArray;
 
@end

The earnStatusArray will be the result of calling earnStatusesForRuns: in the BadgeController — the method you added earlier to calculate badge statuses.

Open BadgesTableViewController.m and add the following imports to the top of the file:

#import "BadgeEarnStatus.h"
#import "BadgeCell.h"
#import "Badge.h"
#import "MathController.h"
#import "Run.h"

Then add the following properties to the class extension category:

@interface BadgesTableViewController ()
 
@property (strong, nonatomic) UIColor *redColor;
@property (strong, nonatomic) UIColor *greenColor;
@property (strong, nonatomic) NSDateFormatter *dateFormatter;
@property (assign, nonatomic) CGAffineTransform transform;
 
@end

These are a few properties that will be used throughout the table view controller. The colors will be used to color each cell according to whether or not the badge has been earned, for example.

Find viewDidLoad in the implementation and make it look like this:

- (void)viewDidLoad
{
    [super viewDidLoad];
 
    self.redColor = [UIColor colorWithRed:1.0f green:20/255.0 blue:44/255.0 alpha:1.0f];
    self.greenColor = [UIColor colorWithRed:0.0f green:146/255.0 blue:78/255.0 alpha:1.0f];
    self.dateFormatter = [[NSDateFormatter alloc] init];
    [self.dateFormatter setDateStyle:NSDateFormatterMediumStyle];
    self.transform = CGAffineTransformMakeRotation(M_PI/8);
}

This sets up the properties you just added. The properties are essentially caches so that each time a new cell is created you don’t need to recreate the required properties over and over. Date formatters are especially expensive to create and therefore a good idea to cache if you can.

Next, remove the implementations of tableView:numberOfRowsInSection: and numberOfSectionsInTableView:. Then add the following methods:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.earnStatusArray.count;
}
 
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    BadgeCell *cell = [tableView dequeueReusableCellWithIdentifier:@"BadgeCell" forIndexPath:indexPath];
    BadgeEarnStatus *earnStatus = [self.earnStatusArray objectAtIndex:indexPath.row];
 
    cell.silverImageView.hidden = !earnStatus.silverRun;
    cell.goldImageView.hidden = !earnStatus.goldRun;
 
    if (earnStatus.earnRun) {
        cell.nameLabel.textColor = self.greenColor;
        cell.nameLabel.text = earnStatus.badge.name;
        cell.descLabel.textColor = self.greenColor;
        cell.descLabel.text = [NSString stringWithFormat:@"Earned: %@", [self.dateFormatter stringFromDate:earnStatus.earnRun.timestamp]];
        cell.badgeImageView.image = [UIImage imageNamed:earnStatus.badge.imageName];
        cell.silverImageView.transform = self.transform;
        cell.goldImageView.transform = self.transform;
        cell.userInteractionEnabled = YES;
    } else {
        cell.nameLabel.textColor = self.redColor;
        cell.nameLabel.text = @"?????";
        cell.descLabel.textColor = self.redColor;
        cell.descLabel.text = [NSString stringWithFormat:@"Run %@ to Earn", [MathController stringifyDistance:earnStatus.badge.distance]];
        cell.badgeImageView.image = [UIImage imageNamed:@"question_badge.png"];
        cell.userInteractionEnabled = NO;
    }
 
    return cell;
}

These methods tell the table view how many rows to show (the number of badges) and how to set up each cell. As you can see, every cell depends on if the user earned the badge, among other things. Also, the cell can only be selected if the badge has been earned, through the use of userInteractionEnabled.

Now you need to make the badges table view controller have some data to work with. Open HomeViewController.m and add these imports at the top of the file:

#import "BadgesTableViewController.h"
#import "BadgeController.h"

Add this property to the class extension category:

@property (strong, nonatomic) NSArray *runArray;

Now add the following method:

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
 
    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
    NSEntityDescription *entity = [NSEntityDescription
                                   entityForName:@"Run" inManagedObjectContext:self.managedObjectContext];
    [fetchRequest setEntity:entity];
 
    NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"timestamp" ascending:NO];
    [fetchRequest setSortDescriptors:@[sortDescriptor]];
 
    self.runArray = [self.managedObjectContext executeFetchRequest:fetchRequest error:nil];
}

This will have the effect of refreshing the run array every time the view controller appears. It does this using a Core Data fetch to fetch all the runs sorted by timestamp.

Finally, add to prepareForSegue:sender: so it looks like this:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    UIViewController *nextController = [segue destinationViewController];
    if ([nextController isKindOfClass:[NewRunViewController class]]) {
        ((NewRunViewController *) nextController).managedObjectContext = self.managedObjectContext;
    } else if ([nextController isKindOfClass:[BadgesTableViewController class]]) {
        ((BadgesTableViewController *) nextController).earnStatusArray = [[BadgeController defaultController] earnStatusesForRuns:self.runArray];
    }
}

Here, when the badges table view controller is being pushed onto the navigation stack, the earn status of all badges is calculated and passed to the badge table view controller.

Now it’s time to connect all your outlets in the storyboard. Open Main.storyboard and do the following:

  • Set the classes of BadgeCell and BadgesTableViewController
  • .

  • Connect the outlets of BadgeCell: nameLabel, descLabel, badgeImageView, silverImageView, and goldImageView

.

Build &run and check out your new badges! You should see something like this:

33_badge_list

You will only have the ‘Earth’ badge currently – but it’s a start! Now to grab some more badges!

What Does A Runner Have To Do To Get A Gold Medal Around Here?

The last view controller for MoonRunner is the one that shows the details of a badge. Create a new class called BadgeDetailsViewController, extending from UIViewController. Open BadgeDetailsViewController.h and replace its contents with the following:

#import <UIKit/UIKit.h>
 
@class BadgeEarnStatus;
 
@interface BadgeDetailsViewController : UIViewController
 
@property (strong, nonatomic) BadgeEarnStatus *earnStatus;
 
@end

Then open BadgesTableViewController.m. Add the following import at the top of the file:

#import "BadgeDetailsViewController.h"

And add the following method:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([[segue destinationViewController] isKindOfClass:[BadgeDetailsViewController class]]) {
        NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow];
        BadgeEarnStatus *earnStatus = [self.earnStatusArray objectAtIndex:indexPath.row];
        [(BadgeDetailsViewController *)[segue destinationViewController] setEarnStatus:earnStatus];
    }
}

This sets up the segue for when a cell is tapped. It hands over the relevant BadgeEarnStatus instance for the detail view controller to display.

Open BadgeDetailsViewController.m, and add the following imports at the top of the file:

#import "BadgeEarnStatus.h"
#import "Badge.h"
#import "MathController.h"
#import "Run.h"
#import "BadgeController.h"

Then add the following properties to the class extension category:

@interface BadgeDetailsViewController ()
 
@property (nonatomic, weak) IBOutlet UIImageView *badgeImageView;
@property (nonatomic, weak) IBOutlet UILabel *nameLabel;
@property (nonatomic, weak) IBOutlet UILabel *distanceLabel;
@property (nonatomic, weak) IBOutlet UILabel *earnedLabel;
@property (nonatomic, weak) IBOutlet UILabel *silverLabel;
@property (nonatomic, weak) IBOutlet UILabel *goldLabel;
@property (nonatomic, weak) IBOutlet UILabel *bestLabel;
@property (nonatomic, weak) IBOutlet UIImageView *silverImageView;
@property (nonatomic, weak) IBOutlet UIImageView *goldImageView;
 
@end

These are all IBOutlets to the UI.

Now find viewDidLoad and make it look like this:

- (void)viewDidLoad
{
    [super viewDidLoad];
 
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateStyle:NSDateFormatterMediumStyle];
 
    CGAffineTransform transform = CGAffineTransformMakeRotation(M_PI/8);
 
    self.nameLabel.text = self.earnStatus.badge.name;
    self.distanceLabel.text = [MathController stringifyDistance:self.earnStatus.badge.distance];
    self.badgeImageView.image = [UIImage imageNamed:self.earnStatus.badge.imageName];
    self.earnedLabel.text = [NSString stringWithFormat:@"Reached on %@" , [formatter stringFromDate:self.earnStatus.earnRun.timestamp]];
 
    if (self.earnStatus.silverRun) {
        self.silverImageView.transform = transform;
        self.silverImageView.hidden = NO;
        self.silverLabel.text = [NSString stringWithFormat:@"Earned on %@" , [formatter stringFromDate:self.earnStatus.silverRun.timestamp]];
 
    } else {
        self.silverImageView.hidden = YES;
        self.silverLabel.text = [NSString stringWithFormat:@"Pace < %@ for silver!", [MathController stringifyAvgPaceFromDist:(self.earnStatus.earnRun.distance.floatValue * silverMultiplier) overTime:self.earnStatus.earnRun.duration.intValue]];
    }
 
    if (self.earnStatus.goldRun) {
        self.goldImageView.transform = transform;
        self.goldImageView.hidden = NO;
        self.goldLabel.text = [NSString stringWithFormat:@"Earned on %@" , [formatter stringFromDate:self.earnStatus.goldRun.timestamp]];
 
    } else {
        self.goldImageView.hidden = YES;
        self.goldLabel.text = [NSString stringWithFormat:@"Pace < %@ for gold!", [MathController stringifyAvgPaceFromDist:(self.earnStatus.earnRun.distance.floatValue * goldMultiplier) overTime:self.earnStatus.earnRun.duration.intValue]];
    }
 
    self.bestLabel.text = [NSString stringWithFormat:@"Best: %@, %@", [MathController stringifyAvgPaceFromDist:self.earnStatus.bestRun.distance.floatValue overTime:self.earnStatus.bestRun.duration.intValue], [formatter stringFromDate:self.earnStatus.bestRun.timestamp]];
}

This code sets up the badge image and puts all the data about the badge earning into the labels.

The most interesting parts are the prompts that tell the user how much they need to speed up to earn the coveted silver and gold badges. I’ve found these prompts to be very motivating, as the pace is always an improvement that requires effort but is obviously possible.

Finally, add the following method:

- (IBAction)infoButtonPressed:(UIButton *)sender
{
    UIAlertView *alertView = [[UIAlertView alloc]
                              initWithTitle:self.earnStatus.badge.name
                              message:self.earnStatus.badge.information
                              delegate:nil
                              cancelButtonTitle:@"OK"
                              otherButtonTitles:nil];
    [alertView show];
}

This will be invoked when the info button is pressed. It shows a pop-up with the badge’s information.

Great! Now the code side of the badge UI is all set. Open Main.storyboard and make the following connections:

  • Set the BadgeDetailsViewController class.
  • Connect the outlets of BadgeDetailsViewController: badgeImageView, bestLabel, distanceLabel, earnedLabel, goldImageView, goldLabel, nameLabel, silverImageLabel, and silverLabel.
  • The received action infoButtonPressed: to BadgeDetailsView.

Now build & run and check out your new badge details!

34_badge_details

Carrot Motivation

Along with the new portion of the app devoted to badges, you need to go back through the UI of the existing app and update it to incorporate the badges!

Open Main.storyboard and find the ‘New Run’ view controller. Add a UIImageView and a UILabel to its view, somewhere above the Stop button. It should now look like this:

39_next_badge

These will be used as a “carrot-on-a-stick” type motivator for the user as they run, giving them a sneak peak at the next badge and how much farther away it is.

Before you can hook up the UI, you need to add a couple methods to BadgeController to determine which badge is best for a certain distance, and which one is coming up next.

Open BadgeController.h and add the following method declarations to the interface:

- (Badge *)bestBadgeForDistance:(float)distance;
- (Badge *)nextBadgeForDistance:(float)distance;

Also add this line above the interface, just below the imports:

@class Badge;

Now open BadgeController.m and implement those methods like this:

- (Badge *)bestBadgeForDistance:(float)distance {
    Badge *bestBadge = self.badges.firstObject;
    for (Badge *badge in self.badges) {
        if (distance < badge.distance) {
            break;
        }
        bestBadge = badge;
    }
    return bestBadge;
}
 
- (Badge *)nextBadgeForDistance:(float)distance {
    Badge *nextBadge;
    for (Badge *badge in self.badges) {
        nextBadge = badge;
        if (distance < badge.distance) {
            break;
        }
    }
    return nextBadge;
}

These are fairly straightforward — they each take an input, distance in meters, and return either:

  • bestBadgeForDistance: The badge that was last won.
  • nextBadgeForDistance: The badge that is next to be won.

Now open NewRunViewController.m and add the following imports at the top of the file:

#import <AudioToolbox/AudioToolbox.h>
#import "BadgeController.h"
#import "Badge.h"

The badge-related imports are obviously needed, and the AudioToolbox import is so that a sound can be played every time you earn a new badge.

Add these three properties to the class extension category:

@property (nonatomic, strong) Badge *upcomingBadge;
@property (nonatomic, weak) IBOutlet UILabel *nextBadgeLabel;
@property (nonatomic, weak) IBOutlet UIImageView *nextBadgeImageView;

Then find viewWillAppear: and add the following code at the end of the method:

self.nextBadgeLabel.hidden = YES;
self.nextBadgeImageView.hidden = YES;

Just like the other views, the badge label and badge image need to start out hidden.

Then find startPressed: and add the following code at the end of the method:

self.nextBadgeImageView.hidden = NO;
self.nextBadgeLabel.hidden = NO;

This ensures that the badge label and badge image show up when the run starts.

Now find eachSecond and add the following code at the end of the method:

self.nextBadgeLabel.text = [NSString stringWithFormat:@"%@ until %@!", [MathController stringifyDistance:(self.upcomingBadge.distance - self.distance)], self.upcomingBadge.name];
[self checkNextBadge];

This makes sure nextBadgeLabel is always up-to-date as the run progresses.

Then, add this new method:

- (void)checkNextBadge
{
    Badge *nextBadge = [[BadgeController defaultController] nextBadgeForDistance:self.distance];
 
    if (self.upcomingBadge
        && ![nextBadge.name isEqualToString:self.upcomingBadge.name]) {
 
        [self playSuccessSound];
    }
 
    self.upcomingBadge = nextBadge;
    self.nextBadgeImageView.image = [UIImage imageNamed:nextBadge.imageName];
}

This method obtains the next badge using the method you added previously. It checks to see if this badge is new, by comparing the name to the existing upcoming badge store in the upcomingBadge property. If the badge is different, then a success sound is played because that means a new badge had been earned!

You’ll notice that you haven’t implemented playSuccessSound yet. Add the following method:

- (void)playSuccessSound
{
    NSString *path = [NSString stringWithFormat:@"%@%@", [[NSBundle mainBundle] resourcePath], @"/success.wav"];
    SystemSoundID soundID;
    NSURL *filePath = [NSURL fileURLWithPath:path isDirectory:NO];
    AudioServicesCreateSystemSoundID((CFURLRef)CFBridgingRetain(filePath), &soundID);
    AudioServicesPlaySystemSound(soundID);
 
    //also vibrate
    AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}

This plays the success sound, but it also vibrates the phone using the system vibrate sound ID. It also helps to vibrate the phone, in case the user is running in a noisy location, like next to a busy road. Or maybe they’ve got their music on and can’t hear the sound anyway!

Open Main.storyboard and find the ‘New Run’ view controller. Connect the IBOutlets for nextBadgeLabel and nextBadgeImageView. Then build & run to watch the label and image update as you run! Stick around for the sound when you pass a new badge!

36_breadcrumbs

Everything Is Better When It Has a “Space Mode”

After a run has finished, it would be nice to provide the user with the ability to see the last badge that they earned during that run. Let’s add that!

Open Main.storyboard and find the ‘Run Details’ view controller. Add a UIImageView with the same frame as the existing MKMapView (directly on top of it). Then add a UIButton with the info image on it, and a UISwitch with an explanatory UILabel above it. The UI should look like this:

30_space_mode_board

Hide the image view you just added by selecting Hidden from the attributes inspector.

This gives your run details a little more personality, with the capability to switch on a “Space Mode” that shows the freshly-earned badge and its information after a run. There’s no feeling quite like finishing a run and finding out you went all the way to Jupiter. :]

Open DetailViewController.m and add the following imports to the top of the file:

#import "Badge.h"
#import "BadgeController.h"

Next, add these two properties to the class extension category:

@property (nonatomic, weak) IBOutlet UIImageView *badgeImageView;
@property (nonatomic, weak) IBOutlet UIButton *infoButton;

Then add the following code to the end of configureView:

Badge *badge = [[BadgeController defaultController] bestBadgeForDistance:self.run.distance.floatValue];
self.badgeImageView.image = [UIImage imageNamed:badge.imageName];

This sets up the badge image view with the image for the badge that was last earned. It does this by fetching the badge that was last earned using the method that you added previously.

Now add the following method:

- (IBAction)displayModeToggled:(UISwitch *)sender
{
    self.badgeImageView.hidden = !sender.isOn;
    self.infoButton.hidden = !sender.isOn;
    self.mapView.hidden = sender.isOn;
}

This will be fired when the switch is toggled. It swaps out the map for the image when the switch is on, i.e. when they are in “Space Mode”!

And finally, add the following method:

- (IBAction)infoButtonPressed
{
    Badge *badge = [[BadgeController defaultController] bestBadgeForDistance:self.run.distance.floatValue];
 
    UIAlertView *alertView = [[UIAlertView alloc]
                              initWithTitle:badge.name
                              message:badge.information
                              delegate:nil
                              cancelButtonTitle:@"OK"
                              otherButtonTitles:nil];
    [alertView show];
}

This will be fired when the info button is pressed. It shows an alert with the badge information.

Now open Main.storyboard and find the ‘Run Details’ view controller. Connect badgeImageView, infoButton, displayModeToggled:, and infoButtonPressed to the views you recently added. Then build & run and bask in the glory of the badge you earn after that long, hard run!

31_badge_achieved

Mapping the Solar System In Your Town

The post-run map already helps you remember your route, and even identify specific areas where your speed was lower. Another helpful feature that would be nice to add is to note exactly when you pass each badge checkpoint, so you can divide up your run.

Annotations are how map views can display point data like this. These are the moving parts you need:

  • A class adopting MKAnnotation handles the data side of the annotation. Your class provides a coordinate to allow the map to know where to put the annotation.
  • A class extending MKAnnotationView arranges the incoming data from an MKAnnotation into its visual form.

So you’ll begin by arranging the badge data into an array of objects conforming to MKAnnotation. Then you’ll use the MKMapViewDelegate method mapView:viewForAnnotation: to translate that data into MKAnnotationViews.

Create a new class named BadgeAnnotation which extends MKPointAnnotation. Then open BadgeAnnotation.h and replace its contents with the following code:

#import <MapKit/MapKit.h>
 
@interface BadgeAnnotation : MKPointAnnotation
 
@property (strong, nonatomic) NSString *imageName;
 
@end

Then open BadgeController.h and add this method declaration to the interface:

- (NSArray *)annotationsForRun:(Run *)run;

Add this line under the imports in the same file:

@class Run;

Next, open BadgeController.m and add the following imports to the top of the file:

#import <MapKit/MapKit.h>
#import "Location.h"
#import "MathController.h"
#import "BadgeAnnotation.h"

And then add the following method to the implementation:

- (NSArray *)annotationsForRun:(Run *)run
{
    NSMutableArray *annotations = [NSMutableArray array];
 
    int locationIndex = 1;
    float distance = 0;
 
    for (Badge *badge in self.badges) {
        if (badge.distance > run.distance.floatValue) {
            break;
        }
 
        while (locationIndex < run.locations.count) {
 
            Location *firstLoc = [run.locations objectAtIndex:(locationIndex-1)];
            Location *secondLoc = [run.locations objectAtIndex:locationIndex];
 
            CLLocation *firstLocCL = [[CLLocation alloc] initWithLatitude:firstLoc.latitude.doubleValue longitude:firstLoc.longitude.doubleValue];
            CLLocation *secondLocCL = [[CLLocation alloc] initWithLatitude:secondLoc.latitude.doubleValue longitude:secondLoc.longitude.doubleValue];
 
            distance += [secondLocCL distanceFromLocation:firstLocCL];
            locationIndex++;
 
            if (distance >= badge.distance) {
                BadgeAnnotation *annotation = [[BadgeAnnotation alloc] init];
                annotation.coordinate = secondLocCL.coordinate;
                annotation.title = badge.name;
                annotation.subtitle = [MathController stringifyDistance:badge.distance];
                annotation.imageName = badge.imageName;
                [annotations addObject:annotation];
                break;
            }
        }
    }
 
    return annotations;
}

This method loops over all the location points in the run and keeps a cumulative distance for the run. When the cumulative distance passes the next badge’s threshold, a BadgeAnnotation is created. This annotation provides the coordinates of where the badge was earned, the badge’s name, the distance through the run and the badge image name.

Now you have the data ready!

Open DetailViewController.m. Add this import to the top of the file:

#import "BadgeAnnotation.h"

Then, add the following method:

- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id < MKAnnotation >)annotation
{
    BadgeAnnotation *badgeAnnotation = (BadgeAnnotation *)annotation;
 
    MKAnnotationView *annView = [mapView dequeueReusableAnnotationViewWithIdentifier:@"checkpoint"];
    if (!annView) {
        annView=[[MKAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:@"checkpoint"];
        annView.image = [UIImage imageNamed:@"mapPin"];
        annView.canShowCallout = YES;
    }
 
    UIImageView *badgeImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 75, 50)];
    badgeImageView.image = [UIImage imageNamed:badgeAnnotation.imageName];
    badgeImageView.contentMode = UIViewContentModeScaleAspectFit;
    annView.leftCalloutAccessoryView = badgeImageView;
 
    return annView;
}

This is part of the MKMapViewDelegate protocol. It is called every time the map wants a view for the given annotation. It’s your job to return a view and the map view will then handle the logic of putting it in the right place on the map.

In this method, you take an incoming annotation and tell the map how to render it. Notice that the title and subtitle properties don’t make an appearance — that rendering happens automatically inside Map Kit.

Then find loadMap and add the following line of code just underneath the call do addOverlays::

[self.mapView addAnnotations:[[BadgeController defaultController] annotationsForRun:self.run]];

With that, you tell the map view to add all the annotations.

Now you can look at your map after a run, and see all the dots that mean you passed a checkpoint! Build & run the app, start and finish a run, and click Save. The map will now have annotations for each badge earned. Click on one, and you can see its name, picture and distance. Neat!

32_custom_callout

And that’s it! The app is complete! Congratulations on finishing the app.

Where to go From Here

Over the course of this two-part tutorial, you built an app that:

  • Measures and tracks your runs using Core Location
  • Displays real-time data, like the run’s average pace, along with an active map
  • Maps out a run with a color-coded polyline and custom annotations at each checkpoint.
  • Awards badges for personal progress in distance and speed.

Ideas to take it to the next level:

  • Add a table for a user’s past runs.
  • Try using the speeds for segments of a run to earn badges.
    • For example, if you run your fastest 5 km in the middle of a 10 km run, how do you count that towards a silver or gold 5 km badge?
    • Find the average pace between each checkpoint and display it on the MKAnnotationView callout
    • Add badges for lifetime achievements, for instance, running a total of 100 km in a week, 1000 km all-time, etc.
    • Sync a user’s run data with a server.

If you’d like to see how it looks when it’s done, download the full project code here.

Thank you for reading! If you have questions, comments or want to share your successes, please post in the comments below! Happy running through the Solar System! :]

How To Make an App Like RunKeeper: Part 2 is a post from: Ray Wenderlich

The post How To Make an App Like RunKeeper: Part 2 appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4384

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>