在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 Data
和storyboards
的项目。
Core Data: Runs and Loatcions
这个应用Core Data
的使用是相当小的,只创建了两个entities
:Runs
和Locations
。
打开Runner.xcdatamodeld
,删除默认创建的entities
,然后添加两个entities
:Run
和Location
。为Run
设置以下属性
Run
包含duration
、distance
、timestamp
.还有一个locations
的relationship
连接其他对象。
现在按下图设置Location
的属性:
Locations
包含latitude
、longitude
、timestamp
。另外将Location
连接到Run
。
请确保Locations
的关系设置为To Many
,排列设置为Ordered
。
下一步创建Model classes,单击File\New\File
然后选择Core Data\NSManagedObject subclass
。
接下来会出现刚才创建的实体,请确保选定
Runner
、Run
、Location
。
到这里,你已经完成这个应用的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
分别设置为Home
和New Run
。
设置Navigation Controller
和Home
的关系是root view
。
在New Run
ViewController上点击黄色的图标,通过control-drag
设置New Run
和Detail View Controller
的segue
为push
。
选择刚才设置的Storyboard Segue
为RunDetails
。
现在的Storyboard将会如下图所示:
开始设计Storyboard
这里为大家提供了一些基本素材the resource package。
打开Main.storyboard
,选择Home
view controller。
Home
view controller作为应用的主菜单,将UILable
放置在view的顶部,设置它的text
为Welcome To Runner
。
然后放置三个UIButton
,分别设置它们的title为New Run
,Post Run
,Badges
。
最终如下图所示:
然后通过control-drag
设置New Run
button和New Run View Controller
的segue
为push
。
New Run View Controller
有两个状态:pre-run
和during-run
,通过事务逻辑管理它们是否显示。现在为起添加三个UIlabel
分别设置为
Distance
,Time
,Pace
。
再添加一个UILabel作为消息提示。最后加两个UIbutton:Start\Stop。最后你的视图如下所示:
最后我们来设置Detail View Controller
,首先删除已经默认添加的UILabel,然后添加MKMapView
和四个UIlabel,分别标记
Distance
、Date
、Time
、Pace
。
非常顺利,目前界面基本设计好,后面我们需要回来添加一些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
NewRunViewController
andDetailViewController
. - Connect both the receiced actions(
startPressd
andstopPressed
) 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.