Bitching and Stiching iPhone Apps (almost) since 1974
RSS icon Email icon Home icon
  • How to make a Pull-To-Reload TableView just like Tweetie 2

    Posted on December 11th, 2009 drops 16 comments

    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 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.

     

    3 responses to “How to make a Pull-To-Reload TableView just like Tweetie 2” RSS icon

    • Sadly I’m a beginner and can’t get this to work. Tried to copy and paste the posted code into the demo created by Devin Doty and I keep running into issues.

      I can’t seem to be able to get the right SoundEffects wrapper, I can’t seem to comprehend if the new code provided functions in conjunction with Devin’s demo, etc.

      Again, I’m new to this and just trying to learn. Can somebody help?

      Thanks.

      • If I where to package all of this into a component of Dr. Touch’s Parts Store, would you buy it, for – say – 50 Euros? http://www.drobnik.com/touch/2010/01/dr-touchs-parts-store/

        • I don’t know about 50 Euros. That would be a bit steep for me, considering that I am just trying to learn and I’m just intrigued about this approach. I’m not using it to implement it on an app or anything like that.

          I just want to, more or less, compare the improvements that you made to the implementation on Devin’s demo. I just don’t know enough yet to piece things together on my own, but I’m working on it tho.

          Aside from that, considering that your is an implementation inspired by, and based on, Devin’s code, it would be somewhat questionable or perhaps controversial to charge for that code.

          But if I were to put a price that I could pay for someting like that, and again, considering my intended purpose, I would say that perhaps 10 or 15 Euros would be something that I could afford.

          That does NOT mean that the code is worth that. Not at all. It is worth more, I know. Knowledge is NOT cheap. I am just basing my appraisal on what I could afford based on my needs. That is all, so please do not get offended by the number.

          Thanks


    13 Trackbacks / Pingbacks

    Leave a reply

    You must be logged in to post a comment.