Kingyeung Chan

THINK GREAT THOUGHTS AND YOU WILL BE GREAT

大家好,我系Monster.Chan,一名来自中国的 iOS / AWS / Unity3D 开发者,就职于SAINSTORE。在不断修炼,努力提升自己


结合工作经验,目前写了《Getting Started with AWS》、《Websites & Web Apps on AWS》,欢迎试读或者购买

Run-Tracking:Part1

在WWDC 2014会议,Apple Inc向我们展示了即将到来的HealthKit API和相应的 Health App. 与此同时,相关 Health and Fitness应用分类在App Store上越来越受欢迎。

为什么Health App会变得这么受欢迎呢?从以下的的信息也许可以找到原因:
* 健康是极其重要的
* 智能手机可以作为管理和跟踪健康的辅佐工具
* 有趣的应用利用徽章制度可以调动人的积极性,但最重要还是让你持续健身运动

这个章节会告诉你怎么实现一个Run-Tracking。你可以从中学习到:
* 使用Core Location
* 在地图上显示你的运动轨迹
* 汇报你跑步的平均速度
* 根据你跑的路程建立徽章制度

正式开始

我们第一件要做的事就是创建一个工程。
打开Xcode,选择File\New\Project,然后选择iOS\Application\Master-Detail Application

将工程命名为Runner,请务必选上Use Core Data
如此简单,你就创建了一个使用Core Datastoryboards的项目。

Core Data: Runs and Loatcions

这个应用Core Data的使用是相当小的,只创建了两个entitiesRunsLocations
打开Runner.xcdatamodeld,删除默认创建的entities,然后添加两个entitiesRunLocation。为Run设置以下属性
Run包含durationdistancetimestamp.还有一个locationsrelationship连接其他对象。
现在按下图设置Location的属性:

Locations包含latitudelongitudetimestamp。另外将Location连接到Run
请确保Locations的关系设置为To Many,排列设置为Ordered

下一步创建Model classes,单击File\New\File然后选择Core Data\NSManagedObject subclass
接下来会出现刚才创建的实体,请确保选定RunnerRunLocation

到这里,你已经完成这个应用的Core Data模块。

这个工程将会用到MapKit,需要为工程加入相关类库。打开工程的Build Phases,在Link Binary With Libraries添加MapKit

开始设置Storyboards

现在我们即将开始可视化操作,如果你没有使用过Storyboards,请查看相关帮助文档。

打开Main.storyboard

Runner这个应用的流程如下:
* Home功能导航
* New Run用户的徽章和跑步记录
* Run Detail显示运动结果,包括彩色的跑步轨迹

由于我们不需要使用UITableViewController,所以我们将会删除项目自带的MasterViewController直接选上它,delete即可。

然后从Object Libary拖拉两个UIViewController添加到Main.storyboard,将它们的title分别设置为HomeNew Run
设置Navigation ControllerHome的关系是root view

New RunViewController上点击黄色的图标,通过control-drag设置New RunDetail View Controllerseguepush

选择刚才设置的Storyboard SegueRunDetails

现在的Storyboard将会如下图所示:

开始设计Storyboard

这里为大家提供了一些基本素材the resource package
打开Main.storyboard,选择Homeview controller。
Homeview controller作为应用的主菜单,将UILable放置在view的顶部,设置它的textWelcome To Runner
然后放置三个UIButton,分别设置它们的title为New RunPost RunBadges
最终如下图所示:

然后通过control-drag设置New Run button和New Run View Controllerseguepush

New Run View Controller有两个状态:pre-runduring-run,通过事务逻辑管理它们是否显示。现在为起添加三个UIlabel分别设置为 DistanceTimePace
再添加一个UILabel作为消息提示。最后加两个UIbutton:Start\Stop。最后你的视图如下所示:

最后我们来设置Detail View Controller,首先删除已经默认添加的UILabel,然后添加MKMapView和四个UIlabel,分别标记 DistanceDateTimePace

非常顺利,目前界面基本设计好,后面我们需要回来添加一些IBOutlet连接。

开始基本应用的基本流程设计

还记得我们最开始在Storyboard删除了MasterViewController吗?在工程目录先仍然保留这MasterViewController.h、MasterViewController.m, 现在你可以删除它们。

现在我们来为HomeViewController创建代码文件,选择File\New\File,然后选择iOS\Cocoa Touch\Objective-C class, 选择UIViewController作为subclass,将文件命名为HomeViewController。最后在HomeViewController.h添加 NSManagedObjectContext属性,如下所示:

#import <UIKit/UIKit.h>
@interface HomeViewController : UIViewController
@property (strong, nonatomic) NSManagedObjectContext *managedObjectContext;
@end

现在先让我们返回AppDelegate.m,你可能会发现有错误提示,因为MasterViewController.h的引用还没有删除,删除即可,同时加入:

#import "HomeViewController.h"

然后修改application:didFinishLaunchingWithOptions:,如下:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // Override point for customization after application launch.
    UINavigationController *navigationController = (UINavigationController *)self.window.rootViewController;
    HomeViewController *controller = (HomeViewController *)navigationController.topViewController;
    controller.managedObjectContext = self.managedObjectContext;
    return YES;
}

现在让我们为NewRunViewController创建代码文件,同样继承UIViewController,将其命名为NewRunViewController。 为NewRunViewController.h添加NSManagedObjectContext属性:

#import <UIKit/UIKit.h>
@interface NewRunViewController : UIViewController {
    int sum;
}
@property (strong, nonatomic) NSManagedObjectContext *managedObjectContext;
@end

打开NewRunViewController.m,作如下修改:

#import "DetailViewController.h"
#import "Run.h" 
static NSString * const detailSegueName = @"RunDetails";
@interface NewRunViewController () <UIActionSheetDelegate>
@property (nonatomic, strong) Run *run;
@property (nonatomic, weak) IBOutlet UILabel *promptLabel;
@property (nonatomic, weak) IBOutlet UILabel *timeLabel;
@property (nonatomic, weak) IBOutlet UILabel *distLabel;
@property (nonatomic, weak) IBOutlet UILabel *paceLabel;
@property (nonatomic, weak) IBOutlet UIButton *startButton;
@property (nonatomic, weak) IBOutlet UIButton *stopButton;
@end

请务必返回Storyboard对各个参数进行对应的连接。

下面将会对UI上的按钮进行相应状态设置:

- (void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];
    self.startButton.hidden = NO;
    self.promptLabel.hidden = NO;
    self.timeLabel.text = @"";
    self.timeLabel.hidden = YES;
    self.distLabel.hidden = YES;
    self.paceLabel.hidden = YES;
    self.stopButton.hidden = YES;
}

只有startButton、promptLabel在运行时是显示的,然后加入如下方法:

-(IBAction)startPressed:(id)sender{
    // hide the start UI
    self.startButton.hidden = YES;
    self.promptLabel.hidden = YES;
    // show the running UI
    self.timeLabel.hidden = NO;
    self.distLabel.hidden = NO;
    self.paceLabel.hidden = NO;
    self.stopButton.hidden = NO;
}
- (IBAction)stopPressed:(id)sender{
    UIActionSheet *actionSheet = [[UIActionSheet alloc] initWithTitle:@"" delegate:self 
            cancelButtonTitle:@"Cancel" destructiveButtonTitle:nil 
            otherButtonTitles:@"Save", @"Discard", nil];
    actionSheet.actionSheetStyle = UIActionSheetStyleDefault;
    [actionSheet showInView:self.view];
}

上面的代码是对start/stop按钮的方法实现,单击start button进入during-run模式,单击stop button会同时UIActionSheet供用户选择 save还是discard跑步记录。

现在加入UIActionSheet的委托方法:

- (void)actionSheet:(UIActionSheet *)actionSheet clickedButtonAtIndex:(NSInteger)buttonIndex {    
    // save
    if (buttonIndex == 0) {
        [self saveRun]; ///< ADD THIS LINE
        [self performSegueWithIdentifier:detailSegueName sender:nil];
        // discard
    } else if (buttonIndex == 1) {
        [self.navigationController popToRootViewControllerAnimated:YES];
    }
}

选择save会进入Detail View Controller,选择Discard返回菜单首页。

最后我们添加一个方法:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender{
    // Get the new view controller using [segue destinationViewController].
    // Pass the selected object to the new view controller.
    [[segue destinationViewController] setRun:self.run];
}

打开DetailViewController.h,修改成如下图所示:

#import <UIKit/UIKit.h>
@class Run;
@interface DetailViewController : UIViewController
//@property (strong, nonatomic) id detailItem;
@property (strong, nonatomic) Run *run;
@end

这里为DetailViewController添加Run属性,这里是用来显示跑步记录的详细记录。

现在对DetailViewController.m进行编码:

#import "DetailViewController.h"
#import <MapKit/MapKit.h>
@interface DetailViewController () <MKMapViewDelegate>
@property (nonatomic, weak) IBOutlet MKMapView *mapView;
@property (nonatomic, weak) IBOutlet UILabel *distanceLabel;
@property (nonatomic, weak) IBOutlet UILabel *dateLabel;
@property (nonatomic, weak) IBOutlet UILabel *timeLabel;
@property (nonatomic, weak) IBOutlet UILabel *paceLabel;
@end
@implementation DetailViewController
#pragma mark - Managing the detail item
- (void)setRun:(Run *)run{
    if (_run != run) {
        _run = run;
        [self configureView];
    }
}
- (void)configureView{
}
- (void)viewDidLoad{
    [super viewDidLoad];
    [self configureView];
}
@end

This import MapKit,so that you can make use of MKMapKit. It also add outles for the various parts of the UI. Then it sets up the basic for the rest of the code. A method that will configure the view for the current run, whitch is called when the view is loaded and when a run is seted.

There’s last step before you’re ready to return to your Storyboards and connect all your outlets. Open HomeViewController.m and add the following import at the of the file.

#import "NewRunViewController.h"

Then add the following method int the implementation:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender{
    // Get the new view controller using [segue destinationViewController].
    // Pass the selected object to the new view controller.
    UIViewController *nextController = [segue destinationViewController];
    if ([nextController isKindOfClass:[NewRunViewController class]]) {
        ((NewRunViewController *) nextController).managedObjectContext = self.managedObjectContext;
    }
}

Finally, head back to your Stortboard and set the following.

  • Set the class of the Home View Controller of HomeViewController.
  • Set the class of the New Run View COntroller of the NewRunViewController.
  • Connect all the outlets of the NewRunViewControllerand DetailViewController.
  • Connect both the receiced actions(startPressd and stopPressed) in NewRunViewController.
  • Connect the MKMapView to DetailViewController as it delegate.

After you done all right, you can build and run.

Math and Unit

There are several of the views you created and attached in the storyboard involve displaying statistic and times. Core Location measures everything int the units of science, it also called metric system. However people in United States use miles, so you need to make sure both systems are live in your apps.

Click File/New"File. Select iOS/CocoaTouch\Objective-C Class. Call the file MathController, inheriting from NSObject and create it. Then Open MathController.h and make the file look like this:

#import <Foundation/Foundation.h>
@interface MathController : NSObject
+ (NSString *)stringifyDistance:(float)meters;
+ (NSString *)stringifySecondCount:(int)seconds usingLongFormat:(BOOL)longFormat;
+ (NSString *)stringifyAvgPaceFromDist:(float)meters overTime:(int)seconds;
@end

Then open MathController.m and add the following to the top of the file.

static bool const isMetric = YES;
static float const metersInKM = 1000;
static float const metersInMile = 1609.344;

Next, add the following methods to the implementation:

+ (NSString *)stringifyDistance:(float)meters {
    float unitDivider;
    NSString *unitName;
    // metric
    if (isMetric) {
        unitName = @"km";
        // to get from meters to kilometers divide by this
        unitDivider = metersInKM;
    // U.S.
    } else {
        unitName = @"mi";
        // to get from meters to miles divide by this
        unitDivider = metersInMile;
    }
    return [NSString stringWithFormat:@"%.2f %@", (meters / unitDivider), unitName];
}
+ (NSString *)stringifySecondCount:(int)seconds usingLongFormat:(BOOL)longFormat {
    int remainingSeconds = seconds;
    int hours = remainingSeconds / 3600;
    remainingSeconds = remainingSeconds - hours * 3600;
    int minutes = remainingSeconds / 60;
    remainingSeconds = remainingSeconds - minutes * 60;
    if (longFormat) {
        if (hours > 0) {
            return [NSString stringWithFormat:@"%ihr %imin %isec", hours, minutes, remainingSeconds];
        } else if (minutes > 0) {
            return [NSString stringWithFormat:@"%imin %isec", minutes, remainingSeconds];
        } else {
            return [NSString stringWithFormat:@"%isec", remainingSeconds];
        }
    } else {
        if (hours > 0) {
            return [NSString stringWithFormat:@"%02i:%02i:%02i", hours, minutes, remainingSeconds];
        } else if (minutes > 0) {
            return [NSString stringWithFormat:@"%02i:%02i", minutes, remainingSeconds];
        } else {
            return [NSString stringWithFormat:@"00:%02i", remainingSeconds];
        }
    }
}
+ (NSString *)stringifyAvgPaceFromDist:(float)meters overTime:(int)seconds {
    if (seconds == 0 && meters == 0) {
        return @"0";
    }
    float avgPaceSecMeters = seconds / meters;
    float unitMultiplier;
    NSString *unitName;
    // metric
    if (isMetric) {
        unitName = @"min/km";
        unitMultiplier = metersInKM;
    // U.S.
    } else {
        unitName = @"min/mi";
        unitMultiplier = metersInMile;
    }
    int paceMin = (int) ((avgPaceSecMeters * unitMultiplier) / 60);
    int paceSec = (int) (avgPaceSecMeters * unitMultiplier - (paceMin*60));
    return [NSString stringWithFormat:@"%i:%02i %@", paceMin, paceSec, unitName];
}

These methods can helpers that covert from distances,time and speeds into pretty strings.

Starting Run

Now, you'll start recording the location of the user for the durtion of the run.

First, you have to make a important project-level change. Click the project at the top of project navigate. Then select Capabilities tab and open up Background Modes. Trun on the switch for this section on the right and then tick Location update. This will allow the app to update the location even if the user presses the home button or to take a call.

Now back to the code. Open NewRunViewController.m. Then add following imports at the top of the file:

#import <CoreLocation/CoreLocation.h>
#import "MathController.h"
#import "Location.h"

Then add the CLLocationManagerDelegate protocol conformance declaration and several new propertiesto the class extension category:

@interface NewRunViewController ()<UIActionSheetDelegate, CLLocationManagerDelegate, MKMapViewDelegate>
@property (nonatomic, strong) Run *run;
@property (nonatomic, weak) IBOutlet UILabel *promptLabel;
@property (nonatomic, weak) IBOutlet UILabel *timeLabel;
@property (nonatomic, weak) IBOutlet UILabel *distLabel;
@property (nonatomic, weak) IBOutlet UILabel *paceLabel;
@property (nonatomic, weak) IBOutlet UIButton *startButton;
@property (nonatomic, weak) IBOutlet UIButton *stopButton;
@property int seconds;
@property float distance;
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) NSMutableArray *locations;
@property (nonatomic, strong) NSTimer *timer;
  • seconds tracks the durtion of run, in seconds.
  • distance cumulative distance of the run, in meters.
  • locationManager tell to start or stop reading the user’s location.
  • locations is an array to hold all the Location objects that will roll in.
  • timer will fire each second and update the UI accordingly.

Now add the following method to the implementation:

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [self.timer invalidate];
}

With this method, the timer will stoped when user navigates away from the view.

Add the following method:

- (void)eachSecond
{
    self.seconds++;
    self.timeLabel.text = [NSString stringWithFormat:@"Time: %@",  [MathController stringifySecondCount:self.seconds usingLongFormat:NO]];
    self.distLabel.text = [NSString stringWithFormat:@"Distance: %@", [MathController stringifyDistance:self.distance]];
    self.paceLabel.text = [NSString stringWithFormat:@"Pace: %@",  [MathController stringifyAvgPaceFromDist:self.distance overTime:self.seconds]];
}

This method will be called every scend.

Another method, add the following:

- (void)startLocationUpdates{
    // Create the location manager if this object does not
    // already have one.
    if (self.locationManager == nil) {
        self.locationManager = [[CLLocationManager alloc] init];
    }
    self.locationManager.delegate = self;
    self.locationManager.desiredAccuracy = kCLLocationAccuracyBest;
    self.locationManager.activityType = CLActivityTypeFitness;
    // Movement threshold for new events.
    self.locationManager.distanceFilter = 10; // meters
    [self.locationManager startUpdatingLocation];
}

Finally, you tell the manager to start getting location updates.

To actually begin the run, add these lines to the end of startPressed::

- (IBAction)startPressed:(id)sender {
    // hide the start UI
    self.startButton.hidden = YES;
    self.promptLabel.hidden = YES;
    // show the running UI
    self.timeLabel.hidden = NO;
    self.distLabel.hidden = NO;
    self.paceLabel.hidden = NO;
    self.stopButton.hidden = NO;
    self.mapView.hidden = NO;
    self.nextBadgeImageView.hidden = NO;
    self.nextBadgeLabel.hidden = NO;
    self.seconds = 0;
    self.distance = 0;
    self.locations = [NSMutableArray array];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:(1.0) target:self
                                                selector:@selector(eachSecond) userInfo:nil repeats:YES];
    [self startLocationUpdates];
}

Recordig the Run

You’ve created the CLLocationManager, but now you need to get updates from it. Open NewRunController.m and add the following method:

- (void)locationManager:(CLLocationManager *)manager
     didUpdateLocations:(NSArray *)locations{
    for (CLLocation *newLocation in locations) {
        if (newLocation.horizontalAccuracy < 20) { 
            // update distance
            if (self.locations.count > 0) {
                self.distance += [newLocation distanceFromLocation:self.locations.lastObject];
            }
            [self.locations addObject:newLocation];
        }
    }
}

This will be called each time there are new location updates to provide the app.

Saving the Run

You already arranged for the UI to accept this input, and now it’s time to process that data.

add this method to NewRunViewController.m:

- (void)saveRun{
    Run *newRun = [NSEntityDescription insertNewObjectForEntityForName:@"Run"                                        inManagedObjectContext:self.managedObjectContext];
    newRun.distance = [NSNumber numberWithFloat:self.distance];
    newRun.duration = [NSNumber numberWithInt:self.seconds];
    newRun.timestamp = [NSDate date];
    NSMutableArray *locationArray = [NSMutableArray array];
    for (CLLocation *location in self.locations) {
        Location *locationObject = [NSEntityDescription insertNewObjectForEntityForName:@"Location"                                                     inManagedObjectContext:self.managedObjectContext];
        locationObject.timestamp = location.timestamp;
        locationObject.latitude = [NSNumber numberWithDouble:location.coordinate.latitude];
        locationObject.longitude = [NSNumber numberWithDouble:location.coordinate.longitude];
        [locationArray addObject:locationObject];
    }
    newRun.locations = [NSOrderedSet orderedSetWithArray:locationArray];
    self.run = newRun;
    // Save the context.
    NSError *error = nil;
    if (![self.managedObjectContext save:&error]) {
        NSLog(@"Unresolved error %@, %@", error, [error userInfo]);
        abort();
    }
}

In this method, you create a new Run object and give it the cumulative distance and duration values as well as assign it a timestamp.

Each of the CLLocation objects recorded during the run is trimmed down to a new Location object and saved.

Finally, edit actionSheet:clickedButtonAtIndex: so that you stop reading locations and that you save the run before performing the segue.

- (void)actionSheet:(UIActionSheet *)actionSheet clickedButtonAtIndex:(NSInteger)buttonIndex {
    // save
    if (buttonIndex == 0) {
        [self saveRun]; ///< ADD THIS LINE
        [self performSegueWithIdentifier:detailSegueName sender:nil];   
        // discard
    } else if (buttonIndex == 1) {
        [self.navigationController popToRootViewControllerAnimated:YES];
    }
}

Run with the Simulator

I know that you want the apps you build encourage more enthusiasm for fitness.

You don’t need to lace up and head out the door either, for there’s a way to get the simulator to pretend it’s running!

Build & runin the simulator, and select Debug\Location\City Run to have the simulator start generating mock data:

Revealing the Map

Now, it's time to show the run stats.

Open the DetailViewController.m and add this at the top of the file:

#import "MathController.h"
#import "Run.h"
#import "Location.h"

Then, make configureView look like this:

- (void)configureView{
    // Update the user interface for the detail item.
    self.distanceLabel.text = [MathController stringifyDistance:self.run.distance.floatValue];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateStyle:NSDateFormatterMediumStyle];
    self.dateLabel.text = [formatter stringFromDate:self.run.timestamp];
    self.timeLabel.text = [NSString stringWithFormat:@"Time: %@",  [MathController stringifySecondCount:self.run.duration.intValue usingLongFormat:YES]];
    self.paceLabel.text = [NSString stringWithFormat:@"Pace: %@",  [MathController stringifyAvgPaceFromDist:self.run.distance.floatValue overTime:self.run.duration.intValue]];
}

This set up the details of the run into the varius labels.

Drawing the map will require just a little more detail. First, the region needs to be set so that can be shown on the run. Then the line drawn over on the top to indicate where the run went to needs to be created and finally styled.

add the following method:

- (MKCoordinateRegion)mapRegion{
    MKCoordinateRegion region;
    Location *initialLoc = self.run.locations.firstObject;
    float minLat = initialLoc.latitude.floatValue;
    float minLng = initialLoc.longitude.floatValue;
    float maxLat = initialLoc.latitude.floatValue;
    float maxLng = initialLoc.longitude.floatValue;
    for (Location *location in self.run.locations) {
        if (location.latitude.floatValue < minLat) {
            minLat = location.latitude.floatValue;
        }
        if (location.longitude.floatValue < minLng) {
            minLng = location.longitude.floatValue;
        }
        if (location.latitude.floatValue > maxLat) {
            maxLat = location.latitude.floatValue;
        }
        if (location.longitude.floatValue > maxLng) {
            maxLng = location.longitude.floatValue;
        }
    }
    region.center.latitude = (minLat + maxLat) / 2.0f;
    region.center.longitude = (minLng + maxLng) / 2.0f;
    region.span.latitudeDelta = (maxLat - minLat) * 1.1f; // 10% padding
    region.span.longitudeDelta = (maxLng - minLng) * 1.1f; // 10% padding
    return region;
}

An MKCoordinateRegion represents the diaplay region for the map, and you define it by supplying a center point and a span that defines horizontal and vertical ranges.

Next, add the following method:

- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id < MKOverlay >)overlay{
    if ([overlay isKindOfClass:[MKPolyline class]]) {
        MKPolyline *polyLine = (MKPolyline *)overlay;
        MKPolylineRenderer *aRenderer = [[MKPolylineRenderer alloc] initWithPolyline:polyLine];
        aRenderer.strokeColor = [UIColor blackColor];
        aRenderer.lineWidth = 3;
        return aRenderer;
    }   
    return nil;
}

This method says that whenever the map comes across a request to add an overlay, it should check if it's a MKPolyline. If so, it should use a render that will draw a black line. An overlay is something that drawn on the top of the map view. A polyline is such an overlay and represents line drawn from a series of location points.

Lastly, you need to the define the coordinates for the polyline. Add the following method:

- (MKPolyline *)polyLine {
    CLLocationCoordinate2D coords[self.run.locations.count];
    for (int i = 0; i < self.run.locations.count; i++) {
        Location *location = [self.run.locations objectAtIndex:i];
        coords[i] = CLLocationCoordinate2DMake(location.latitude.doubleValue, location.longitude.doubleValue);
    }
    return [MKPolyline polylineWithCoordinates:coords count:self.run.locations.count];
}

Here, you shoved the data from the Location objects into an array of CLLocationCoordinate2D.

Now, let's put these three together!

- (void)loadMap{
    if (self.run.locations.count > 0) {
        self.mapView.hidden = NO;
        // set the map bounds
        [self.mapView setRegion:[self mapRegion]];
        // make the line(s!) on the map
        [self.mapView addOverlay:[self polyLine]];
    } else {
        // no locations were found!
        self.mapView.hidden = YES;
        UIAlertView *alertView = [[UIAlertView alloc]
                                  initWithTitle:@"Error"
                                  message:@"Sorry, this run has no locations saved."
                                  delegate:nil
                                  cancelButtonTitle:@"OK"
                                  otherButtonTitles:nil];
        [alertView show];
    }
}

Here, you make sure that these are points to drawn, set the map region as define earlier, and add the polyline overlay.

Last, you have to add the code at the end of configureView.

[self loadMap];

And now, you can build & Run, you should see a map after your simulator is done its workout.

Color Polyline

Now, the app is pretty cool, but I want to help users train more smarter is to show them how fast or slow they ran.

Click File/New/File. Select iOS\Cocoa Touch\Objective-C class. Call the file MulticolorPolylineSegment that inherits MKPolyline and create it. Then open MulticolorPolylineSegment.h and make it look like this:

#import <MapKit/MapKit.h>
@interface MulticolorPolylineSegment : MKPolyline
@property (strong, nonatomic) UIColor *color;
+ (NSArray *)colorSegmentsForLocations:(NSArray *)locations;
@end

The color is going to denote the speed and so the color of each segment is stored here on the polyline. Other than that, it's the same as MKpolyline.

Then add the following implementation of the mothod:

+ (NSArray *)colorSegmentsForLocations:(NSArray *)locations{
    // make array of all speeds, find slowest+fastest\
    NSMutableArray *speeds = [NSMutableArray array];
    double slowestSpeed = DBL_MAX;
    double fastestSpeed = 0.0;
    for (int i = 1; i < locations.count; i++) {
        Location *firstLoc = [locations objectAtIndex:(i-1)];
        Location *secondLoc = [locations objectAtIndex:i];
        CLLocation *firstLocCL = [[CLLocation alloc] initWithLatitude:firstLoc.latitude.doubleValue longitude:firstLoc.longitude.doubleValue];
        CLLocation *secondLocCL = [[CLLocation alloc] initWithLatitude:secondLoc.latitude.doubleValue longitude:secondLoc.longitude.doubleValue];
        double distance = [secondLocCL distanceFromLocation:firstLocCL];
        double time = [secondLoc.timestamp timeIntervalSinceDate:firstLoc.timestamp];
        double speed = distance/time;
        slowestSpeed = speed < slowestSpeed ? speed : slowestSpeed;
        fastestSpeed = speed > fastestSpeed ? speed : fastestSpeed;
        [speeds addObject:@(speed)];
    }
    return speeds;
}

This method returns the array of speed value for each sequential pair of locations.

First, you'll notice that a loop through all locations from the input. You have to covert each Location to a CCLocation so you can use distanceFromLocation.

Then add the following code, just before the return in the method you just added:

// now knowing the slowest+fastest, we can get mean too
    double meanSpeed = (slowestSpeed + fastestSpeed)/2;
    // RGB for red (slowest)
    CGFloat r_red = 1.0f;
    CGFloat r_green = 20/255.0f;
    CGFloat r_blue = 44/255.0f;
    // RGB for yellow (middle)
    CGFloat y_red = 1.0f;
    CGFloat y_green = 215/255.0f;
    CGFloat y_blue = 0.0f;
    // RGB for green (fastest)
    CGFloat g_red = 0.0f;
    CGFloat g_green = 146/255.0f;
    CGFloat g_blue = 78/255.0f;

Here you define three color you'll use for slow, medium and fast polyline segments.

At last, remove the exiting retun and add the followin code to the end of the method:

    NSMutableArray *colorSegments = [NSMutableArray array];
    for (int i = 1; i < locations.count; i++) {
        Location *firstLoc = [locations objectAtIndex:(i-1)];
        Location *secondLoc = [locations objectAtIndex:i];
        CLLocationCoordinate2D coords[2];
        coords[0].latitude = firstLoc.latitude.doubleValue;
        coords[0].longitude = firstLoc.longitude.doubleValue;
        coords[1].latitude = secondLoc.latitude.doubleValue;
        coords[1].longitude = secondLoc.longitude.doubleValue;
        NSNumber *speed = [speeds objectAtIndex:(i-1)];
        UIColor *color = [UIColor blackColor];
        // between red and yellow
        if (speed.doubleValue < meanSpeed) {
            double ratio = (speed.doubleValue - slowestSpeed) / (meanSpeed - slowestSpeed);
            CGFloat red = r_red + ratio * (y_red - r_red);
            CGFloat green = r_green + ratio * (y_green - r_green);
            CGFloat blue = r_blue + ratio * (y_blue - r_blue);
            color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
            // between yellow and green
        } else {
            double ratio = (speed.doubleValue - meanSpeed) / (fastestSpeed - meanSpeed);
            CGFloat red = y_red + ratio * (g_red - y_red);
            CGFloat green = y_green + ratio * (g_green - y_green);
            CGFloat blue = y_blue + ratio * (g_blue - y_blue);
            color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
        }
        MulticolorPolylineSegment *segment = [MulticolorPolylineSegment polylineWithCoordinates:coords count:2];
        segment.color = color;
        [colorSegments addObject:segment];
    }
    return colorSegments;

In this loop, you determine the value of each pre-calculated speed, relative to the full range of speeds. Then determine the UIColor to apply to the segment.

Applying The Color Segments

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

#import "MulticolorPolylineSegment.h"

Then, find the LoadMap: replace the following line:

[self.mapView addOverlay:[self polyLine]];

with:

NSArray *colorSegmentArray = [MulticolorPolylineSegment colorSegmentsForLocations:self.run.locations.array];
[self.mapView addOverlays:colorSegmentArray]; 

This creates the array of segments using MulticolorPolylineSegment and adds all overays on the map.

Lastly, you shoud prepare your polyline renderer to pay attation to the specific color of each segment. So replace your current implementation of mapView:rendererForOverlay: with the following code:

- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id < MKOverlay >)overlay{    
    if ([overlay isKindOfClass:[MulticolorPolylineSegment class]]) {
        MulticolorPolylineSegment *polyLine = (MulticolorPolylineSegment *)overlay;
        MKPolylineRenderer *aRenderer = [[MKPolylineRenderer alloc] initWithPolyline:polyLine];
        aRenderer.strokeColor = polyLine.color;
        aRenderer.lineWidth = 3;
        return aRenderer;
    }
    return nil;
}

Now you can build & run, you'll see the multicolored map.

Show the map during the run

Now, the post-run map is stunning, but how about during the run?

Open Main.storyboard and find the New Run View Controller. Drag in a new NKMapView:

Then open NewRunViewController.m add this import at the top:

#import <MapKit/MapKit.h>

And add the MKMapViewDelegate protocol conformation declaration to this line:

@interface NewRunViewController ()<UIActionSheetDelegate, CLLocationManagerDelegate, MKMapViewDelegate>

Then add IBOutlet for the map to the class extension category:

@property (nonatomic, weak) IBOutlet MKMapView *mapView;

And add this line to end of the viewWillAppear::

self.mapView.hidden = YES;

This makes sure the map is hidden at first. Now add this to the end of the startPressed::

self.mapView.hidden = NO;

This make sure the map appear when the run starts.

We also want to see the ployline, so let's use our old friend, mapView:rendererForOverlay:. Add the following method:

- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id < MKOverlay >)overlay {
    if ([overlay isKindOfClass:[MKPolyline class]]) {
        MKPolyline *polyLine = (MKPolyline *)overlay;
        MKPolylineRenderer *aRenderer = [[MKPolylineRenderer alloc] initWithPolyline:polyLine];
        aRenderer.strokeColor = [UIColor blueColor];
        aRenderer.lineWidth = 3;
        return aRenderer;
    }
    return nil;
}

In this method the polyline will always in blue.

Next, you need to write some codes to update the map region and draw the polyline ever time valid location is found. Find your implementation of locationManager:didUpdateLocations: and update it to this:

- (void)locationManager:(CLLocationManager *)manager
     didUpdateLocations:(NSArray *)locations{    
    for (CLLocation *newLocation in locations) {
        NSDate *eventDate = newLocation.timestamp;
        NSTimeInterval howRecent = [eventDate timeIntervalSinceNow];
        if (abs(howRecent) < 10.0 && newLocation.horizontalAccuracy < 20) {
            // update distance
            if (self.locations.count > 0) {
                self.distance += [newLocation distanceFromLocation:self.locations.lastObject];
                CLLocationCoordinate2D coords[2];
                coords[0] = ((CLLocation *)self.locations.lastObject).coordinate;
                coords[1] = newLocation.coordinate;
                sum++;
                NSLog(@"0->%lf--%lf", coords[0].latitude, coords[0].longitude);
                NSLog(@"1->%lf--%lf", coords[1].latitude, coords[1].longitude);
                MKCoordinateRegion region =
                MKCoordinateRegionMakeWithDistance(newLocation.coordinate, 500, 500);
                [self.mapView setRegion:region animated:YES];
                [self.mapView addOverlay:[MKPolyline polylineWithCoordinates:coords count:2]];
            }
            [self.locations addObject:newLocation];
        }
    }
    NSLog(@"sum->%d", sum);
}

Now, the map will always centers on the most recent location. and constantly adds little blue polylines.

Lastly, open Main.storyboard and find the New Run View Controller. Connect the outlet for mapView on the map view, and set the mapView's delegate to the view controller.

Build & Run, and start a new run. You'll see the map updating in real-time.

最近的文章

Run-Tracking:part2

This is the second and final part of the Run-Tracking tutorial. In first part, you created an app that: Use Core Location to track your route.Continually maps your path and reports your average pace for your run.Shows the map for yout route when...…

继续阅读
更早的文章

Thinking in Swift

今年最遗憾的事恐怕就是不能亲身参加WWDC2014,虽然没有带来新设备,不过苹果依然給我们带来了重大的喜悦。在objc相当成熟的今天,苹果毅然推出新的编程语言——Swift,无疑这是最大的亮点。这几天一直在关注和探索Swift,对于一门全新的语言,开荒阶段的探索是十分激动人心的,同时由于资料缺失和细节的隐藏不禁让人十分苦恼。个人认为objc有更好的学习曲线,或者这样说,objc除了语法比较特殊外其概念还是比较容易的。而Swift语法相当漂亮,但其背后却隐藏很多的细节和实现,如果无法理解这些...…

继续阅读