BuildMobile: Building a GPS Enabled iPad Search App

Part of the appeal of mobile services is that they are relevant to where you are right now. Most phones support GPS and a connection to the network. And when you combine those you get a convenient location based service. In this example application we are going to create a backend service in PHP that an iPad application on the front end will connect to. This service will have a list of stores in the local area in a MySQL database. You are welcome to use this example code, both on the back end and on the front end, as a starter kit for your own geo-location based service. Pick up all of the code for StoreFinder on BuildMobile’s GitHub.

Creating The Backend

Building the backend starts with creating a MySQL database that has the essential location data in it. You can see the initialization script in Listing 1.

Listing 1. db.sql

DROP TABLE IF EXISTS locations;
CREATE TABLE locations(
    lon FLOAT,
    lat FLOAT,
    name VARCHAR(255) );
INSERT INTO locations VALUES ( -122.035706, 37.332802, 'Starbucks' );
INSERT INTO locations VALUES ( -122.036133, 37.331711, 'Peets' );
INSERT INTO locations VALUES ( -122.033173, 37.336182, 'Chipotle' );
...
INSERT INTO locations VALUES ( -122.035919, 37.324341, 'Buy More' );
INSERT INTO locations VALUES ( -122.031410, 37.333176, 'Costco' );

The first thing the script does is create the locations table that includes the latitude, longitude and name of each of the stores. It then inserts a bunch of test records in that are located roughly around Apple’s Cupertino campus. You will probably have to add some records near you as you test this code in order to get any results.

To put this into production we first create the database using mysqladmin then use the mysql command to run the script, like so:

$ mysqladmin --user=root create geo
$ mysql –user=root geo  db.sql

Depending on your MySQL installation you will have to change the username as well as add a password if one is required.

With the database ready it’s time to create the PHP script that will do the searching and return results as XML. This starts with finding the bounding box of minimum and maximum latitude and longitude as shown in Listing 2.

Listing 2. circle.php Figuring Out the Bounding Box

?php
define( 'LATMILES', 1 / 69 );
define( 'LONMILES', 1 / 53 );
$lat = 37.3328;
$lon = -122.036;
$radius = 1.0;
if ( isset( $_GET['lat'] ) ) { $lat = (float)$_GET['lat']; }
if ( isset( $_GET['lon'] ) ) { $lon = (float)$_GET['lon']; }
if ( isset( $_GET['radius'] ) ) { $radius = (float)$_GET['radius']; }
$minlat = $lat - ( $radius * LONMILES );
$minlon = $lon - ( $radius * LATMILES );
$maxlat = $lat + ( $radius * LONMILES );
$maxlon = $lon + ( $radius * LATMILES );

Every point of latitude is 69 miles and every point of longitude is 53 miles. These are stored as the LATMILES and LONMILES constants at the top of the script. The script uses these to create the minlat, maxlat, minlon and maxlon variables which represent a bounding box for the query. The measurements of the box are determined by the radius values that’s passed in. For convenience sake these values are all defaulted to reasonable values.

The next step is to create the query that will find the location records within the bounding box. The code for this is shown in Listing 3.

Listing 3. circle.php Creating the Query

$dbh = new PDO('mysql:host=localhost;dbname=geo', 'root', '');
$sql = 'SELECT lat, lon, name FROM locations WHERE lat = ? AND lat = ? AND lon = ? AND lon = ?';
$params = array( $minlat, $maxlat, $minlon, $maxlon );
if ( isset( $_GET['q'] ) ) {
  $sql .= " AND name LIKE ?";
  $params []= '%'.$_GET['q'].'%';
}
$q = $dbh-prepare( $sql );
$q-execute( $params );

This code uses the PDO library to connect to the database. It then formats and executes a SQL statement that constrains the records returned to only those within the bounding box that was created in Listing 2. Optionally if a ‘q’ parameter is specified only records where the name contains the value specified will be returned. For example, a user can specify that he only wants to see stores that have the name ‘bucks’ in the title and he would only get Starbucks stores in the vicinity.

The final step for this script is to format the results as XML. This is shown in Listing 4.

Listing 4. circle.php Formatting the Results

$doc = new DOMDocument();
$r = $doc-createElement( "locations" );
$doc-appendChild( $r );
foreach ( $q-fetchAll() as $row) {
  $dlat = ( (float)$row['lat'] - $lat ) / LATMILES;
  $dlon = ( (float)$row['lon'] - $lon ) / LONMILES;
  $d = sqrt( ( $dlat * $dlat ) + ( $dlon * $dlon ) );
  if ( $d = $radius ) {
    $e = $doc-createElement( "location" );
    $e-setAttribute( 'lat', $row['lat'] );
    $e-setAttribute( 'lon', $row['lon'] );
    $e-setAttribute( 'name', $row['name'] );
    $e-setAttribute( 'd', $d );
    $r-appendChild( $e );
  }
}
print $doc-saveXML();
?

To make life easy we use the XML DOM library to create an XML document in memory then simply write it out using the saveXML method. This ensures that the XML will properly format special characters like less than, greater than, ampersand, quotes and so on. It’s also a lot easier to read than XML code written by hand.

This code also applies a circle filter to the data. Meaning that we further constrict the rows returned from the original box into a circle. It’s not clear that a user would notice the refinement, but it does mean that if the user specifies 5 miles from there location all of the results will indeed be in a five mile circle around their current position.

With the database built and the script ready to go it’s time to test it.

Testing The Backend

You could look at the result in a browser by simply navigating to the page and refining the search parameters as you see fit. But as you can see in Listing 5 sometimes it’s easier just to use CURL.

Listing 5. Running the Script Using CURL

$ curl "http://localhost/circle.php?radius=1"
?xml version="1.0"?
locations
location lat="37.3328" lon="-122.036" name="Starbucks" d="0"/
location lat="37.3317" lon="-122.036" name="Peets" d="0.075900000000068"/
...
/locations
$

CURL is a command line utility that fetches the specified URL and prints out the result to STDOUT. From the URL you can see that I haven’t specified a latitude and longitude. This means that the search will be centered at Apple’s Cupertino headquarters where most of the example data is centered. All I have done is specified that the stores returned must be within the radius of one mile, which refines the results a little bit.

The database is built, the PHP script to run the query on the server side is complete and tested. The next step is to put a simple iPad front end on it.

Building The iPad App

This iPad application will be a ‘View-based’ application. Meaning that we start off with a blank canvas to which we add our controls. In this application we only need two controls, a UISearchBar that will have the search query term (for example ‘bucks’ to find all of the Starbucks) and the UITableView which will display all of the returned records. You can see this defined in Listing 6.

Listing 6. StoreFinderViewController.h

#import UIKit/UIKit.h
#import CoreLocation/CoreLocation.h
@interface StoreFinderViewController : UIViewControllerUITableViewDataSource,
        CLLocationManagerDelegate,
        NSXMLParserDelegate,
        UISearchBarDelegate {
    IBOutlet UISearchBar *searchBar;
    IBOutlet UITableView *tableView;
    CLLocationManager *locationManager;
    NSMutableArray *locations;
    float latitude;
    float longitude;
}
@property (nonatomic, retain) CLLocationManager *locationManager;
@end

Not only do we have the searchBar and tableView pointers, but we also have a locationManager, which is used to get the GPS data. The locations array which stores all of the locations we have found. And the latitude and longitude values which store the most recent latitude and longitude.

Once we have the control pointers defined in the code we need to add them to the display definition file. Adding the UI controls to the display starts with opening up the StoreFinderViewController.XIB file. From there select the ‘File Inspect’ from the View Utilities menu. At the bottom right will be a panel of objects that you can drag onto the view controller area. Drag a UISearchBar first and locate it at the top of the window. Then grab a UITableView and drop it onto the display. It should fill the entire contents, if it doesn’t then just shift it around until it does.

Next we need to connect these controls to the ViewController. First right click on the UISearchBar item in the Objects panel on the left hand side of the display. This will bring up a popup menu where there is an item labels ‘New Referencing Outlet’. Click on the little bubble on the right and drag the line to the ‘File’s Owner’ item and release it. This will bring up another menu of options. Attach it to the ‘searchBar’ item.

Now do the same thing for the UITableView to connect it to tableView in the view controller. And finally right click on the UITableView again and this time drag the dataSource bubble to the File's Owner and release.

This is all you have to do to get the UI connected. From here on out it’s all in the code.

In order to include a locationManager you need to add the Core Location framework to the project. To do that select the ‘StoreFinder’ project and then the ‘StoreFinder’ target, which has the little paint brush and ruler icon. From there select the ‘Build Phases’ tab, and spin open the ‘Link Binary With Libraries’ section. That will show you the libraries you currently have included. Click on the plus button and select the Core Location framework to add it to your application.

The class also defines a lot of delegates. The UITableViewDataSource means that the object will be used to drive the data for the table view. The CLLocationManagerDelegate means that the object will respond to GPS updates. The NSXMLParserDelegate is used for parsing the XML response from the server. And finally the UISearchBarDelegate means that the object will handle events from the search bar. Notably when the user clicks on the search button or cancels the search.

With the class definition all squared away it’s time to get into building the view controller class itself. That starts with the initialization code shown in Listing 7.

Listing 7. StoreFinderViewController.m Initialization

#import "StoreFinderViewController.h"
@implementation StoreFinderViewController
- (void)dealloc
{
    [super dealloc];
    [self.locationManager release];
    if ( locations )
        [locations release];
}
- (void)viewDidLoad
{
    [super viewDidLoad];
    locations = NULL;
    self.locationManager = [[[CLLocationManager alloc] init] autorelease];
    self.locationManager.delegate = self;
    [self.locationManager startUpdatingLocation];
}
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return YES;
}

The only really interesting thing here is the creation of the locationManager object and the request to start getting data from the GPS. In Listing 8 we have the code that handles the callbacks from the UITableView for data.

Listing 8. StoreFinderViewController.m Table Handling

- (NSInteger)tableView:(UITableView *)table numberOfRowsInSection:(NSInteger)section {
    if ( locations != NULL ) {
        return [locations count];
    }
    return 0;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"MyIdentifier"];
    if (cell == nil)
    {
        cell = [[[UITableViewCell alloc] initWithFrame:CGRectZero reuseIdentifier:@"MyIdentifier"] autorelease];
    }
    NSDictionary *itemAtIndex = (NSDictionary *)[locations objectAtIndex:indexPath.row];
    UILabel *newCellLabel = [cell textLabel];
    [newCellLabel setText:[itemAtIndex objectForKey:@"name"]];
    return cell;
}

If you haven’t see the UITableViewDataSource stuff before it can be pretty interesting. What’s happening is that the control calls back only for enough data as it needs to render what’s visible to the user. The first call is to numberOfRowsInSection which gets the size of the table. Then the cellForRowAtIndexPath returns a cell object for the given index. In this case the code uses the locations array to set a label with the name of the store that was found at the given row index.

Of course a tabular display of data is no good without some data to drive it. The code in Listing 9 is what makes the actual request to the server and parses the returned data.

Listing 9. StoreFinderViewController.m XML Request and Parsing

- (void)updateLocation:(CLLocation *)newLocation {
    if ( locations ) {
        [locations release];
    }
    locations = [[NSMutableArray alloc] init];
    if ( newLocation ) {
        latitude = newLocation.coordinate.latitude;
        longitude = newLocation.coordinate.longitude;
    }
    NSString *urlString = [NSString stringWithFormat:@"http://localhost/geo2/circle.php?lat=%glon=%gradius=100q=%@",
        latitude, longitude, searchBar.text ? searchBar.text : @""];
    NSXMLParser *locationParser = [[[NSXMLParser alloc] initWithContentsOfURL:[NSURL URLWithString:urlString]] autorelease];
    [locationParser setDelegate:self];
    [locationParser parse];
    [tableView reloadData];
}
- (void)parser:(NSXMLParser *)parser didStartElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName attributes:(NSDictionary *)attributeDict {
    if ( [elementName isEqualToString:@"location"]) {
        [locations addObject:[[NSDictionary alloc] initWithDictionary:attributeDict]];
    }
}

It starts with the updateLocation method which is either called when the GPS changes, or when the user makes a search request. The updateLocation method creates an NSXMLParser and then points it at the URL of the server. In this case the server is on localhost, but you can put it anywhere you want. The XML parser requests the data, gets back the results and starts parsing it.

The parsing of the data fires off callback methods, in this case didStartElement, which indicates that a tag has been started. That’s good enough for this code because the location tag contains all of the data we need to know in the attributes which are passed in from the XML parser through the attributes parameter. The didStartElement simply looks at the tag to see if it’s name is ‘location’ and if so it copies the attribute into a new object within the locations array.

At the end of the process the reloadData method is called which refreshes the UITableView control.

The next thing to do is handle the GPS callbacks which are shown in Listing 10.

Listing 10. StoreFinderViewController.m GPS Handling

- (void)locationManager:(CLLocationManager *)manager
    didUpdateToLocation:(CLLocation *)newLocation
           fromLocation:(CLLocation *)oldLocation
{
    [self updateLocation:newLocation];
}
- (void)locationManager:(CLLocationManager *)manager
       didFailWithError:(NSError *)error
{
}

All we do here is call the updateLocation method with the new location when it changes.

And the final small code piece handles the search bar as shown in Listing 11.

Listing 11. StoreFinderViewController.m Search Bar Handling

- (void)searchBarSearchButtonClicked:(UISearchBar *)sb {
    [self updateLocation:NULL];
    [searchBar resignFirstResponder];
}
- (void)searchBarCancelButtonClicked:(UISearchBar *)sb
{
    [searchBar resignFirstResponder];
}
@end

These two methods handle either searching or cancelling from the search bar. When the user selects search we simply run the query again and update the table view using the updateLocation method, then get rid of the keyboard using the resignFirstResponder call. The cancel method just gets rid of the keyboard.

With the front end and back end written it’s time to try this puppy out.

Trying The App Out

The first thing that happens when you launch the app is that you are prompted to allow the application to use your current location as shown in Figure 1.

Figure1

Figure1

If you want the code to work click the OK button. If you want to save yourself some headaches later on you should also check the “Don’t ask me again” checkbox.

With the location check out of the way the application queries the PHP service on the back end and parses the returned XML into the locations array. This array is then displayed in the UITableView as you can see in Figure 2.

Figure2

Figure2

Alright, now we are really getting somewhere. So let’s make sure that we can not only get the stores that are around us, but which match our search criteria. We do this by clicking in the search bar which brings up the keyboard as shown in Figure 3.

Figure3

Figure3

With the keyboard up we can now enter a search term like ‘ice’ as shown in Figure 4.

Figure4

Figure4

We then click the search button and the results are refined to just the Ice Skating rink shown in Figure 5.

Figure5

Figure5

In addition the keyboard has been automatically dismissed opening up the entirety of the screen to show the copious results.

Conclusions

Building this GPS application was remarkably easy on both the front and back end sides. The back end amounts to a very simple database with a rudimentary bounded query. And the iPad application on the front end gets the GPS data and makes a very simple HTTP request to get the data from the back end to show. This is just a starting point though, from here you can build out the back end to add more data or more search support. On the front end you can add mapping or additional data display.