Monday, January 10, 2011

iPhone / iPad MapView without Google


One of the challenges I recently faced was the ability to utilise third-party mapping tiles and potentially cache them locally on the iPhone/iPad.

The standard Google MapView and delegate classes supplied by Apple's SDK does not easily allow this so I went searching for another solution. That is when I came across an Open Source framework called route-me.

I already had a standard web solution called myTRC that utilised GeoServer and Geomajas. So ideally I wanted to make use of this existing service by requesting tiles via WMS (Web Map Service).

After I bit of experimenting and researching I was to create a simple iPhone/iPad application that requested and cached WMS tiles.

The only modification I had to make to GeoSever was to create a new layer group and change the projection to EPSG:900913 to avoid the re-projection of the iOS Location Service. I suspect this re-projection requires further investigation as the location service to map is slightly out.

The changes to route-me required two new files called RMGenericMercatorWMSSource.h and RMGenericMercatorWMSSource.m. The following thread will also help you understand its background and issues. See the end of this blog to obtain these two classes.

Note: The following instructions assumes basic knowledge of iPhone development and troubleshooting.

To get started copy the example call 'SimpleMap' supplied by route-me.

1. Add the RMGenericMercatorWMSSource.h and RMGenericMercatorWMSSource.m classes.

2. Modify the initialisation of the MapView to use WMS.

Example.
[mapView setDelegate:self];
 
 NSDictionary *wmsParameters = 
 [[NSDictionary alloc] initWithObjects:
   [NSArray arrayWithObjects:@"EPSG:900913",@"",@"TRCImagery",nil]
          forKeys:[NSArray arrayWithObjects:@"SRS",@"styles",@"layers",nil]];
 
 id myTilesource = [[[RMGenericMercatorWMSSource alloc]   
  initWithBaseUrl:@"http://yourURL.com/wms?"   
  parameters:wmsParameters] autorelease]; 


My full source for MapViewViewController.m
#import "MapViewViewController.h"

#import "RMMapContents.h"
#import "RMFoundation.h"
#import "RMMarker.h"
#import "RMMarkerManager.h"
#import "RMGenericMercatorWMSSource.h"
#import "SlidingMessageViewController.h"

@implementation MapViewViewController

@synthesize mapView;
@synthesize locationManager;
@synthesize currentLocation;
@synthesize infoTextView;
@synthesize slidingInfo;

- (void)dealloc 
{
    [mapView release];
    [locationManager stopUpdatingLocation];
    [locationManager release];    
    [slidingInfo release];
    [super dealloc];
}

// Override initWithNibName:bundle: to load the view using a nib file then perform additional customization that is not appropriate for viewDidLoad.
- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
    if (self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) 
    {
        // customisation
    }
    
    return self;
}

- (BOOL)mapView:(RMMapView *)map 
shouldDragMarker:(RMMarker *)marker
      withEvent:(UIEvent *)event
{
    //If you do not implement this function, then all drags on markers will be sent to the didDragMarker function.
    //If you always return YES you will get the same result
    //If you always return NO you will never get a call to the didDragMarker function
    return YES;
}

- (void)mapView:(RMMapView *)map
  didDragMarker:(RMMarker *)marker 
      withEvent:(UIEvent *)event 
{
    CGPoint position = [[[event allTouches] anyObject] locationInView:mapView];
    
    RMMarkerManager *markerManager = [mapView markerManager];
    
    NSLog(@"New location: east:%lf north:%lf", [marker projectedLocation].easting, [marker projectedLocation].northing);
    CGRect rect = [marker bounds];
    
    [markerManager moveMarker:marker 
                         AtXY:CGPointMake(position.x,position.y +rect.size.height/3)];
    
}

- (void)updateInfo {
    RMMapContents *contents = self.mapView.contents;
    CLLocationCoordinate2D mapCenter = [contents mapCenter];
    
    double truescaleDenominator = [contents scaleDenominator];
    double routemeMetersPerPixel = [contents metersPerPixel]; 
    [infoTextView setText:[NSString stringWithFormat:@"Latitude : %f\nLongitude : %f\nZoom: %.2f Meter per pixel : %.1f\nTrue scale : 1:%.0f", 
                           mapCenter.latitude, 
                           mapCenter.longitude, 
                           contents.zoom, 
                           routemeMetersPerPixel,
                           truescaleDenominator]];
}


- (void)initLocationServices {
    locationManager    = [[CLLocationManager alloc] init];
    locationManager.delegate        = self;
    locationManager.desiredAccuracy = kCLLocationAccuracyBest;
}

- (void) startLocationServices {
    NSLog(@"Starting location service");
    [locationManager startUpdatingLocation];    
}

- (void) stopLocationServices {
    [locationManager stopUpdatingLocation];
}

- (void) initNotifications {
    //Notifications for tile requests.  This code allows for a class to 
    //know when a tile is requested and retrieved
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(tileRequested:)
                                                 name:@"RMTileRequested" object:nil ];
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(tileRetrieved:)
                                                 name:@"RMTileRetrieved" object:nil ];    
}

- (void) initSpinner {
    pendingTiles = 0;    
    spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
    [spinner setCenter: self.view.center]; 
    [self.mapView addSubview:spinner]; 
}

- (void)viewDidLoad 
{
    NSLog(@"viewDidLoad");
    
    [super viewDidLoad];
    
    [self initLocationServices];
    [self startLocationServices];
    
    self.slidingInfo = [[[SlidingMessageViewController alloc] initWithView:infoTextView] autorelease];
    
    //-17.334991+145.578552
    
    currentLocation.latitude = -17.334991;
    currentLocation.longitude = 145.578552;
    
    
    tap = NO;
    
    [mapView setDelegate:self];
    
    NSDictionary *wmsParameters = 
    [[NSDictionary alloc] initWithObjects:
     [NSArray arrayWithObjects:@"EPSG:900913",@"",@"TRCImagery",nil] 
                                  forKeys:[NSArray arrayWithObjects:@"SRS",@"styles",@"layers",nil]];
    
    id myTilesource = [[[RMGenericMercatorWMSSource alloc]   
                        initWithBaseUrl:@"http://yoururl.com/wms?"   
                        parameters:wmsParameters] autorelease]; 
    [[[RMMapContents alloc] initWithView:mapView 
                              tilesource:myTilesource 
                            centerLatLon:currentLocation 
                               zoomLevel:12.0f 
                            maxZoomLevel:18.0f 
                            minZoomLevel:9.0f 
                         backgroundImage:nil] autorelease]; 
    
    
    [mapView setBackgroundColor:[UIColor grayColor]];  //or clear etc 
    
    if (locationManager.location != nil)
    {
        currentLocation = locationManager.location.coordinate;
        
        NSLog(@"Location: Lat: %lf Lon: %lf", currentLocation.latitude, currentLocation.longitude);
    }
    
    [mapView moveToLatLong:currentLocation]; 
    [self.view addSubview:mapView]; 
    
    [self updateInfo];
    
    RMMarkerManager *markerManager = [mapView markerManager];
    
    RMMarker *marker = [[RMMarker alloc] initWithUIImage: [UIImage imageNamed:@"74-location.png"]];
    [marker setTextForegroundColor:[UIColor blueColor]];
    [markerManager addMarker:marker AtLatLong:currentLocation];
    [marker release];
    
    [self initNotifications];
    [self initSpinner];
    
}

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation 
{
    return YES;
}


- (void)didReceiveMemoryWarning 
{
    // due to a bug, RMMapView should never be released, as it causes the application to crash
    //[super didReceiveMemoryWarning]; // Releases the view if it doesn't have a superview
    
    [mapView.contents didReceiveMemoryWarning];
}



#pragma mark --
#pragma mark locationManagerDelegate Methods

- (void)locationManager: (CLLocationManager *)manager 
    didUpdateToLocation: (CLLocation *)newLocation
           fromLocation: (CLLocation *)oldLocation
{
    
    if ([newLocation.timestamp timeIntervalSinceNow] > 10.0) {
        NSLog(@"The position is too recent" );
        return;
    }
    
    NSLog(@"Moving from lat: %lf lon: %lf to lat: %lf lon: %lf accuracy: %f,%df", 
          oldLocation.coordinate.latitude, oldLocation.coordinate.longitude,
          newLocation.coordinate.latitude, newLocation.coordinate.longitude,
          newLocation.horizontalAccuracy, newLocation.verticalAccuracy);
    
    currentLocation = newLocation.coordinate;
    RMMarkerManager *markerManager = [mapView markerManager];
    NSArray *markers = [markerManager markers];
    for (NSInteger i = 0; i < [markers count]; ++i)
    {
        RMMarker *marker = [markers objectAtIndex: i];
        CLLocationCoordinate2D location = [markerManager latitudeLongitudeForMarker:marker];
        if (location.latitude == oldLocation.coordinate.latitude &&
            location.longitude == oldLocation.coordinate.longitude)
        {
            [markerManager moveMarker: marker
                             AtLatLon: newLocation.coordinate];
            break; // We're done. 
        }
    }
    
    [mapView moveToLatLong:currentLocation]; 
}

- (void)locationManager: (CLLocationManager *)manager
       didFailWithError: (NSError *)error
{
    NSLog(@"Location Manager error: %@", [error localizedDescription]);
}

#pragma mark -
#pragma mark Delegate methods

- (void) afterMapMove: (RMMapView*) map {
    [self updateInfo];
    [self stopLocationServices];
}

- (void) afterMapZoom: (RMMapView*) map byFactor: (float) zoomFactor near:(CGPoint) center {
    [self updateInfo];
}

- (IBAction) toggleInfo: (id) sender {
    [self.slidingInfo toggle];
}

- (void) clearCache {
    NSLog(@"Clearing cache");
    [mapView.contents removeAllCachedImages];
    [[NSURLCache sharedURLCache] removeAllCachedResponses];
}

- (IBAction) promptClearCache: (id) sender {
    UIAlertView *alertWithYesNoButtons = [[UIAlertView alloc] initWithTitle:@"Clear Off-line Cache"
                                                                    message:@"Clear the Off-line Map Cache?" delegate:self cancelButtonTitle:@"No" otherButtonTitles:@"Yes", nil];
    [alertWithYesNoButtons setTag: 1];
    [alertWithYesNoButtons show];
    [alertWithYesNoButtons release];
}

- (IBAction) locateMe: (id) sender {
    [self startLocationServices];
}

- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
    NSString *buttonTitle = [alertView buttonTitleAtIndex:buttonIndex];
    
    if ([alertView tag] == 1) {
        if ([buttonTitle isEqualToString:@"Yes"]) {
            [self clearCache];
        }
    }
}

-(void)tileRequested:(NSNotification *)notification
{
    if (pendingTiles == 0) {
        [spinner startAnimating];
    }
    pendingTiles++;
    NSLog(@"Tile request started - %d", pendingTiles);
}

-(void)tileRetrieved:(NSNotification *)notification;
{
    pendingTiles--;
    if (pendingTiles == 0) {
        [spinner stopAnimating];
    }
    NSLog(@"Tile request ended - %d", pendingTiles);
}

@synthesize tap;
@synthesize tapCount;
@end
RMGenericMercatorWMSSource.h
#import 
#import 
#import 
#import "RMLatLong.h"
#import "RMAbstractMercatorWebSource.h"

typedef struct { 
 CGPoint ul; 
 CGPoint lr; 
} CGXYRect; 

@interface RMGenericMercatorWMSSource : RMAbstractMercatorWebSource  {
 NSMutableDictionary *wmsParameters;
 NSString *urlTemplate;
 CGFloat initialResolution, originShift;
}

- (RMGenericMercatorWMSSource *)initWithBaseUrl:(NSString *)baseUrl parameters:(NSDictionary *)params;

- (CGPoint)LatLonToMeters:(CLLocationCoordinate2D)latlon;
- (float)ResolutionAtZoom:(int)zoom;
- (CGPoint)PixelsToMeters:(int)px PixelY:(int)py atZoom:(int)zoom;
- (CLLocationCoordinate2D)MetersToLatLon:(CGPoint)meters;
- (CGXYRect)TileBounds:(RMTile)tile;

@end
RMGenericMercatorWMSSource.m
#import "RMGenericMercatorWMSSource.h"

CGFloat DegreesToRadians(CGFloat degrees) {return degrees * M_PI / 180;}; 
CGFloat RadiansToDegrees(CGFloat radians) {return radians * 180/ M_PI;}; 

@implementation RMGenericMercatorWMSSource

-(id) initWithBaseUrl:(NSString *)baseUrl parameters:(NSDictionary *)params
{ 
 if (![super init]) 
  return nil; 
 initialResolution = 2 * M_PI * 6378137 / [self tileSideLength];
 // 156543.03392804062 for sideLength 256 pixels 
 originShift = 2 * M_PI * 6378137 / 2.0;
 // 20037508.342789244 
 
 // setup default parameters
 // use official EPSG:3857 by default, user can override to 900913 if needed.
 wmsParameters = [[NSMutableDictionary alloc] initWithObjects:[[[NSArray alloc] 
      initWithObjects:@"EPSG:900913",@"image/png",@"GetMap",@"1.1.1",@"WMS",nil] autorelease] 
      forKeys:[[[NSArray alloc] initWithObjects:@"SRS",@"FORMAT",@"REQUEST",@"VERSION",@"SERVICE",nil] autorelease]];
 [wmsParameters addEntriesFromDictionary:params];

 // build WMS request URL template
 urlTemplate = [NSString stringWithString:baseUrl];
 NSEnumerator *e = [wmsParameters keyEnumerator];
 NSString *key;
 NSString *delimiter = @"";
 while (key = [e nextObject]) {
  urlTemplate = [urlTemplate stringByAppendingFormat:@"%@%@=%@",
        delimiter,
        [[key uppercaseString] stringByAddingPercentEscapesUsingEncoding:NSASCIIStringEncoding], 
        [[wmsParameters objectForKey:key] stringByAddingPercentEscapesUsingEncoding:NSASCIIStringEncoding]];
  delimiter = @"&";
 }
 int sideLength =  [self tileSideLength];
 urlTemplate = [[urlTemplate stringByAppendingFormat:@"&WIDTH=%d&HEIGHT=%d",sideLength,sideLength] retain];
 return self;
}


-(NSString*) tileURL: (RMTile) tile 
{ 
 //RMLatLongBounds tileBounds = [self TileLatLonBounds:tile];
 // Get BBOX coordinates in meters
 CGXYRect tileBounds = [self TileBounds:tile];
 
 NSString *url = [urlTemplate stringByAppendingFormat:@"&BBOX=%f,%f,%f,%f",
      tileBounds.ul.x,
      tileBounds.lr.y,
      tileBounds.lr.x,
      tileBounds.ul.y];
 NSLog(@"Tile %d,%d,%d yields %@",tile.zoom, tile.x, tile.y, url); 
 return url; 
} 

// implement in subclass?
-(NSString*) uniqueTilecacheKey
{
 return @"AbstractMercatorWMSSource";
}

-(NSString *)shortName
{
 return @"Generic WMS Source";
}
-(NSString *)longDescription
{
 return @"Generic WMS Source";
}
-(NSString *)shortAttribution
{
 return @"Generic WMS Source";
}
-(NSString *)longAttribution
{
 return @"Generic WMS Source";
}

-(float) minZoom
{
 return 9.0f;
}
-(float) maxZoom
{
 return 18.0f;
}

// Converts given lat/lon in WGS84 Datum to XY in Spherical Mercator EPSG:3857 
-(CGPoint) LatLonToMeters: (CLLocationCoordinate2D) latlon 
{ 
 CGPoint meters; 
 meters.x = latlon.longitude * originShift / 180.0; 
 meters.y = (log( tan((90.0 + latlon.latitude) * M_PI / 360.0 )) / (M_PI / 180.0)) * originShift / 180.0; 
 return meters; 
}

//Converts XY point from Spherical Mercator EPSG:3857 to lat/lon in WGS84 Datum 
-(CLLocationCoordinate2D) MetersToLatLon: (CGPoint) meters 
{ 
 CLLocationCoordinate2D latlon; 
 latlon.longitude = (meters.x / originShift) * 180.0; 
 latlon.latitude = (meters.y / originShift) * 180.0; 
 //latlon.latitude = - 180 / M_PI * (2 * atan( exp( latlon.latitude * M_PI / 180.0)) - M_PI / 2.0); 
 latlon.latitude = 180 / M_PI * (2 * atan( exp( latlon.latitude * M_PI / 180.0)) - M_PI / 2.0); 
 return latlon; 
} 

// Converts pixel coordinates in given zoom level of pyramid to EPSG:3857 
-(CGPoint) PixelsToMeters: (int) px PixelY:(int)py atZoom:(int)zoom 
{ 
 float resolution = [self ResolutionAtZoom: zoom]; 
 CGPoint meters; 
 meters.x = px * resolution - originShift; 
 meters.y = py * resolution - originShift; 
 return meters; 
} 

//Returns bounds of the given tile in EPSG:3857 coordinates 
-(CGXYRect)  TileBounds: (RMTile) tile 
{
 int sideLength =  [self tileSideLength];

 int zoom = tile.zoom;
 long twoToZoom = pow(2,zoom);
 CGXYRect tileBounds; 
 tileBounds.ul = [self PixelsToMeters: (tile.x * sideLength) 
          PixelY: ((twoToZoom-tile.y) * sideLength) 
          atZoom: zoom ]; 
 tileBounds.lr = [self PixelsToMeters: ((tile.x+1) * sideLength) 
          PixelY: ((twoToZoom-tile.y-1) * sideLength) 
          atZoom: zoom];
 return tileBounds; 
} 

//Resolution (meters/pixel) for given zoom level (measured at Equator) 
-(float) ResolutionAtZoom : (int) zoom 
{ 
 return initialResolution / pow(2,zoom); 
} 

@end

Sunday, December 12, 2010

Global log level control with cocoalumberjack

I started playing around with cocoalumberjack on the iPad to record user interactions and usage with the DDFileLogger.   The need to have a global log level became necessary otherwise I would have to assign ddLogLevel in every class.  

static const int ddLogLevel = LOG_LEVEL_VERBOSE;

The cocoalumberjack instructions mentions this feature, but doesn't supply details on the implementation.

I am not 100% sure if this is correct way but it works well enough.

Create a class and create a global ddLogLevel constant and set your required logging level.

Constant.h
extern int const ddLogLevel;

Constant.m
#import "Constants.h"
#import "DDLog.h"

int const ddLogLevel = LOG_LEVEL_VERBOSE;

Configure your logger.

#import "DDLog.h"
#import "DDASLLogger.h"
#import "DDTTYLogger.h"
#import "DDFileLogger.h"

...

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {    
 
 [DDLog addLogger:[DDASLLogger sharedInstance]];
 [DDLog addLogger:[DDTTYLogger sharedInstance]];
 
 DDFileLogger *fileLogger = [[DDFileLogger alloc] init]; 
 [DDLog addLogger:fileLogger];
 [fileLogger release];

...


To use this now simple import your class.

#import "DDLog.h"
#import "Constants.h"

...

- (void)someMethod {
 DDLogVerbose(@"Log this message");
}

Sunday, December 5, 2010

iOS : Replace a SQLite database at runtime using Core Data

I have been developing an application that required the SQLite database file to be replaced at runtime. This event was triggered when a new database file was downloaded from a remote server. I considered forcing the user to close the application, however this would be a poor user experience and just poor form.

One major issue was releasing the existing database correctly and then re-connecting.  The last hurdle was to clear any existing references to any managed objects currently being viewed or cached.

After searching the net and some experimenting I finally have something that is working.

- (BOOL) updateDB {
 
 if (managedObjectContext != nil) {
  [managedObjectContext lock];
  [managedObjectContext reset];
 }
 
 if (persistentStore != nil) {
  NSError *error;
  if (![persistentStoreCoordinator removePersistentStore:persistentStore error:&error]) {
      NSLog(@"Unable to remove persistent store error %@, %@", error, [error userInfo]);
      return FALSE;     
  }
 }
 if (managedObjectContext != nil) { 
     [managedObjectContext unlock];
 }
 if (persistentStore != nil) {
     [persistentStoreCoordinator release], persistentStoreCoordinator = nil;
 }
 if (managedObjectContext != nil) {
     [managedObjectContext release], managedObjectContext = nil;
 }
 if (managedObjectModel != nil) {
     [managedObjectModel release], managedObjectModel = nil;
 }
 
 [self replaceDB];
 
 [self managedObjectContext];
 
 [self clearDBReferences];
 
 return TRUE;
}

The managedObjectContext is initialised lazily, therefore is could be nil.

- (NSManagedObjectContext *) managedObjectContext {
    
    if (managedObjectContext != nil) {
        return managedObjectContext;
    }
  
    NSPersistentStoreCoordinator *coordinator = [self persistentStoreCoordinator];
    if (coordinator != nil) {
        managedObjectContext = [[NSManagedObjectContext alloc] init];
        [managedObjectContext setPersistentStoreCoordinator: coordinator];
    }
    return managedObjectContext;
}

The order of the messages are important to ensure the resources are released in the reverse order they were initialised.

The [self replaceDB] simple copies the new SQLite DB into place.

The [self managedObjectContext] simple forces the Lazily initialisation.

The last message races though all controllers requesting them to clear any references to managed objects.

I simple created a ReleaseResource protocol that has a clear method. If a controller implements this protocol then it can simple release any managed objects.

For example, I have a controller that subclasses UITableViewController.

-(void)clear {
    self.fetchedResultsController = nil;
    [self.tableView reloadData];
}

This code will obviously change depending on your application layout. This method races through the controllers looking for classes that implement the ReleaseResource protocol.

- (void) clearDBReferences {
    for (UIViewController *controller in tabBarController.viewControllers) {
  // for iPad
  if ([controller isKindOfClass:[UISplitViewController class]]) {
   NSLog(@"Clearing iPAD DB References");
   for (UINavigationController *navController in ((UISplitViewController *) controller).viewControllers) {
    [navController popToRootViewControllerAnimated:FALSE];
    for (UINavigationController *rootController in ((UINavigationController *) navController).viewControllers) {
     if ([rootController conformsToProtocol:@protocol(ReleaseResources)]) {
      [rootController performSelector:@selector(clear)];
     }
    }
   }
  }  
  // for iPhone
  if ([controller isKindOfClass:[UINavigationController class]]) {
   UINavigationController *navController = ((UINavigationController *) controller);
   NSLog(@"Clearing iPhone DB References");   
   [navController popToRootViewControllerAnimated:FALSE];
   for (UINavigationController *rootController in ((UINavigationController *) navController).viewControllers) {
    if ([rootController conformsToProtocol:@protocol(ReleaseResources)]) {
     [rootController performSelector:@selector(clear)];
    }
   }
  }
 }
}