Do you like the way application images are displayed in AppStore or the way Safari flips between tabs? This post contains a complete  horizontal, paged UIScrollView with preview control.

Alexander Repty posted a sample on his blog and my sample is based on his work. I’ve encapsulated the solution into one, easy to use, control and solved the problem where “pages” in the scrollview didn’t detect touch/tap events. Here’s a short video showcasing the control:


UIScrollView -- preview

The control (BSPreviewScrollView) subclass UIView and contains a standard UIScrollView. The trick to achieve the preview experience is to disable clipping on the UIScrollView by setting “clipsToBounds” to NO. This makes content positioned outside the actual frame bounds to be visible. However, the content that would otherwise be hidden doesn’t respond to touch event such as scrolling. This is solved by listening to the “hitTest” event in BSPreviewScrollView and returning the UIScrollView if the touch is inside the preview areas.

There’s a difference here from Alexander Reptys solution where the UIScrollView was always returned in the hitTest method. This caused touch events from reaching the views inside the UIScrollView. Instead, BSPreviewScrollView defaults to base functionality unless the touch is actually outside the UIScrollView, ie, in the preview areas. This way the UIScrollView functions as expected and no functionality is crippled because of the preview function.

Preview padding

It’s important to notice that if you want padding or space between your views, which i suspect you do, you must handle this within the views you are adding to BSPreviewScrollView. In the sample app attached to this post I place UIImageViews inside the scroll view and while the actual view size is 240x320 pixels the image is just 210x280, this makes for 15 pixels of empty space on each side of the image. When lining two views next to each other this totals in 30 pixels of empty space between the images. So the content inside the views you want to display should be horizontally centered with empty pixels on both sides.

UIScrollView -- Paging

Paging is taken care of by the standard paging functionality in UIScrollView.


BSPreviewScrollView code

BSPreviewScrollView.h

#import <UIKit/UIKit.h>
#import <QuartzCore/QuartzCore.h>
 
 
@class BSPreviewScrollView;
 
@protocol BSPreviewScrollViewDelegate
@required
-(UIView*)viewForItemAtIndex:(BSPreviewScrollView*)scrollView index:(int)index;
-(int)itemCount:(BSPreviewScrollView*)scrollView;
 
@end
 
 
@interface BSPreviewScrollView : UIView<UIScrollViewDelegate> {
	UIScrollView *scrollView;	
	id<BSPreviewScrollViewDelegate, NSObject> delegate;
	NSMutableArray *scrollViewPages;
	BOOL firstLayout;
	CGSize pageSize;
	BOOL dropShadow;
}
@property (nonatomic, retain) UIScrollView *scrollView;
@property (nonatomic, assign) id<BSPreviewScrollViewDelegate, NSObject> delegate;
@property (nonatomic, assign) CGSize pageSize;
@property (nonatomic, assign) BOOL dropShadow;
 
- (void)didReceiveMemoryWarning;
- (id)initWithFrameAndPageSize:(CGRect)frame pageSize:(CGSize)size;
 
@end

BSPreviewScrollView.m

#import "BSPreviewScrollView.h"
 
#define SHADOW_HEIGHT 20.0
#define SHADOW_INVERSE_HEIGHT 10.0
#define SHADOW_RATIO (SHADOW_INVERSE_HEIGHT / SHADOW_HEIGHT)
 
@implementation BSPreviewScrollView
@synthesize scrollView, pageSize, dropShadow, delegate;
 
 
- (void)awakeFromNib
{
	firstLayout = YES;
	dropShadow = YES;
}
 
- (id)initWithFrame:(CGRect)frame
{
	if(self = [super initWithFrame:frame])
	{
		firstLayout = YES;
		dropShadow = YES;
	}
 
	return self;
}
 
- (id)initWithFrameAndPageSize:(CGRect)frame pageSize:(CGSize)size 
{    
	if (self = [self initWithFrame:frame]) 
	{
		self.pageSize = size;
    }
    return self;
}
 
-(void)loadPage:(int)page
{
	// Sanity checks
    if (page < 0) return;
    if (page >= [scrollViewPages count]) return;
 
	// Check if the page is already loaded
	UIView *view = [scrollViewPages objectAtIndex:page];
 
	// if the view is null we request the view from our delegate
	if ((NSNull *)view == [NSNull null]) 
	{
		view = [delegate viewForItemAtIndex:self index:page];
		[scrollViewPages replaceObjectAtIndex:page withObject:view];
	}
 
	// add the controller's view to the scroll view	if it's not already added
	if (view.superview == nil) 
	{
		// Position the view in our scrollview
		CGRect viewFrame = view.frame;
		viewFrame.origin.x = viewFrame.size.width * page;
		viewFrame.origin.y = 0;
 
		view.frame = viewFrame;
 
		[self.scrollView addSubview:view];
	}
}
 
// Shadow code from http://cocoawithlove.com/2009/08/adding-shadow-effects-to-uitableview.html
- (CAGradientLayer *)shadowAsInverse:(BOOL)inverse
{
    CAGradientLayer *newShadow = [[[CAGradientLayer alloc] init] autorelease];
    CGRect newShadowFrame =	CGRectMake(0, 0, self.frame.size.width, inverse ? SHADOW_INVERSE_HEIGHT : SHADOW_HEIGHT);
    newShadow.frame = newShadowFrame;
    CGColorRef darkColor =[UIColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:inverse ? (SHADOW_INVERSE_HEIGHT / SHADOW_HEIGHT) * 0.5 : 0.5].CGColor;
    CGColorRef lightColor =	[self.backgroundColor colorWithAlphaComponent:0.0].CGColor;
    newShadow.colors = [NSArray arrayWithObjects: (id)(inverse ? lightColor : darkColor), (id)(inverse ? darkColor : lightColor), nil];
    return newShadow;
}
 
- (void)layoutSubviews
{
	// We need to do some setup once the view is visible. This will only be done once.
	if(firstLayout)
	{
		// Add drop shadow to add that 3d effect
		if(dropShadow)
		{
			CAGradientLayer *topShadowLayer = [self shadowAsInverse:NO];
			CAGradientLayer *bottomShadowLayer = [self shadowAsInverse:YES];
			[self.layer insertSublayer:topShadowLayer atIndex:0];
			[self.layer insertSublayer:bottomShadowLayer atIndex:0];
 
			[CATransaction begin];
			[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
 
			// Position and stretch the shadow layers to fit
			CGRect topShadowLayerFrame = topShadowLayer.frame;
			topShadowLayerFrame.size.width = self.frame.size.width;
			topShadowLayerFrame.origin.y = 0;
			topShadowLayer.frame = topShadowLayerFrame;
 
			CGRect bottomShadowLayerFrame = bottomShadowLayer.frame;
			bottomShadowLayerFrame.size.width = self.frame.size.width;
			bottomShadowLayerFrame.origin.y = self.frame.size.height - bottomShadowLayer.frame.size.height;
			bottomShadowLayer.frame = bottomShadowLayerFrame;
 
			[CATransaction commit];
		}
 
		// Position and size the scrollview. It will be centered in the view.
		CGRect scrollViewRect = CGRectMake(0, 0, pageSize.width, pageSize.height);
		scrollViewRect.origin.x = ((self.frame.size.width - pageSize.width) / 2);
		scrollViewRect.origin.y = ((self.frame.size.height - pageSize.height) / 2);
 
		scrollView = [[UIScrollView alloc] initWithFrame:scrollViewRect];
		scrollView.clipsToBounds = NO; // Important, this creates the "preview"
		scrollView.pagingEnabled = YES;
		scrollView.showsHorizontalScrollIndicator = NO;
		scrollView.showsVerticalScrollIndicator = NO;
		scrollView.delegate = self;
 
		[self addSubview:scrollView];
 
 
		int pageCount = [delegate itemCount:self];
		scrollViewPages = [[NSMutableArray alloc] initWithCapacity:pageCount];
 
		// Fill our pages collection with empty placeholders
		for(int i = 0; i < pageCount; i++)
		{
			[scrollViewPages addObject:[NSNull null]];
		}
 
		// Calculate the size of all combined views that we are scrolling through 
		self.scrollView.contentSize = CGSizeMake([delegate itemCount:self] * self.scrollView.frame.size.width, scrollView.frame.size.height);
 
		// Load the first two pages
		[self loadPage:0];
		[self loadPage:1];
 
		firstLayout = NO;
	}
}
 
 
 
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
 
	// If the point is not inside the scrollview, ie, in the preview areas we need to return
	// the scrollview here for interaction to work
	if (!CGRectContainsPoint(scrollView.frame, point)) {
		return self.scrollView;
	}
 
	// If the point is inside the scrollview there's no reason to mess with the event.
	// This allows interaction to be handled by the active subview just like any scrollview
	return [super hitTest:point	withEvent:event];
}
 
-(int)currentPage
{
	// Calculate which page is visible 
	CGFloat pageWidth = scrollView.frame.size.width;
	int page = floor((scrollView.contentOffset.x - pageWidth / 2) / pageWidth) + 1;
 
	return page;
}
 
#pragma mark -
#pragma mark UIScrollViewDelegate methods
 
-(void)scrollViewDidScroll:(UIScrollView *)sv
{
	int page = [self currentPage];
 
 
	// Load the visible and neighbouring pages 
	[self loadPage:page-1];
	[self loadPage:page];
	[self loadPage:page+1];
}
 
#pragma mark -
#pragma mark Memory management
 
// didReceiveMemoryWarning is not called automatically for views, 
// make sure you call it from your view controller
- (void)didReceiveMemoryWarning 
{
	// Calculate the current page in scroll view
    int currentPage = [self currentPage];
 
	// unload the pages which are no longer visible
	for (int i = 0; i < [scrollViewPages count]; i++) 
	{
		UIView *viewController = [scrollViewPages objectAtIndex:i];
        if((NSNull *)viewController != [NSNull null])
		{
			if(i < currentPage-1 || i > currentPage+1)
			{
				[viewController removeFromSuperview];
				[scrollViewPages replaceObjectAtIndex:i withObject:[NSNull null]];
			}
		}
	}
 
}
 
- (void)dealloc 
{
	[scrollViewPages release];
	[scrollView release];
	[super dealloc];
}
 
 
@end

Download sample application

Download the sample application utilizing BSPreviewScrollView

Related posts