Monday, November 19, 2012

Infinite Scrolling with reusable views iOS

UIScrollView in iOS provides a large ground to play with in iOS, UITableView being one of those.
But as the ground is large and opportunity as well, memory of a device should not be incorrectly used.


The example, that we have today shows "recycled" or "reusable" views to create an infinite scrolling behaviour. 

Some highlights of the implementation

  • There are 3 views at most at a single time
  • UIScrollView.contentSize is set to compose all of the 3
  • Whenever scrolling is performed, UIScrollView.contentOffset is set back to center.
  • After contentOffset is at center, depending upon the scrolling (left or right) the new center position is calculated and view at center is replaced by the view expected for the position. This happens smoothly enough and user doesn't suspect the happening behind scene.
  • After center view is created the view before and after is created as well.
  • If the current position is the first  (index 0) the view before is the last view(viewCount-1) and if current position is last (index, viewCount-1) next view is first (index 0). Giving a circular scrolling behaviour
The example demonstrates the same, with infinite scroll through various iPhone versions. Complete example can be downloaded from : https://bitbucket.org/shardul/ios/src

We achieve this by subclassing UIScrollView, lets look at the implementation.

InfiniteScrollView.h


//
//  InfinitePagingScrollView.h
//  InfinitePagingScrollView
//
//  Created by Shardul Prabhu on 18/11/12.
//  Copyright (c) 2012 Shardul Prabhu. All rights reserved.
//

#import 

@protocol InfinitePagingScrollViewDelegate;

@interface InfinitePagingScrollView : UIScrollView

@property (nonatomic,assign) id delegateForViews;

@end

@protocol InfinitePagingScrollViewDelegate

- (UIView*)setupView:(UIView*)reusedView forPosition:(NSUInteger) position;
- (NSUInteger) noOfViews;
- (void)clearView:(UIView*)reusableView;

@end


We will use delegation model of implementing.
InfinitePagingScrollView.h declares InfinitePagingScrollViewDelegate which requires implementation of


- (UIView*)setupView:(UIView*)reusedView forPosition:(NSUInteger) position;
 
    This method should return a UIView to be shown defined position. If reusedView is nil a new view should be created, otherwise, the same view can be reused by just updating the display logic.

- (NSUInteger) noOfViews;

   The total no of views the delegate wants to scroll through

- (void)clearView:(UIView*)reusableView;

   Before sending the reusedView to setupView method call, clearView for the same is called. So appropriate clearance can be made.

InfiniteScrollView.m


//
//  InfinitePagingScrollView.m
//  InfinitePagingScrollView
//
//  Created by Shardul Prabhu on 18/11/12.
//  Copyright (c) 2012 Shardul Prabhu. All rights reserved.
//

#import "InfinitePagingScrollView.h"

@protocol InfinitePagingScrollViewDelegate;

static const NSUInteger numOfReusableViews = 3;

@interface InfinitePagingScrollView(){
    
    NSMutableArray  *visibleViews;
    UIView          *containerView;
    NSUInteger      currentPosition;
}

@end

@implementation InfinitePagingScrollView

@synthesize delegateForViews=_delegateForViews;

- (id)initWithCoder:(NSCoder *)aDecoder{
    if((self= [super initWithCoder:aDecoder])){
        
        visibleViews=[[NSMutableArray alloc] init];
        
        containerView = [[UIView alloc] init];
        
        [self addSubview:containerView];
        
        [self setShowsHorizontalScrollIndicator:NO];
        
    }
    return self;
}

- (void)layoutSubviews{
    [super layoutSubviews];
    
    if(self.delegateForViews){
        
        CGPoint contentOffset = self.contentOffset;
        
        if([self.delegateForViews noOfViews]>numOfReusableViews){
            NSUInteger centerIndex=visibleViews.count/2;
            NSUInteger noOfViews=[self.delegateForViews noOfViews];
            UIView *centerView=[visibleViews objectAtIndex:centerIndex];
            
            CGPoint centerViewOrigin=centerView.frame.origin;
            CGSize centerViewSize=centerView.frame.size;
            CGFloat offsetDifference=contentOffset.x-centerViewOrigin.x;
            CGFloat offsetDifferenceAbs=fabs(contentOffset.x-centerViewOrigin.x);
            
            if(offsetDifferenceAbs>=centerViewSize.width){
                
                if(offsetDifference<0){
                    currentPosition--;
                }else{
                    currentPosition++;
                }
                
                self.contentOffset=centerViewOrigin;
                
                currentPosition=[self getPosition:currentPosition noOfViews:noOfViews];
                
                [self.delegateForViews clearView:centerView];
                [self.delegateForViews setupView:centerView forPosition:currentPosition];
                
                for (int i=centerIndex-1; i>=0; i--) {
                    UIView* prevView=[visibleViews objectAtIndex:i];
                    [self.delegateForViews clearView:prevView];
                    [self.delegateForViews setupView:prevView forPosition:
                            [self getPosition:currentPosition-1 noOfViews:noOfViews]];
                }
                
                for (int i=centerIndex+1; i<visibleViews.count; i++) {
                    UIView* nextView=[visibleViews objectAtIndex:i];
                    [self.delegateForViews clearView:nextView];
                    [self.delegateForViews setupView:nextView forPosition:
                            [self getPosition:currentPosition+1 noOfViews:noOfViews]];
                }
                
            }
        }
        
    }
    
}

- (NSUInteger)getPosition:(NSUInteger) aPosition noOfViews:(NSUInteger) count{
    if(aPosition==-1){
        aPosition=count-1;
    }
    else if(aPosition==(count)){
        aPosition=0;
    }
    return aPosition;
}


- (void)setDelegateForViews:(id<InfinitePagingScrollViewDelegate>)aDelegateForViews{
    _delegateForViews=aDelegateForViews;
    [self invalidateViews];
}


- (void) invalidateViews{
    if(self.delegateForViews){
        currentPosition=1;
        NSUInteger noOfViews=MIN(numOfReusableViews, [self.delegateForViews noOfViews]);
        
        containerView.frame= CGRectMake(0, 0, self.frame.size.width*noOfViews,
                                        self.frame.size.height);
        self.contentSize=CGSizeMake(self.frame.size.width*noOfViews, self.frame.size.height);
        for (int i=0; i<noOfViews; i++) {
            UIView* view=[self.delegateForViews setupView:nil forPosition:i];
            CGRect frame=view.frame;
            view.frame=CGRectMake(frame.origin.x+(frame.size.width*i),
                                  frame.origin.y,
                                  frame.size.width,
                                  frame.size.height);
            [containerView addSubview:view];
            [visibleViews addObject:view];
        }
        
        
    }
}

@end


layoutSubViews is where all the magic happens. Offsets are adjusted depending upon the scroll and delegate calls are made accordingly.

ViewController.m


//
//  ViewController.m
//  InfinitePagingScrollView
//
//  Created by Shardul Prabhu on 18/11/12.
//  Copyright (c) 2012 Shardul Prabhu. All rights reserved.
//

#import "ViewController.h"
#import "InfinitePagingScrollView.h"

@interface ViewController () <InfinitePagingScrollViewDelegate>{
    NSArray         *iPhoneVersions;
}

@property (weak, nonatomic) IBOutlet  InfinitePagingScrollView *infiniteScrollView;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    iPhoneVersions=[[NSArray alloc] initWithObjects:@"iPhone",@"iPhone 3G",@"iPhone 3GS",
                    @"iPhone 4",@"iPhone 4S",@"iPhone 5", nil];
}

- (void)viewDidAppear:(BOOL)animated{
    self.infiniteScrollView.delegateForViews=self;
}

- (NSUInteger)noOfViews{
    return iPhoneVersions.count;
}

- (UIView*)setupView:(UIView *)reusableView forPosition:(NSUInteger)position{
    UIView* view=reusableView;
    
    if(view==nil){
        UIStoryboard *storyboard = [UIStoryboard
                                    storyboardWithName:@"MainStoryboard_iPhone" bundle:nil];
        UIViewController *viewController = [storyboard
                            instantiateViewControllerWithIdentifier:@"iPhoneViewController"];
        view=viewController.view;
        [(UILabel*)view setTextAlignment:NSTextAlignmentCenter];
        view.frame=self.infiniteScrollView.frame;
    }
    
    [(UILabel*)view setText:[iPhoneVersions objectAtIndex:position]];
    [view setBackgroundColor:[UIColor colorWithWhite:1.0-(position/2.0*0.1) alpha:1.0]];
    [view setUserInteractionEnabled:YES];
    
    UITapGestureRecognizer* tapGesture=[[UITapGestureRecognizer alloc]
                                        initWithTarget:self action:@selector(tapped:)];
    [view addGestureRecognizer:tapGesture];
    
    return view;
}

- (void)clearView:(UIView*)reusableView{
    [reusableView setBackgroundColor:[UIColor clearColor]];
    [(UILabel*)reusableView setFont:[UIFont systemFontOfSize:16.0]];
    [reusableView removeGestureRecognizer:[reusableView.gestureRecognizers lastObject]];
}

- (void)tapped:(UIGestureRecognizer*) gestureRecognizer{
    [((UILabel*)gestureRecognizer.view) setFont:[UIFont boldSystemFontOfSize:16.0]];
}

@end

This shows the implementation of protocol. When new views are setup a UITapGestureRecognizer is set which makes the label bold and clearView sets back to normal.

I hope this was helpful, and will help you as a developer. Don't forget to share this with your friends, and comment below. I am open to refinement as well, I am learning iOS as well. :-)