For years I’ve wanted to have an in-app tutorial in my PrayerMate app that would use what are known in the UX business as “coachmarks” – a kind of guided tour of the basic functionality of the app whenever it is first installed. Prompted by a colleague, I finally decided to give it a go recently, and settled on using Darin Doria’s DDCoachMarks library for iOS (I also had to implement this on Android using a different library). Here’s the results:
I had to make a few little tweaks to the library so that it would handle long captions in its bubbles and so on. That left two main challenges: handling rotations, and handling scrolling.
Handling rotations
If a user rotates their device half way through a tutorial, DDCoachmarks kind of freaks out. This is understandable, since we’ve defined all of our bubbles in screen co-ordinates, and those co-ordinates completely change when a device rotates. I solved it by making my DDCoachMarksViewDelegate monitor for device orientation change events. Just before my [coachMarksView start] call I added this:
// Start listening for rotation events
[[UIDevice currentDevice] beginGeneratingDeviceOrientationNotifications];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(deviceOrientationDidChange:) name:UIDeviceOrientationDidChangeNotification object:nil];
[coachMarksView start];
Then I handle those events like so:
- (void)deviceOrientationDidChange:(NSNotification *)notification {
//Obtaining the current device orientation
UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation];
//Ignoring specific orientations
if (orientation == UIDeviceOrientationFaceUp || orientation == UIDeviceOrientationFaceDown || orientation == UIDeviceOrientationUnknown) {
return;
}
// We need to allow a slight pause before running handler to make sure rotation has been processed by the view hierarchy
[self performSelectorOnMainThread:@selector(handleDeviceOrientationChange:) withObject:self.currentCoachMarksView waitUntilDone:NO];
}
- (void)handleDeviceOrientationChange:(DDCoachMarksView*)coachMarksView {
// Begin the whole coach marks process again from the beginning, rebuilding the coachmarks with updated co-ordinates
coachMarksView.coachMarks = [self coachMarksAfterRotation:coachMarksView];
[coachMarksView start];
}
Of course you also then need to stop listening for rotation events after the coachmarks are finished:
- (void)coachMarksViewWillCleanup:(DDCoachMarksView *)coachMarksView {
// Stop listening for orientation changes
[[UIDevice currentDevice] endGeneratingDeviceOrientationNotifications];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIDeviceOrientationDidChangeNotification object:nil];
}
Handling scrolling
The bigger headache was that some of my coach marks were related to UI elements that were off the bottom of the screen, requiring me to scroll to bring them into view.
To handle this, I have a delegate callback each time a new coachmark is shown:
- (void)coachMarksView:(DDCoachMarksView*)coachMarksView willNavigateToIndex:(NSUInteger)index {
// This is app-specific code to look up the UIScrollView that's currently being looked at
UIScrollView* scrollView = [self.rootViewController currentScrollView];
if (scrollView == nil) {
// No scrolling possible
return;
}
// Look up the details of the coach mark that's about to be displayed
NSDictionary* coachMark = [coachMarksView.coachMarks objectAtIndex:index];
// I added some extra info to my coachmarks dictionary to indicate which coachmarks might require scrolling
if ([coachMark objectForKey:@"scrollView"] == nil) {
// No scrolling required - so always scroll back up to the top
[scrollView setContentOffset:CGPointZero animated:YES];
return;
}
CGRect markRect = [[coachMark objectForKey:@"rect"] CGRectValue];
// Modify coachmarks according to scroll offset
CGRect modifiedRect = CGRectMake(markRect.origin.x, markRect.origin.y - scrollView.contentOffset.y, markRect.size.width, markRect.size.height);
// See if the coachmark is offscreen or not
if (!CGRectContainsRect([self visibleRectForCoachMarks:coachMarksView], modifiedRect)) {
if (scrollView != nil) {
// Scroll until it's in view //
///////////////////////////////
// Convert from screen co-ordinates into the co-ordinate system of the UIScrollView
CGRect markInScrollView = [self.rootViewController.view convertRect:markRect fromView:self.rootViewController.navigationController.view];
markInScrollView = [scrollView convertRect:markInScrollView fromView:self.rootViewController.view];
// Record the current scroll position
CGPoint originalOffset = scrollView.contentOffset;
// Then scroll without animation to work out how far we need to scroll
[scrollView scrollRectToVisible:markInScrollView animated:NO];
// Modify coachmarks rectangle by scrollView offset
markRect.origin.y -= scrollView.contentOffset.y;
markRect.origin.x -= scrollView.contentOffset.x;
// Update the coachmarks array
NSMutableArray *newCoachmarks = [coachMarksView.coachMarks mutableCopy];
NSMutableDictionary* mutable = [coachMark mutableCopy];
[mutable setObject:[NSValue valueWithCGRect:markRect] forKey:@"rect"];
[newCoachmarks setObject:mutable atIndexedSubscript:index];
coachMarksView.coachMarks = newCoachmarks;
// Now put the UIScrollView back where it started and ANIMATE it into position - this is just so that it looks nicer
scrollView.contentOffset = originalOffset;
[scrollView scrollRectToVisible:markInScrollView animated:YES];
}
}
}
Conclusion
And there we have it! Beautiful coachmarks and a user who knows exactly how your app works as though you were right there next to them explaining it all.
Many thanks to Darin Doria for his hard work on this handy little iOS library.