BuySellAds.com

Our DNA is written in Objective-C
Jump

How to make a Pull-To-Reload TableView just like Tweetie 2

When I started on Twitter, I tried out a few Twitter clients both on Mac and iPhone until I quickly settled on Tweetie. When Loren Brichter made the bold move to sell Tweetie 2 as a seperate app I also purchased it because I am convinced this guy means quality and Tweetie 2 is on the first page of my springboard.

One thing that’s cool about Tweetie 2 is the fresh paradigm to refreshing the contents of a table view. Up until now we had been looking for space to mount a reload button on, sometimes having to resort to adding an extra tool bar for just one view so that you can have enough space. Now if you have a tableview that it sorted reverse chronologically, then you have a natural urge to make new items appear at the top by pulling down the table with extra force.

Loren recognized this need and innovated the Pull-To-Reload paradigm. If you want to refresh a tableview in Tweetie 2 then you simply pull down the table far enough for an additional cell to appear at the top with the instruction “Pull down to refresh”. If you do, then at a certain point the arrow rotates and the text changes to “Release to refresh”. All accompanied by two distinct wooshing sounds and a pop once the reloading action has ceased. The Intuitiveness of this paradigm is so compelling in fact that people who use Tweetie 2 start to try to refresh ALL tableviews like this.

Might be a good case to make this the standard way from now on because it feels more logical and natural than to tap on a small button with a circular arrow on it. A user of MyAppSales requested that I add this mechanism for reloading reviews of individual apps. At first I thought this to be advanced magic, probably using forbidden techniques. But after a bit of research and lots of hints coming from my Twitter friends (thanks Thomas and Fabian) I figured it out. This article explains how I did it.

At first I experimented a bit myself and found that if you add a subview to a tableview then this moves together with it. But I could not find any way to make the contents of this extra view change depending on where it was on the screen. So I asked for help and help I got. The deciding hint was to have a look at Devin Doty’s (enormego) implementation of this.

So thank you to Devin for laying the groundwork. The first bit of knowledge that was necessary was to understand that UITableView inherits from UIScrollView and thus also receives all the scroll view delegate messages. The 3 magic ingredients are scrollViewWillBeginDragging, scrollViewDidScroll and scrollViewDidEndDragging. Once you know that these are called you cannot but marvel at the ingeniousness. The checkForRefresh BOOL keeps track if dragging has started so that in all other cases scrolling can be ignored. And the reloading BOOL is YES if the reloading animation is being shown.

The second piece of the puzzle is how to make the refresh view stay visible during reloading. This is achieved by setting the edge insets of the table to a negative value. And when the reload is done to set them back to zero. All in animation blocks so that it does not jump but implicitly animates to the new state.

Devin’s implementation consists of two classes. EGORefreshTableHeaderView is added as a subview of EGOTableViewPullRefresh. The latter subclasses UITableView and has to have the delegate pointing to itself so that it can receive the scrolling events.

This is bad form in my humble opinion. When I tried to simply copy/paste Devin’s code into MyAppSales I found that I had a problem due to this delegate bending. In order to have custom heights of my cells on the review tableview I needed to implement tableView:heightForRowAtIndexPath. This is part of the delegate protocol, but with Devin’s approach my view controller is never called to get this height. The same is true for all other delegate methods. The approach I have seen other people take to work around this problem is to override and forward all delegate methods to a custom delegate. So you get lots of unreadable code and a general feeling of yuck yuck.

Furthermore the EGOTableViewPullRefresh class saves the last updated date in the user preferences and generally does too much interact with data for the Model-View-Controller way of coding. Interaction with data (M) is supposed to be handled by a table view controller (C) and not the table view itself (V). So that had to go, and while I was at it, I changed the date formatter to use the system locale instead of hard coding the format.

So I did it the “proper way” by NOT subclassing UITableView, but UITableViewController instead. By creating your own PullToRefreshTableViewController you no longer have to resort to trickery in forwarding delegate method calls. In fact the only thing you need to do to change one table view into one supporting this reloading is to change the class from UITableViewController to PullToRefreshTableViewController. This simplifies the whole affair tremendously.

Another problem I found when playing around with Devin’s code was that I managed to get into a strange state where during reload the arrow would show and after it finished the activity indicator became visible. The reason is that the method to toggle between states assumes that only flip-flop-flip is possible. So I added a parameter to force it into the appropriate state even if you toss the tableview around.

- (void)toggleActivityView:(BOOL)isON
{
	if (!isON)
	{
		[activityView stopAnimating];
		arrowImage.hidden = NO;
	}
	else
	{
		[activityView startAnimating];
		arrowImage.hidden = YES;
		[self setStatus:kLoadingStatus];
	}
}

I also added a line of code to ignore scrolling events while reloading is taking place to additionally prevent getting into an inconsistent state:

if (reloading) return;

Devin’s project comes with a look that is almost identical to Tweetie 2, even though I feel that the arrow is a bit too large. But there are 3 colors of arrows to choose from. The final touch is to find 3 wav files to use. Here I initially wrote about borrowing sounds from Tweetie 2 which caused a major outcry of people. So please don’t. Why not just make your own sounds to underline your app’s uniqueness? Or simply forget about the sounds, Apple recommends you either have sound effects throughout your app or not at all.

Now enough talk, let me show you my code. Please forgive me for inserting so many extra line breaks so that the code will fit into the code boxes. If you don’t want to copy/paste it, then just grab the files from the MyAppSales trunk. EGORefreshTableHeaderView needed only minor modifications:

EGORefreshTableHeaderView.h

//
//  EGORefreshTableHeaderView.h
//  Demo
//
//  Created by Devin Doty on 10/14/09October14.
//  Copyright 2009 enormego. All rights reserved.
//
 
#import <UIKit/UIKit.h&>
 
@interface EGORefreshTableHeaderView : UIView {
 
	UILabel *lastUpdatedLabel;
	UILabel *statusLabel;
	UIImageView *arrowImage;
	UIActivityIndicatorView *activityView;
 
	BOOL isFlipped;
 
	NSDate *lastUpdatedDate;
}
@property BOOL isFlipped;
 
@property (nonatomic, retain) NSDate *lastUpdatedDate;
 
- (void)flipImageAnimated:(BOOL)animated;
- (void)toggleActivityView:(BOOL)isON;
- (void)setStatus:(int)status;
 
@end

In the implementation I also replaced setCurrentDate with a property because the last updated date is not necessarily the current one. Each of the apps you a tracking with MyAppSales can have a different time you last updated the reviews of it. The labels no longer have a clear background but instead the same background color as the whole view. It does not do much for performance in this case, but in general you should make your labels opaque so that less compositing is going on.

EGORefreshTableHeaderView.m

//
//  EGORefreshTableHeaderView.m
//  Demo
//
//  Created by Devin Doty on 10/14/09October14.
//  Copyright 2009 enormego. All rights reserved.
//
 
#import "EGORefreshTableHeaderView.h"
#import <QuartzCore/QuartzCore.h>
 
#define kReleaseToReloadStatus	0
#define kPullToReloadStatus		1
#define kLoadingStatus			2
 
#define TEXT_COLOR [UIColor colorWithRed:0.341 green:0.737 blue:0.537 alpha:1.0]
#define BORDER_COLOR [UIColor colorWithRed:0.341 green:0.737 blue:0.537 alpha:1.0]
 
@implementation EGORefreshTableHeaderView
 
@synthesize isFlipped, lastUpdatedDate;
 
- (id)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame])
	{
		self.backgroundColor = [UIColor colorWithRed:226.0/255.0
				green:231.0/255.0 blue:237.0/255.0 alpha:1.0];
 
		lastUpdatedLabel = [[UILabel alloc] initWithFrame:
			CGRectMake(0.0f, frame.size.height - 30.0f,
			 320.0f, 20.0f)];
		lastUpdatedLabel.font = [UIFont systemFontOfSize:12.0f];
		lastUpdatedLabel.textColor = TEXT_COLOR;
		lastUpdatedLabel.shadowColor =
			 [UIColor colorWithWhite:0.9f alpha:1.0f];
		lastUpdatedLabel.shadowOffset = CGSizeMake(0.0f, 1.0f);
		lastUpdatedLabel.backgroundColor = self.backgroundColor;
		lastUpdatedLabel.opaque = YES;
		lastUpdatedLabel.textAlignment = UITextAlignmentCenter;
		[self addSubview:lastUpdatedLabel];
		[lastUpdatedLabel release];
 
		statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(0.0f,
				 frame.size.height - 48.0f, 320.0f, 20.0f)];
		statusLabel.font = [UIFont boldSystemFontOfSize:13.0f];
		statusLabel.textColor = TEXT_COLOR;
		statusLabel.shadowColor = [UIColor colorWithWhite:0.9f alpha:1.0f];
		statusLabel.shadowOffset = CGSizeMake(0.0f, 1.0f);
		statusLabel.backgroundColor = self.backgroundColor;
		statusLabel.opaque = YES;
		statusLabel.textAlignment = UITextAlignmentCenter;
		[self setStatus:kPullToReloadStatus];
		[self addSubview:statusLabel];
		[statusLabel release];
 
		arrowImage = [[UIImageView alloc] initWithFrame:
			CGRectMake(25.0f, frame.size.height
			- 65.0f, 30.0f, 55.0f)];
		arrowImage.contentMode = UIViewContentModeScaleAspectFit;
		arrowImage.image = [UIImage imageNamed:@"blueArrow.png"];
		[arrowImage layer].transform =
			CATransform3DMakeRotation(M_PI, 0.0f, 0.0f, 1.0f);
		[self addSubview:arrowImage];
		[arrowImage release];
 
		activityView = [[UIActivityIndicatorView alloc]
			initWithActivityIndicatorStyle:
			UIActivityIndicatorViewStyleGray];
		activityView.frame = CGRectMake(25.0f, frame.size.height
			- 38.0f, 20.0f, 20.0f);
		activityView.hidesWhenStopped = YES;
		[self addSubview:activityView];
		[activityView release];
 
		isFlipped = NO;
    }
    return self;
}
 
- (void)drawRect:(CGRect)rect{
	CGContextRef context = UIGraphicsGetCurrentContext();
	CGContextDrawPath(context,  kCGPathFillStroke);
	[BORDER_COLOR setStroke];
	CGContextBeginPath(context);
	CGContextMoveToPoint(context, 0.0f, self.bounds.size.height - 1);
	CGContextAddLineToPoint(context, self.bounds.size.width,
		self.bounds.size.height - 1);
	CGContextStrokePath(context);
}
 
- (void)flipImageAnimated:(BOOL)animated
{
	[UIView beginAnimations:nil context:NULL];
	[UIView setAnimationDuration:animated ? .18 : 0.0];
	[arrowImage layer].transform = isFlipped ?
			CATransform3DMakeRotation(M_PI, 0.0f, 0.0f, 1.0f) :
			CATransform3DMakeRotation(M_PI * 2, 0.0f, 0.0f, 1.0f);
	[UIView commitAnimations];
 
	isFlipped = !isFlipped;
}
 
- (void)setLastUpdatedDate:(NSDate *)newDate
{
	if (newDate)
	{
		if (lastUpdatedDate != newDate)
		{
			[lastUpdatedDate release];
		}
 
		lastUpdatedDate = [newDate retain];
 
		NSDateFormatter* formatter = [[NSDateFormatter alloc] init];
		[formatter setDateStyle:NSDateFormatterShortStyle];
		[formatter setTimeStyle:NSDateFormatterShortStyle];
		lastUpdatedLabel.text = [NSString stringWithFormat:
		@"Last Updated: %@", [formatter stringFromDate:lastUpdatedDate]];
		[formatter release];
	}
	else
	{
		lastUpdatedDate = nil;
		lastUpdatedLabel.text = @"Last Updated: Never";
	}
}
 
- (void)setStatus:(int)status
{
	switch (status) {
		case kReleaseToReloadStatus:
			statusLabel.text = @"Release to refresh...";
			break;
		case kPullToReloadStatus:
			statusLabel.text = @"Pull down to refresh...";
			break;
		case kLoadingStatus:
			statusLabel.text = @"Loading...";
			break;
		default:
			break;
	}
}
 
- (void)toggleActivityView:(BOOL)isON
{
	if (!isON)
	{
		[activityView stopAnimating];
		arrowImage.hidden = NO;
	}
	else
	{
		[activityView startAnimating];
		arrowImage.hidden = YES;
		[self setStatus:kLoadingStatus];
	}
}
 
- (void)dealloc
{
	activityView = nil;
	statusLabel = nil;
	arrowImage = nil;
	lastUpdatedLabel = nil;
    [super dealloc];
}
 
@end

And this is my re-implementation as view controller, with all the necessary modifications discussed above.

PullToRefreshTableViewController.h

//
//  PullToRefreshTableViewController.h
//  ASiST
//
//  Created by Oliver on 09.12.09.
//  Copyright 2009 Drobnik.com. All rights reserved.
//
 
#import <UIKit/UIKit.h>
#import "EGORefreshTableHeaderView.h"
#import "SoundEffect.h"
 
@interface PullToRefreshTableViewController : UITableViewController
{
	EGORefreshTableHeaderView *refreshHeaderView;
 
	BOOL checkForRefresh;
	BOOL reloading;
 
	SoundEffect *psst1Sound;
	SoundEffect *psst2Sound;
	SoundEffect *popSound;
}
 
- (void)dataSourceDidFinishLoadingNewData;
- (void) showReloadAnimationAnimated:(BOOL)animated;
 
@end

PullToRefreshTableViewController.m

//
//  PullToRefreshTableViewController.m
//  ASiST
//
//  Created by Oliver on 09.12.09.
//  Copyright 2009 Drobnik.com. All rights reserved.
//
 
#import "PullToRefreshTableViewController.h"
 
#define kReleaseToReloadStatus 0
#define kPullToReloadStatus 1
#define kLoadingStatus 2
 
@implementation PullToRefreshTableViewController
 
- (void)viewDidLoad
{
    [super viewDidLoad];
 
	refreshHeaderView = [[EGORefreshTableHeaderView alloc] initWithFrame:
			CGRectMake(0.0f, 0.0f - self.view.bounds.size.height,
			320.0f, self.view.bounds.size.height)];
	[self.tableView addSubview:refreshHeaderView];
	self.tableView.showsVerticalScrollIndicator = YES;
 
	// pre-load sounds
	psst1Sound = [[SoundEffect alloc] initWithContentsOfFile:
				[[NSBundle mainBundle] pathForResource:@"psst1"
		ofType:@"wav"]];
	psst2Sound  = [[SoundEffect alloc] initWithContentsOfFile:
				[[NSBundle mainBundle] pathForResource:@"psst2"
		ofType:@"wav"]];
	popSound  = [[SoundEffect alloc] initWithContentsOfFile:
				[[NSBundle mainBundle] pathForResource:@"pop"
		ofType:@"wav"]];
 
}
 
- (void)dealloc
{
	[psst1Sound release];
	[psst2Sound release];
	[popSound release];
	[refreshHeaderView release];
    [super dealloc];
}
 
#pragma mark State Changes
 
- (void) showReloadAnimationAnimated:(BOOL)animated
{
	reloading = YES;
	[refreshHeaderView toggleActivityView:YES];
 
	if (animated)
	{
		[UIView beginAnimations:nil context:NULL];
		[UIView setAnimationDuration:0.2];
		self.tableView.contentInset = UIEdgeInsetsMake(60.0f, 0.0f, 0.0f,
			0.0f);
		[UIView commitAnimations];
	}
	else
	{
		self.tableView.contentInset = UIEdgeInsetsMake(60.0f, 0.0f, 0.0f,
			0.0f);
	}
}
 
- (void) reloadTableViewDataSource
{
	NSLog(@"Please override reloadTableViewDataSource");
}
 
- (void)dataSourceDidFinishLoadingNewData
{
	reloading = NO;
	[refreshHeaderView flipImageAnimated:NO];
	[UIView beginAnimations:nil context:NULL];
	[UIView setAnimationDuration:.3];
	[self.tableView setContentInset:UIEdgeInsetsMake(0.0f, 0.0f, 0.0f, 0.0f)];
	[refreshHeaderView setStatus:kPullToReloadStatus];
	[refreshHeaderView toggleActivityView:NO];
	[UIView commitAnimations];
	[popSound play];
}
 
#pragma mark Table view methods
 
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return 1;
}
 
// Customize the number of rows in the table view.
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:
	(NSInteger)section
{
    return 0;
}
 
// Customize the appearance of table view cells.
- (UITableViewCell *)tableView:(UITableView *)tableView
	cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
 
    static NSString *CellIdentifier = @"Cell";
 
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:
		CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault
				reuseIdentifier:CellIdentifier] autorelease];
    }
 
    // Set up the cell...
 
    return cell;
}
 
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:
	(NSIndexPath *)indexPath
{
    // Navigation logic may go here.
}
 
#pragma mark Scrolling Overrides
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
	if (!reloading)
	{
		checkForRefresh = YES;  //  only check offset when dragging
	}
}
 
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
	if (reloading) return;
 
	if (checkForRefresh) {
		if (refreshHeaderView.isFlipped
				&amp;&amp; scrollView.contentOffset.y &gt; -65.0f
				&amp;&amp; scrollView.contentOffset.y &lt; 0.0f
				&amp;&amp; !reloading) {
			[refreshHeaderView flipImageAnimated:YES];
			[refreshHeaderView setStatus:kPullToReloadStatus];
			[popSound play];
 
		} else if (!refreshHeaderView.isFlipped
				&amp;&amp; scrollView.contentOffset.y &lt; -65.0f) {
			[refreshHeaderView flipImageAnimated:YES];
			[refreshHeaderView setStatus:kReleaseToReloadStatus];
			[psst1Sound play];
		}
	}
}
 
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView
				 willDecelerate:(BOOL)decelerate
{
	if (reloading) return;
 
	if (scrollView.contentOffset.y &lt;= - 65.0f) {
		if([self.tableView.dataSource respondsToSelector:
				@selector(reloadTableViewDataSource)]){
			[self showReloadAnimationAnimated:YES];
			[psst2Sound play];
			[self reloadTableViewDataSource];
		}
	}
	checkForRefresh = NO;
}
 
@end

Now, to use this code to add pull-to-refresh to any existing tableview you simple change the table view controller’s class. This is from the class where I am using it, AppDetailViewController, also part of MyAppSales.

#import "PullToRefreshTableViewController.h"
 
@interface AppDetailViewController : PullToRefreshTableViewController
@end

In the implementation you only have to override the method that get’s called when reload should take place. When reloading is done you call dataSourceDidFinishLoadingNewData.

- (void)synchingDone:(NSNotification *)notification
{
	refreshHeaderView.lastUpdatedDate = myApp.lastReviewRefresh;
	[super dataSourceDidFinishLoadingNewData];
}
 
- (void)reloadTableViewDataSource
{
	[myApp getAllReviews];
}

Ah, one more thing … it could also be the case that a reload is already active and I want the reloading header be showing when the view appears. That’s why I have this new method showReloadAnimationAnimated: that accepts a parameter to either animate or not animate the showing of the header. In our view controllers viewWillAppear I am calling it without animation:

if (alreadyReloading)
	[self showReloadAnimationAnimated:NO];

If you already donated for MyAppSales then simply update your working copy from trunk to get this code and all used resources, the images, the sounds and the SoundEffect class. If you don’t yet support my work, why not start today?

I encourage you to make use of this paradigm (and code) in your own projects. Let me know how this works out for you.

* UPDATE Dec 12th: Due to an outcry on the blogosphere over my using the wave files from Tweetie I need to point out that I don’t condone “repurposing” other apps’ resources in your own commercial apps. The point was to show that it’s a possible and I am doing it in an educational context. The other reason I can do that is that MyAppSales is not a commercial app. Otherwise it would be on the app store. If and when I am using the PullToRefreshTableviewController in a commercial app then I will have to make my own sounds or get permission (aka “license”) from Loren to use them.


Categories: Recipes

%d bloggers like this: