Saturday, June 9, 2012

UITableView Smooth Scrolling

In this post, I will try to show a step by step tutorial on how to implement a UITableView with smooth scrolling. We will build a simple twitter client. Our final application will be like this :
Final Application
Final Application
Our final application will be consisted of a UISearchBar and UITableView. Also as you can notice the UITableView will have dynamic row height and rounded images. Although it seems an ordinary application we will draw all the text and images manually using drawRect method of UIView which is the key point for having a smooth scrolling UITableView.

Creating the Project

We will start with the empty project template.


Now we have an empty project, we will add a new storyboard file for the UI and name it "Main". Then we need to add a storyboard file and name it "Main".




To make this storyboard file as our main UI interface we will make two changes. First we need to set the "Main StoryBoard" settings of the Targets section.  Write the name of our storyboard file in the Main Storyboard field.


Second we need the comment out the window creation code in the AppDelegate.m file.
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
   /* self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    // Override point for customization after application launch.
    self.window.backgroundColor = [UIColor whiteColor];
    [self.window makeKeyAndVisible];*/
    return YES;
}
because the storyboard will create the window for us automatically. Now we can add UI elements to our storyboard file. Our application will consist of a search field and a UITableView.
  • First add a View Controller
  • Second add an Table View
  • Third add an Search Bar to the Table View Element

Now add a subclass of UIViewController to manage the UITableView and SearchBar using File-> New->File Menu. We will call it MainViewController.


After adding MainViewController file switch to storyboard and change the underlying class of the ViewController to "MainViewController" in the identity inspector section.

Adding Third Party Libraries
We will use three third party libraries

you can find detailed instructions on how to add these libraries to your projects in the provided links above. Create a new group under your project folder called Libraries and add the third party libraries to this group to keep things organized.

Now in order to handle the UITableView and UISearchBar events we will add two class called TableHelper and SearchBarHelper. Usually all the delegate methods for UIView controls are kept inside one container UIViewController but I think it is much easier to have different classes for each UIView controls.
Now let's look at SearchHelper implementation :

SearchBarHelper for UISearchBar Delegate Methods

In order to handle UISearchBar delegate methods our custom class will implement UISearchBarDelegate protocol and we will use
- (void)searchBarTextDidBeginEditing:
- (void)searchBarSearchButtonClicked:
delegate methods. Here is the SearchBarHelper.h and SearchBarHelper.m
#import <Foundation/Foundation.h>
@interface SearchBarHelper : NSObject<UISearchBarDelegate>
-(void)hideOverlay;
@end

#import "SearchBarHelper.h"
#import "Helper.h"
#import "MBProgressHUD.h"

@interface SearchBarHelper ()
{
    UIView* overlayView;
    UISearchBar *m_searchBar;
}
@end

@implementation SearchBarHelper

#pragma mark optional methods

- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar{
    [self hideOverlay];
    
    MBProgressHUD *hud =  [MBProgressHUD showHUDAddedTo:searchBar.superview animated:YES];
    hud.userInteractionEnabled = NO;
    hud.labelText = @"Loading";
    hud.dimBackground = YES;
    [[Helper sharedInstance] bindData:searchBar.text CallBack:^{
        [MBProgressHUD hideHUDForView:searchBar.superview animated:YES];
    }];
}

- (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar{
    
    if (!m_searchBar) {
        m_searchBar = searchBar;
    }
    
    if (!overlayView) {
        CGRect tableViewRect = [searchBar superview].bounds;
        CGRect searchBarRect = searchBar.bounds;
        CGRect overlayRect = CGRectMake(0, searchBarRect.size.height, searchBarRect.size.width, tableViewRect.size.height);
        overlayView = [[UIView alloc] initWithFrame:overlayRect];
        overlayView.alpha = 0;
        overlayView.backgroundColor = [UIColor blackColor];
        
        [overlayView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(hideOverlay)]];
        
    }
    [UIView animateWithDuration:0.4 animations:^{
        overlayView.alpha = 0.7;
        [searchBar.superview addSubview:overlayView];
    }];
}
-(void)hideOverlay{
    [m_searchBar resignFirstResponder];
    [overlayView removeFromSuperview];
    overlayView.alpha = 0;
}
@end
it is a pretty simple implemantation. In the searchBarTextDidBeginEditing we save the calling UISearchBar into a variable. We could also use an IBoutlet for this but by doing saving a reference without using an IBoutlet we make the SearchBarHelper more flexible. Also we lazily create an UIView instance when user starts typing. After user hit the search button we call a helper method to download and parse the Json data which we will as a data source UITableView. Now let's look at the Helper class that contains helper methods for json download & parsing, image downloading and image processing.

Helper class

We will use a singleton class called Helper which will wrap task for json parsing, calculating boundaries for dynamic row height and rounded image borders. Here is the Helper class. Helper.h
#import <Foundation/Foundation.h>
#import "AFImageRequestOperation.h"
#import "AFJSONRequestOperation.h"
#import "AFNetworking/AFHTTPClient.h"
#import "Twit.h"
#import "MBProgressHUD.h"

typedef void (^ImageCompletionBlock)(UIImage* image, NSError *error);
typedef void (^JSonCompletionBlock)(id json, NSError *error);
typedef void (^CallbackBlock)(void);

@interface Helper : NSObject

@property (nonatomic, strong) NSMutableArray* data;

+(id)sharedInstance;
-(void) requestImageWithUrl:(NSString*)url CallBack:(ImageCompletionBlock)callBack;
-(void) requestJsonWithUrl:(NSString*)url CallBack:(JSonCompletionBlock)callBack;
-(void) bindData:(NSString*)searchKey CallBack:(CallbackBlock) callBack;
-(UIImage*) roundCorneredImage: (UIImage*) orig radius:(CGFloat) r;
@end
#import "Helper.h"
#import "Constants.h"

@interface Helper()
@property (nonatomic, strong) NSCache *sharedCache;
-(void) initializeReachability;
@end

@implementation Helper
@synthesize data = _data;
@synthesize sharedCache = _sharedCache;

static Helper* m_helper;

-(void) initializeReachability{
    
    UIWindow *window = [[[UIApplication sharedApplication] delegate] window];
    
    [[AFHTTPClient clientWithBaseURL:[NSURL URLWithString:@"http://www.google.com"]]
     setReachabilityStatusChangeBlock:^(AFNetworkReachabilityStatus status) {
         if (status == AFNetworkReachabilityStatusNotReachable) {
             dispatch_async(dispatch_get_main_queue(), ^{
                 MBProgressHUD*  hud= [MBProgressHUD showHUDAddedTo:window
                                                           animated:YES];
                 hud.mode = MBProgressHUDModeText;
                 hud.dimBackground = YES;
                 hud.labelText = @"No active internet connection";
             });
         }
         else {
             dispatch_async(dispatch_get_main_queue(), ^{
                 [MBProgressHUD hideAllHUDsForView:window animated:NO];
             });
         }
     }];
}

+(void)initialize{
    if (self == [Helper class] && m_helper == nil) {
        m_helper = [[Helper alloc] init];
        m_helper->_sharedCache = [[NSCache alloc] init];
        [m_helper initializeReachability];
    }
}
+(id)sharedInstance{
    return m_helper;
}

-(void)requestImageWithUrl:(NSString *)stringUrl CallBack:(ImageCompletionBlock)callBack{
    UIImage *img = [self.sharedCache objectForKey:stringUrl];
    if (img) {
        callBack(img, nil);
    }
    else {
        NSURL *url = [NSURL URLWithString:[stringUrl stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]];
        NSURLRequest* request = [NSURLRequest requestWithURL:url];
        
        AFImageRequestOperation *imageRequest = [AFImageRequestOperation
                                                 imageRequestOperationWithRequest:request
                                                 imageProcessingBlock:^UIImage *(UIImage *image) {
                                                     return  [self roundCorneredImage:image radius:IMAGE_BORDER];
                                                     
                                                 } success:^(NSURLRequest *request, NSHTTPURLResponse *response, UIImage *image) {
                                                     [self.sharedCache setObject:image forKey:stringUrl];
                                                     callBack(image, nil);
                                                 } failure:^(NSURLRequest *request, NSHTTPURLResponse *response, NSError *error) {
                                                     callBack(nil, error);
                                                 }];
        [imageRequest start];
    }
}

-(void)requestJsonWithUrl:(NSString *)stringUrl CallBack:(JSonCompletionBlock)callBack{
    NSURL *url = [NSURL URLWithString:[stringUrl stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    
    AFJSONRequestOperation *jsonRequest = [AFJSONRequestOperation JSONRequestOperationWithRequest:request success:^(NSURLRequest *request, NSHTTPURLResponse *response, id JSON) {
        callBack(JSON, nil);
    } failure:^(NSURLRequest *request, NSHTTPURLResponse *response, NSError *error, id JSON) {
        callBack(nil, error);
    }];
    
    [jsonRequest start];
}

-(void) bindData:(NSString *)searchKey CallBack:(CallbackBlock)callBack{
    
    NSString *url = [NSString stringWithFormat:SEARCH_URL, [searchKey stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]];
    
    [self requestJsonWithUrl:url CallBack:^(NSDictionary *json, NSError *error) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            NSMutableArray *dataArray = [[NSMutableArray alloc] init];
            NSArray *results = [json objectForKey:@"results"];
            float cellWidth = [UIScreen mainScreen].bounds.size.width;
            float textContentWidth = cellWidth - (IMAGE_SIZE + 3 * MARGIN);
            float textStartIndex = IMAGE_SIZE + MARGIN * 2.0f;
            
            for (NSDictionary *item in results) {
                Twit *twit = [[Twit alloc] init];
                twit.id = [item objectForKey:@"id"];
                twit.created_at = [item objectForKey:@"created_at"];
                twit.from_user = [item objectForKey:@"from_user"];
                twit.from_user_name = [item objectForKey:@"from_user_name"];
                twit.profile_image_url = [[item objectForKey:@"profile_image_url"] stringByReplacingOccurrencesOfString:@"normal" withString:@"bigger"];
                twit.text = [item objectForKey:@"text"];
                
                CGSize sizeTitleFrame = [twit.from_user_name sizeWithFont:[UIFont boldSystemFontOfSize:FONT_SIZE_SMALL] constrainedToSize:CGSizeMake(textContentWidth, FONT_SIZE_SMALL) lineBreakMode:UILineBreakModeHeadTruncation];
                
                twit.rectTitleFrame = CGRectMake(textStartIndex, MARGIN, sizeTitleFrame.width, sizeTitleFrame.height + MARGIN);
                
                
                CGSize sizeTextFrame = [twit.text sizeWithFont:[UIFont systemFontOfSize:FONT_SIZE_BIG] constrainedToSize:CGSizeMake(textContentWidth, 20000) lineBreakMode:UILineBreakModeWordWrap];
                twit.rectTextFrame = CGRectMake(textStartIndex, twit.rectTitleFrame.size.height + MARGIN, sizeTextFrame.width, sizeTextFrame.height);
                
                twit.cellHeight = MAX(twit.rectTitleFrame.size.height + twit.rectTextFrame.size.height + 2 * MARGIN, textStartIndex);
                [dataArray addObject:twit];
            }
            
            self.data = dataArray;
            
            dispatch_async(dispatch_get_main_queue(), ^{
                callBack();
            });
        });
    }];
}

- (UIImage*) roundCorneredImage: (UIImage*) orig radius:(CGFloat) r {
    
    UIGraphicsBeginImageContextWithOptions(orig.size, NO, [UIScreen mainScreen].scale);
    [[UIBezierPath bezierPathWithRoundedRect:(CGRect){CGPointZero, orig.size}
                                cornerRadius:r] addClip];
    [orig drawInRect:(CGRect){CGPointZero, orig.size}];
    UIImage* result = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return result;
}

@end
  • The "Helper" class has a class property called sharedInstance which will be used across the application to get a reference to the singleton instance.
  • The initializeReachability instance method notify user about the network changes
  • The requestJsonWithUrl:CallBack: method downloads the json request and fires a callback block on success or failure
  • The requestImageWithUrl:CallBack: methods downloads the image and fires a callback block on success or failure
  • The bindData:CallBack: method loop over the supplied array data and creates a twit object and calculates the boundaries of for the text, image and user name.
We use a model class for the twits. Here ise the Twit object.

#import <Foundation/Foundation.h>

@interface Twit : NSObject

@property (nonatomic, strong) NSString *id;
@property (nonatomic, strong) NSString *created_at;
@property (nonatomic, strong) NSString *from_user;
@property (nonatomic, strong) NSString *from_user_name;
@property (nonatomic, strong) NSString *profile_image_url;
@property (nonatomic, strong) NSString *text;
@property (nonatomic) float  cellHeight;
@property (nonatomic) CGRect rectTextFrame;
@property (nonatomic) CGRect rectTitleFrame;
@property (nonatomic, strong) NSString *profileImgUrl;
@property (nonatomic, strong) UIImage *profileImg;

@end


#import "Twit.h"

@implementation Twit

@synthesize id;
@synthesize created_at;
@synthesize from_user;
@synthesize from_user_name;
@synthesize profile_image_url;
@synthesize text;
@synthesize profileImgUrl;
@synthesize profileImg;
@synthesize cellHeight;
@synthesize rectTextFrame;
@synthesize rectTitleFrame;
@end

Custom UITableViewCell and custom UIView classes

We need to implement a custom UITableViewCell which will contain a custom UIView that we will draw our cell content manually in draw. Here is the TwitCell implementation.
TwitCell.h
#import <UIKit/UIKit.h>
#import "TwitView.h"
#import "Twit.h"

@interface TwitCell : UITableViewCell
@property (nonatomic, strong) Twit *modelData;
@end
TwitCell.m
#import "TwitCell.h"

@interface TwitCell ()
{
    TwitView *_customView;
}
@end

@implementation TwitCell
@synthesize modelData = _modelData;

- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        CGSize bounds = self.contentView.bounds.size;
        self.opaque = YES;
        _customView = [[TwitView alloc] initWithFrame:CGRectMake(0, 0, bounds.width, bounds.height)];
        _customView.backgroundColor = [UIColor whiteColor];
        
        [self.contentView addSubview:_customView];
    }
    return self;
}

- (void)setSelected:(BOOL)selected animated:(BOOL)animated
{
    [super setSelected:selected animated:animated];

    // Configure the view for the selected state
}

-(void)setModelData:(Twit *)modelData{
    _modelData = modelData;
    _customView.modelData = modelData;
}

@end
In the init method of TwitCell class a custom UIView class is initialized which we will draw our cell content into. Also there is a modelData property which is an instance of Twit class. Here is the custom UIView class that we will our cell content into.
TwitView.h
#import <UIKit/UIKit.h>
#import "Twit.h"

@interface TwitView : UIView

@property (nonatomic, strong) Twit* modelData;
@end
TwitView.m
#import "TwitView.h"
#import "Constants.h"

@implementation TwitView
@synthesize modelData = _modelData;

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        // Initialization code
    }
    return self;
}

- (void)drawRect:(CGRect)rect
{
    [_modelData.from_user_name drawInRect:_modelData.rectTitleFrame withFont:[UIFont boldSystemFontOfSize:FONT_SIZE_SMALL]];
    [_modelData.text drawInRect:_modelData.rectTextFrame withFont:[UIFont systemFontOfSize:FONT_SIZE_BIG]];
    [_modelData.profileImg drawInRect:CGRectMake(MARGIN, MARGIN, IMAGE_SIZE, IMAGE_SIZE)];
    
}

-(void)setModelData:(Twit *)modelData{
    _modelData = modelData;
    self.frame = CGRectMake(0, 0, self.frame.size.width, modelData.cellHeight);
    [self setNeedsDisplay];
}
@end
and here is the constant.h file that are the macros used in the TwitterView class.
Constant.h
#ifndef News_Constants_h
#define News_Constants_h

#define MARGIN              10
#define IMAGE_SIZE          60
#define FONT_SIZE_BIG       14
#define FONT_SIZE_SMALL     11
#define IMAGE_BORDER        5
#define SEARCH_URL @"http://search.twitter.com/search.json?q=%@&rpp=100&include_entities=false&result_type=recent"

#endif

UITableViewDelegate implemantation : TableHelper

Here is TableHelper class that we will create our custom drawn UITableViewCell. Also we will also override the -(void)observeValueForKeyPath:ofObject:change: which will tell the UITableView to reload its data.
TableHelper.h
#import <Foundation/Foundation.h>

@interface TableHelper : NSObject<UITableViewDataSource, UITableViewDelegate>
@end
TableHelper.m
#import "TableHelper.h"
#import "Twit.h"
#import "TwitCell.h"
#import "Helper.h"
@interface TableHelper ()
{
    NSMutableArray *data;
    UITableView *m_tableView;
    Helper* m_helper;
}
@end

@implementation TableHelper

#pragma mark optional UITableView method

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
    Twit *twit = [data objectAtIndex:indexPath.row];
    return twit.cellHeight;
}

#pragma mark required UITableView method

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    static NSString *cellIdentifier = @"customcell";
    
    TwitCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];
    
    if (cell == nil) {
        cell = [[TwitCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:cellIdentifier];
    }
    Twit *modelData = [data objectAtIndex:indexPath.row];
    
    if (!modelData.profileImg) {
        
        [m_helper requestImageWithUrl:modelData.profile_image_url CallBack:^(UIImage *image, NSError *error) {
            TwitCell* cell = (TwitCell*)[tableView cellForRowAtIndexPath:indexPath];
            Twit *modelData = [data objectAtIndex:indexPath.row];
            modelData.profileImg = image;
            cell.modelData = modelData;
        }];
    }
    cell.modelData = modelData;
    return cell;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
    if (!m_tableView) {
        m_tableView = tableView;
    }
    return [data count];
}

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{
    if (!m_helper) {
        m_helper = object;
    }
    data = [change objectForKey:NSKeyValueChangeNewKey];
    [m_tableView reloadData];
}

@end

Connecting The Delegates

Here is the custom UIViewController that will connect the UISearchBar and UITableView.
UIViewController.h
#import <UIKit/UIKit.h>
#import "SearchBarHelper.h"
#import "TableHelper.h"

@interface MainViewController : UIViewController
@property (strong, nonatomic) IBOutlet SearchBarHelper *searchBarDelegate;
@property (strong, nonatomic) IBOutlet TableHelper *tableViewDelegate;

@end
UIViewController.m
#import "MainViewController.h"
#import "Helper.h"

@implementation MainViewController
@synthesize searchBarDelegate;
@synthesize tableViewDelegate;

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    [[Helper sharedInstance] addObserver:tableViewDelegate forKeyPath:@"data" options:NSKeyValueObservingOptionNew context:nil];
}

- (void)viewDidUnload
{
    [self setSearchBarDelegate:nil];
    [self setTableViewDelegate:nil];
    [super viewDidUnload];
    // Release any retained subviews of the main view.
}

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return (interfaceOrientation == UIInterfaceOrientationPortrait);
}

@end
Here is the final result :

You can find the project files here
Hope this helps...

No comments:

Post a Comment