Friday, April 16, 2010
My love/hate relationship with the iPhone SDK
My app iTravel goes out and gets Wikitravel pages, and then gets subpages in the background. (eg if you ask for "New York City", it'll also go out and get "Manhattan", "Upper West Side", etc.) It also plots listings (sights, restaurants, etc.) for all these pages on a map. I wanted the background thread that was loading this data to automatically a new page's listings to the map, if the viewer was using one; so they can go to "New York City", go to the map, and then watch Manhattan slowly get barnacled by map annotations, one neighbourhood at a time.
I thought this was going to be difficult. I couldn't have been more wrong. Here's the background-thread code, in its entirety:
-(void) refreshMapIfActive {
UIApplication *app = [UIApplication sharedApplication];
iTravelRightAppDelegate *appDelegate = app.delegate;
UINavigationController *controller = [appDelegate navigationController];
NSArray *viewControllers = [controller viewControllers];
UIViewController *currentController = [viewControllers lastObject];
if ([currentController class] == [MapViewController class])
[currentController performSelectorOnMainThread:@selector(showAnnotations:) withObject:nil waitUntilDone:NO];
}
I'd like to do this in Android, too...but a) their map implementation works a lot slower, b) I don't think there even is a method to get the currently active Activity.
Now, the "showAnnotations:" method within MapViewController is obviously trickier. For one thing, it's synchronized, lest the user try to filter a map just when a background thread is adding listings - not really a UI issue, since this usually takes all of 1-2 seconds:
-(void)doShowAnnotations:(NSArray*)annotationsToShow {
//autorelease pool
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
@synchronized (self) {
@try {
NSArray *wikiPageMarkers = [WikiPage getPageMarkersWithin:mapView.region];
BOOL added=NO;
double pageLevel = [Settings getMapPageLevel];
BOOL abovePageLevel = mapView.region.span.longitudeDelta > pageLevel || mapView.region.span.latitudeDelta > pageLevel;
if (abovePageLevel && [mapView.annotations count] > [wikiPageMarkers count]) { //clear away low-level annotations
[mapView performSelectorOnMainThread:@selector(removeAnnotations:) withObject:[annotations allObjects] waitUntilDone:YES];
[annotations removeAllObjects];
}
else if (!abovePageLevel)
{
UITabBarItem *selected = [self.tabBar selectedItem];
if (selected!=nil) {
//first, remove all annotations that don't fit the selection
NSMutableArray *toRemove = [NSMutableArray arrayWithCapacity:[mapView.annotations count]];
for (NSObject *annotation in mapView.annotations) {
if ([annotation class] == [Listing class]) {
Listing *listing = (Listing*) annotation;
if (!(selected.tag==MY && [listing isInMyListings] || selected.tag==[listing.category intValue]))
[toRemove addObject:listing];
}
}
[mapView performSelectorOnMainThread:@selector(removeAnnotations:) withObject:toRemove waitUntilDone:YES];
}
if (annotationsToShow==nil) { //if we don't have a specific request, get all the listings within the map's region
NSNumber *category = selected==nil ? nil : [NSNumber numberWithInt:selected.tag];
annotationsToShow = [ListingManager getListingsWithin:mapView.region forCategory:category];
}
//the following is incredibly messy because NSSet and NSMutableSet are unusable for our purposes.
What follows is one of the reasons I hate the SDK. Basically, I want to do some fairly basic set arithmetic to ensure that we remove undesired annotations from the map (but keep them in our local "annotations" set in case we need to add them again) and add new ones that are desired. Because the "-unionSet:" etc. methods on NSMutableSet don't work at all like you'd expect, though, I basically have to do that by hand. I'll skip over that messy part to the good stuff:
NSMutableArray *thereNotRequested = [NSMutableArray arrayWithArray:mapView.annotations];
for (annotation in mapView.annotations) {
if ([requested objectForKey:[annotation title]] != nil)
[thereNotRequested removeObject:annotation];
}
[mapView performSelectorOnMainThread:@selector(removeAnnotations:) withObject:thereNotRequested waitUntilDone:YES];
if ([arrayToAdd count]>0) {
[mapView performSelectorOnMainThread:@selector(addAnnotations:) withObject:arrayToAdd waitUntilDone:YES];
added=YES;
}
if (!added)
[self performSelectorOnMainThread:@selector(activityDone) withObject:nil waitUntilDone:NO];
}
@catch(NSException *exception) {
[Util doLog:[NSString stringWithFormat:@"Warning: adding annotations to map view failed: %@", [exception reason]]];
[self performSelectorOnMainThread:@selector(activityDone) withObject:nil waitUntilDone:NO];
}
@finally {
[pool release];
}
}
Labels: currentController, iPhone, maps, MapView, NSMutableSet, Objective-C, performSelector:, selector, selectors, sets, synchronization, threading, UINavigationController
Monday, March 1, 2010
Responding to zooms and pans in Android's MapView
There is, however, a solution. Not a pretty one, but it works. The solution is to use your own subclass of MapView:
public class ITRMapView extends MapView {
public ITRMapView(android.content.Context context, android.util.AttributeSet attrs) {
super(context, attrs);
}
public ITRMapView(android.content.Context context, android.util.AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public ITRMapView(android.content.Context context, java.lang.String apiKey) {
super(context, apiKey);
}
}
and within that subclass, override onTouchEvent. You're probably only interested in the UP action:
public boolean onTouchEvent(MotionEvent ev) {
if (ev.getAction()==MotionEvent.ACTION_UP) {
//do your thing
}
return super.onTouchEvent(ev);
}
But what if you want to detect zoom actions as well as touches? Simple, thought I: just override onDraw() and do the same thing. But you can't - MapView's onDraw() is final!
Catastrophe? Not quite. It turns out that overriding dispatchDraw() works just fine:
int oldZoomLevel=-1;
public void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (getZoomLevel() != oldZoomLevel) {
//do your thing
oldZoomLevel = getZoomLevel();
}
}
Et voila - you can programmatically detect and respond when the user pans and/or zooms your map. Really not so hard after all.
Labels: Android, dispatchDraw, MapView, MotionEvent, onDraw, onTouchEvent, onTouchListener, pan, touch, touches, zoom
Tuesday, September 1, 2009
iPhone bits and bobs
- MapView code
A fully functional MapViewDelegate implementation for you. All you have to do to start putting items on maps is make them implement MKAnnotation, which is super-easy.
- (MKAnnotationView *)mapView:(MKMapView *)myMapView viewForAnnotation:(id)annotation {
//this is clumsy, but there's no obvious better way - otherwise it crashes when showing user location
if (![annotation isKindOfClass:[ECEntry class]])
return nil;
ECEntry *myEntry = (ECEntry*)annotation;
MKPinAnnotationView *myView = (MKPinAnnotationView*)[myMapView dequeueReusableAnnotationViewWithIdentifier:@"Local"];
if (myView==nil)
myView = [[[MKPinAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:@"Local"] autorelease];
myView.pinColor = myEntry.groupID==0 ? MKPinAnnotationColorGreen : MKPinAnnotationColorRed;
myView.canShowCallout=YES;
myView.rightCalloutAccessoryView = [UIButton buttonWithType:UIButtonTypeDetailDisclosure];
return myView;
}
- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view calloutAccessoryControlTapped:(UIControl *)control {
ECEntryViewController *entryViewController = [[ECEntryViewController alloc] initWithNibName:nil bundle:nil];
entryViewController.myEntry = (ECEntry*)[view annotation];
[self.navigationController pushViewController:entryViewController animated:YES];
[entryViewController release];
} - Using the camera via UIImagePickerController
So this is amazingly easy, and I can even provide you with some code that
a) launches a camera, phot-album selector, or photo-library selector, depending on what's available on your device:
b) presents the image picker as a modal view controller, keeping it within your existing NavigationController tree:
c) gets the selected/captured image (via UIImagePickerControllerDelegate) and saves it to a file.
-(void) doCamera {
BOOL hasCamera = [UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera];
BOOL hasAlbum = [UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeSavedPhotosAlbum];
BOOL hasLibrary = [UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypePhotoLibrary];
if (hasCamera || hasAlbum || hasLibrary) {
UIImagePickerController *photoController = [[UIImagePickerController alloc] init];
photoController.delegate = self;
if (hasCamera)
photoController.sourceType = UIImagePickerControllerSourceTypeCamera;
else if (hasAlbum) {
photoController.sourceType = UIImagePickerControllerSourceTypeSavedPhotosAlbum;
NSString *warningMessage = [NSString localizedStringWithFormat:@"Your device has no camera, but does have an album of saved photos to choose from"];
[Util doAlert:NSLocalizedString(@"Warning",@"Alert title") withMessage:warningMessage];
}
else if (hasLibrary) {
photoController.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
NSString *warningMessage = [NSString localizedStringWithFormat:@"Your device has no camera, but does have a photo library to choose from"];
[Util doAlert:NSLocalizedString(@"Warning",@"Alert title") withMessage:warningMessage];
}
[self doSave];
[self presentModalViewController:photoController animated:YES];
[photoController release];
}
else {
NSString *alertMessage = [NSString localizedStringWithFormat:@"Your device does not support photos"];
[Util doAlert:NSLocalizedString(@"No photo support",@"Alert title") withMessage:alertMessage];
}
}
- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {
UIImage *image = [info objectForKey:UIImagePickerControllerEditedImage];
if (image == nil)
image = [info objectForKey:UIImagePickerControllerOriginalImage];
//TODO, if possible: if the image has already been saved, just use its path, don't save it again
if (image!=nil) {
//convert image to NSData - use Settings for PNG/JPG
[Util showActivity];
NSData *imageData=nil;
if ([Settings saveImagesAsPNG])
imageData = [NSData dataWithData:UIImagePNGRepresentation(image)];
else
imageData = [NSData dataWithData:UIImageJPEGRepresentation(image, [Settings getJPEGQuality])];
//save NSData to file
NSString *pathDir = [Util getApplicationDocumentsPath];
NSString *fileName = [[NSProcessInfo processInfo] globallyUniqueString];
fileName = [fileName stringByAppendingString:([Settings saveImagesAsPNG] ? @".png" : @".jpg")];
NSString *fullPath = [pathDir stringByAppendingPathComponent:fileName];
NSError *error=nil;
[imageData writeToFile:fullPath options:NSAtomicWrite error:&error];
if (error) {
NSString *errorMessage = [NSString localizedStringWithFormat:@"Unable to write to file %@: %@ - %@", fullPath, error, [error userInfo]];
[Util doAlert:NSLocalizedString(@"Error",@"Alert title") withMessage:errorMessage];
}
[Util stopShowingActivity];
//set entry
myEntry.photoPath=fileName;
//all done
[self dismissModalViewControllerAnimated:YES];
[self.tableView reloadData];
}
}
- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
[self dismissModalViewControllerAnimated:YES];
[self.tableView reloadData];
} - Unit test woes
I've been having loads of trouble with the allegedly-baked-in unit-testing framework described here. Specifically, I get masses of "symbol(s) not found" linker errors when I try to compile for the Testing target, re all kinds of basic UIKit and CoreData stuff. I presume it's some kind of importing-the-wrong-kind-of-framework problem, but I have no idea how to correct it, and I don't want to dive into the seething nest of rattlesnakes that is the innards of XCode's build process. Should I just switch to the Google unit-test framework for iPhone?
Labels: ImagePicker, iPhone, MapView, MKAnnotation, MKMapView, presentModalViewController, SenTestingKit, UIImagePIckerController, UIImagePickerControllerDelegate
Subscribe to Posts [Atom]