Created
September 6, 2013 09:12
-
-
Save redent/6461423 to your computer and use it in GitHub Desktop.
Horizontal selector view with view reuse and infinite circular scrolling, allowing more than one element per page
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// TCHorizontalSelectorView.m | |
// TwinCodersLibrary | |
// | |
// Created by Guillermo Gutiérrez on 16/01/13. | |
// Copyright (c) 2013 TwinCoders S.L. All rights reserved. | |
// | |
#import <UIKit/UIKit.h> | |
#pragma mark - TCElementIndexPath interface | |
@interface TCElementIndexPath : NSObject<NSCopying> | |
@property (nonatomic, assign) NSInteger elementIndex; | |
@property (nonatomic, assign) NSInteger contentIndex; | |
- (id)initWithContentIndex:(NSInteger)contentIndex elementIndex:(NSInteger)elementIndex; | |
+ (id)elementIndexWithContentIndex:(NSInteger)contentIndex elementIndex:(NSInteger)elementIndex; | |
@end | |
#pragma mark - TCElementIndexPath implementation | |
@implementation TCElementIndexPath | |
- (id)initWithContentIndex:(NSInteger)contentIndex elementIndex:(NSInteger)elementIndex { | |
if (( self = [super init] )) { | |
self.elementIndex = elementIndex; | |
self.contentIndex = contentIndex; | |
} | |
return self; | |
} | |
+ (id)elementIndexWithContentIndex:(NSInteger)contentIndex elementIndex:(NSInteger)elementIndex { | |
return [[self alloc] initWithContentIndex:contentIndex elementIndex:elementIndex]; | |
} | |
- (BOOL)isEqual:(id)object { | |
return [object isKindOfClass:[TCElementIndexPath class]] | |
&& self.elementIndex == [(TCElementIndexPath*)object elementIndex] | |
&& self.contentIndex == [(TCElementIndexPath*)object contentIndex]; | |
} | |
- (NSUInteger)hash { | |
return _contentIndex * 10000 + _elementIndex; | |
} | |
- (NSString *)description { | |
return [NSString stringWithFormat:@"[%i, %i]", _contentIndex, _elementIndex]; | |
} | |
- (NSString *)debugDescription { | |
return self.description; | |
} | |
- (id)copyWithZone:(NSZone *)zone { | |
return [[self class] elementIndexWithContentIndex:_contentIndex elementIndex:_elementIndex]; | |
} | |
@end | |
#pragma mark - TCReusableSelectionView protocol | |
@protocol TCReusableSelectionView <NSObject> | |
@property (nonatomic, readonly) NSString* reuseIdentifier; | |
@end | |
#pragma mark Type definitions | |
typedef enum { | |
TCHorizontalSelectorTypeCenter = 0, | |
TCHorizontalSelectorTypeFill | |
} TCHorizontalSelectorType; | |
typedef UIView* (^TCHorizontalSelectorDataSource)(NSInteger elementIndex); | |
typedef void (^TCHorizontalSelectorElementSelected)(NSInteger elementIndex); | |
#pragma mark - TCHorizontalSelectorView interface | |
@interface TCHorizontalSelectorView : UIView<UIScrollViewDelegate> | |
#pragma mark - Properties | |
@property (nonatomic, getter=isNotifyOnClick) BOOL notifyOnClick; // If true, the selection block will be called when the product view is clicked instead of when is shown in the scroll. Only works for TCClickableUIViews or UIButton subclasses | |
@property (nonatomic, getter=isNotifyOnScrollStop) BOOL notifyOnScrollStop; // If true, the selection block will be called when the scroll completely stops instead of when the page changes | |
@property (nonatomic) NSInteger elementsPerPage; // Specifies the number of elements shown on each page. If set to a number different than one and notifyOnClick is disabled, selection will be notified for the first element of each page | |
@property (nonatomic) TCHorizontalSelectorType type; | |
@property (nonatomic) BOOL circularScrollEnabled; | |
@property (nonatomic, strong) NSNumber* preRenderedElements; // Indicates the amount of elements to pre-render front and behind the current visible elements. Defaults to 0. | |
@property (nonatomic) BOOL removeElementsOutOfBounds; // Removes elements out of the scroll bounds so they can be reused. Defaults to YES | |
@property (nonatomic) NSInteger selectedElement; | |
@property (nonatomic, readonly) NSInteger numberOfElements; | |
@property (nonatomic, readonly) NSInteger numberOfPages; | |
#pragma mark - IBOutlets | |
@property (nonatomic, strong) IBOutlet UIScrollView *scrollView; | |
@property (nonatomic, strong) IBOutlet UIPageControl *pageControl; | |
#pragma mark - Public methods | |
- (UIView<TCReusableSelectionView>*)dequeueViewWithReusableIdentifier:(NSString*)reuseIdentifier; | |
- (void)setNumberOfElements:(NSInteger)numberOfElements | |
withDataSource:(TCHorizontalSelectorDataSource)dataSource | |
onElementSelected:(TCHorizontalSelectorElementSelected)onElementSelected; | |
- (void)setSelectedElement:(NSInteger)selectedElement animated:(BOOL)animated; | |
- (void)resetViews; | |
@end | |
static const NSInteger kMaxPages = 3; | |
#pragma mark - TCHorizontalSelectorView private extension | |
@interface TCHorizontalSelectorView () | |
@property (nonatomic, copy) TCHorizontalSelectorDataSource dataSource; | |
@property (nonatomic, copy) TCHorizontalSelectorElementSelected onElementSelected; | |
@property (nonatomic, readwrite) NSInteger numberOfElements; | |
@property (nonatomic, readwrite) NSInteger numberOfPages; | |
@property (nonatomic, readonly) CGFloat pageWidth; | |
@property (nonatomic, strong) NSMutableDictionary* reusableViews; | |
@property (nonatomic, strong) NSMutableDictionary* presentedViews; | |
@end | |
#pragma mark - TCHorizontalSelectorView implementation | |
@implementation TCHorizontalSelectorView | |
#pragma mark - Init | |
- (id)init { | |
if (( self = [super init] )) { | |
[self initialize]; | |
} | |
return self; | |
} | |
- (id)initWithCoder:(NSCoder *)aDecoder { | |
if (( self = [super initWithCoder:aDecoder] )) { | |
[self initialize]; | |
} | |
return self; | |
} | |
- (id)initWithFrame:(CGRect)frame { | |
if (( self = [super initWithFrame:frame] )) { | |
[self initialize]; | |
} | |
return self; | |
} | |
- (void)initialize { | |
self.reusableViews = [[NSMutableDictionary alloc] init]; | |
self.presentedViews = [[NSMutableDictionary alloc] init]; | |
self.removeElementsOutOfBounds = YES; | |
self.elementsPerPage = 1; | |
self.preRenderedElements = @0; | |
} | |
#pragma mark - Public methods | |
- (UIView<TCReusableSelectionView>*)dequeueViewWithReusableIdentifier:(NSString*)reuseIdentifier { | |
NSMutableSet* reusableViews = [self.reusableViews objectForKey:reuseIdentifier]; | |
UIView<TCReusableSelectionView>* view = [reusableViews anyObject]; | |
if (view != nil) { | |
[reusableViews removeObject:view]; | |
} | |
return view; | |
} | |
- (void)setNumberOfElements:(NSInteger)numberOfElements | |
withDataSource:(TCHorizontalSelectorDataSource)dataSource | |
onElementSelected:(TCHorizontalSelectorElementSelected)onElementSelected { | |
self.scrollView.delegate = self; | |
self.scrollView.pagingEnabled = YES; | |
self.numberOfElements = numberOfElements; | |
self.dataSource = dataSource; | |
self.onElementSelected = onElementSelected; | |
[self resetViews]; | |
} | |
- (void)setSelectedElement:(NSInteger)selectedElement animated:(BOOL)animated { | |
_selectedElement = selectedElement; | |
[self scrollToElement:selectedElement animated:animated]; | |
} | |
- (void)setSelectedElement:(NSInteger)selectedElement { | |
[self setSelectedElement:selectedElement animated:NO]; | |
} | |
#pragma mark - Private methods | |
- (CGFloat)startOffset { | |
if (self.circularScrollEnabled) { | |
return self.contentWidth * kMaxPages; | |
} | |
return 0; | |
} | |
- (void)resetViews { | |
self.numberOfPages = ceilf(((CGFloat)self.numberOfElements) / self.elementsPerPage); | |
self.pageControl.numberOfPages = self.numberOfPages; | |
self.scrollView.contentSize = CGSizeMake(MAX(2 * self.startOffset, self.contentWidth), 0); | |
self.scrollView.contentOffset = CGPointMake(self.startOffset, 0); | |
self.scrollView.showsHorizontalScrollIndicator = NO; | |
[self removeElementsNotInIndexSet:nil]; | |
[self refreshViews]; | |
} | |
- (void)refreshViews { | |
CGFloat currentPosition = [self currentOffsetPosition]; | |
NSSet* elementIndexes = [self visibleIndexesForPosition:currentPosition]; | |
if (self.removeElementsOutOfBounds) { | |
[self removeElementsNotInIndexSet:elementIndexes]; | |
} | |
NSMutableSet* elementsToLayout = [NSMutableSet setWithSet:elementIndexes]; | |
[elementsToLayout minusSet:[NSSet setWithArray:self.presentedViews.allKeys]]; | |
for (TCElementIndexPath* elementIndex in elementIndexes) { | |
[self layoutElementIndexPath:elementIndex]; | |
} | |
} | |
- (void)layoutElementIndexPath:(TCElementIndexPath*)elementIndexPath { | |
CGFloat position = [self positionForIndexPath:elementIndexPath]; | |
CGFloat availableWidth = [self elementAvailableWidth]; | |
UIView* elementView = [self viewForElementIndexPath:elementIndexPath]; | |
CGFloat scrollHeight = self.scrollView.bounds.size.height; | |
CGRect frame = elementView.frame; | |
switch (self.type) { | |
case TCHorizontalSelectorTypeFill: { | |
frame.origin = CGPointMake(position, 0); | |
frame.size = CGSizeMake(availableWidth, scrollHeight); | |
break; | |
} | |
case TCHorizontalSelectorTypeCenter: { | |
CGSize size = elementView.frame.size; | |
frame.origin = CGPointMake(position + (availableWidth - size.width) / 2, (scrollHeight - size.height) / 2); | |
break; | |
} | |
} | |
elementView.frame = frame; | |
[self.scrollView addSubview:elementView]; | |
} | |
- (void)removeElementsNotInIndexSet:(NSSet*)elementIndexes { | |
NSMutableSet* viewsToRemove = [NSMutableSet setWithArray:self.presentedViews.allKeys]; | |
[viewsToRemove minusSet:elementIndexes]; | |
for (TCElementIndexPath* elementIndexPath in viewsToRemove) { | |
UIView* elementView = self.presentedViews[elementIndexPath]; | |
[self.presentedViews removeObjectForKey:elementIndexPath]; | |
[elementView removeFromSuperview]; | |
[self enqueueViewForReuse:elementView]; | |
} | |
} | |
- (UIView*)viewForElementIndexPath:(TCElementIndexPath*)elementIndexPath { | |
UIView* elementView = self.presentedViews[elementIndexPath]; | |
if (elementView == nil) { | |
elementView = [self viewForElement:elementIndexPath.elementIndex]; | |
self.presentedViews[elementIndexPath] = elementView; | |
} | |
return elementView; | |
} | |
- (UIView*)viewForElement:(NSInteger)elementIndex { | |
if (self.dataSource == nil) { | |
TCLog(@"Horizontal selector data source is nil"); | |
return nil; | |
} | |
UIView* elementView = self.dataSource(elementIndex); | |
if ([elementView isKindOfClass:[UIButton class]]) { | |
UIButton* button = (UIButton*)elementView; | |
[button removeTarget:self action:NULL forControlEvents:UIControlEventAllEvents]; | |
[button addTarget:self action:@selector(elementViewTapped:) forControlEvents:UIControlEventTouchUpInside]; | |
} | |
return elementView; | |
} | |
- (NSSet*)visibleIndexesForPosition:(CGFloat)scrollPosition { | |
if (self.numberOfElements == 0) { | |
return nil; | |
} | |
CGFloat additionalScrollPosition = self.preRenderedElements.integerValue * self.elementAvailableWidth; | |
CGFloat visibleInitialPosition = scrollPosition - additionalScrollPosition; | |
CGFloat visibleEndPosition = scrollPosition + additionalScrollPosition + self.pageWidth - 0.001; | |
return [self elementIndexesBetweenPosition:visibleInitialPosition endPosition:visibleEndPosition]; | |
} | |
- (NSSet*)elementIndexesBetweenPosition:(CGFloat)initialPosition endPosition:(CGFloat)endPosition { | |
TCElementIndexPath* initialElement = [self elementIndexForPosition:initialPosition]; | |
TCElementIndexPath* endElement = [self elementIndexForPosition:endPosition]; | |
return [self elementIndexesFromElementIndex:initialElement toElementIndex:endElement]; | |
} | |
- (NSSet*)elementIndexesFromElementIndex:(TCElementIndexPath*)initialElement toElementIndex:(TCElementIndexPath*)endElement { | |
NSMutableSet* set = [[NSMutableSet alloc] init]; | |
NSInteger contentIndex = initialElement.contentIndex; | |
NSInteger elementIndex = initialElement.elementIndex; | |
const NSInteger endContentIndex = endElement.contentIndex; | |
const NSInteger endElementIndex = endElement.elementIndex; | |
while (contentIndex < endContentIndex || (contentIndex == endContentIndex && elementIndex <= endElementIndex)) { | |
// Ignore elements outside the initial content index if circular scroll is disabled | |
if (self.circularScrollEnabled || contentIndex == 0) { | |
[set addObject:[TCElementIndexPath elementIndexWithContentIndex:contentIndex elementIndex:elementIndex]]; | |
} | |
elementIndex = elementIndex + 1; | |
if (elementIndex >= self.numberOfElements) { | |
elementIndex = 0; | |
contentIndex = contentIndex + 1; | |
} | |
} | |
return set; | |
} | |
- (NSIndexSet*)elementsForPage:(NSInteger)page { | |
NSInteger startElement = page * self.elementsPerPage; | |
NSInteger endElement = MIN(startElement + self.elementsPerPage, self.numberOfElements - 1); | |
NSInteger count = endElement - startElement + 1; | |
return [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(startElement, count)]; | |
} | |
- (void)enqueueViewForReuse:(UIView*)view { | |
NSString* reuseIdentifier = nil; | |
if ([view conformsToProtocol:@protocol(TCReusableSelectionView)]) { | |
reuseIdentifier = [(id<TCReusableSelectionView>)view reuseIdentifier]; | |
} | |
if (reuseIdentifier != nil) { | |
if (self.reusableViews == nil) { | |
self.reusableViews = [[NSMutableDictionary alloc] init]; | |
} | |
NSMutableSet* reusableViews = self.reusableViews[reuseIdentifier]; | |
if (reusableViews == nil) { | |
reusableViews = [[NSMutableSet alloc] init]; | |
self.reusableViews[reuseIdentifier] = reusableViews; | |
} | |
[reusableViews addObject:view]; | |
} | |
} | |
- (void)setCurrentPage:(NSInteger)currentPage { | |
[self.pageControl setCurrentPage:currentPage]; | |
} | |
- (TCElementIndexPath*)elementIndexPathForView:(UIView*)elementView { | |
__block TCElementIndexPath* elementIndexPath = nil; | |
[self.presentedViews enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { | |
if (obj == elementView) { | |
elementIndexPath = key; | |
*stop = YES; | |
} | |
}]; | |
return elementIndexPath; | |
} | |
#pragma mark Actions | |
- (void)elementViewTapped:(id)sender { | |
if ([self isNotifyOnClick]) { | |
TCElementIndexPath* elementIndexPath = [self elementIndexPathForView:sender]; | |
if (elementIndexPath != nil) { | |
self.onElementSelected(elementIndexPath.elementIndex); | |
} | |
else { | |
TCLog(@"Could not find element index for view %@", sender); | |
} | |
} | |
} | |
#pragma mark Scroll position | |
- (CGFloat)pageWidth { | |
return self.scrollView.bounds.size.width; | |
} | |
- (CGFloat)elementAvailableWidth { | |
return self.pageWidth / self.elementsPerPage; | |
} | |
- (CGFloat)currentOffsetPosition { | |
return self.scrollView.contentOffset.x; | |
} | |
- (CGFloat)contentWidth { | |
return self.pageWidth * self.numberOfPages; | |
} | |
- (NSInteger)contentIndexForPosition:(CGFloat)position { | |
return floorf((position - self.startOffset) / [self contentWidth]); | |
} | |
- (CGFloat)startingPositionForContentIndex:(NSInteger)contentIndex { | |
return self.startOffset + (contentIndex * [self contentWidth]); | |
} | |
- (CGFloat)positionForIndexPath:(TCElementIndexPath*)indexPath { | |
return [self startingPositionForContentIndex:indexPath.contentIndex] + (indexPath.elementIndex * self.elementAvailableWidth); | |
} | |
- (NSInteger)pageForElement:(NSInteger)elementIndex { | |
return elementIndex / self.elementsPerPage; | |
} | |
- (CGFloat)positionForPage:(NSInteger)page { | |
return self.startOffset + page * self.pageWidth; | |
} | |
- (CGFloat)positionForElement:(NSInteger)elementIndex { | |
return [self positionForPage:[self pageForElement:elementIndex]]; | |
} | |
- (NSInteger)elementForPosition:(CGFloat)position { | |
return [self pageForPosition:position] * self.elementsPerPage; | |
} | |
- (TCElementIndexPath*)elementIndexForPosition:(CGFloat)position { | |
NSInteger contentIndex = [self contentIndexForPosition:position]; | |
CGFloat offset = self.startOffset + contentIndex * self.contentWidth; | |
CGFloat elementPosition = position - offset; | |
NSInteger elementIndex = floorf(elementPosition / self.elementAvailableWidth); | |
elementIndex = MAX(0, MIN(self.numberOfElements - 1, elementIndex)); | |
return [TCElementIndexPath elementIndexWithContentIndex:contentIndex elementIndex:elementIndex]; | |
} | |
- (CGFloat)relativePositionFromPosition:(CGFloat)position { | |
CGFloat contentSize = [self contentWidth]; | |
NSInteger maxAmountOfPages = ceilf(self.startOffset / contentSize); | |
CGFloat relativePosition = fmodf(position - self.startOffset + (self.pageWidth * self.numberOfPages * maxAmountOfPages), contentSize); | |
relativePosition = MAX(MIN(relativePosition, contentSize), 0); | |
return relativePosition; | |
} | |
- (NSInteger)pageForPosition:(CGFloat)position { | |
if (self.numberOfPages == 0 || self.pageWidth <= 0.0001) { | |
return 0; | |
} | |
CGFloat relativePosition = [self relativePositionFromPosition:position] + (self.pageWidth / 2); | |
NSInteger page = (NSInteger)(floorf(relativePosition / self.pageWidth) + self.numberOfPages) % self.numberOfPages; | |
page = MAX(MIN(page, self.numberOfPages - 1), 0); | |
return page; | |
} | |
- (void)scrollToElement:(NSInteger)elementIndex animated:(BOOL)animated { | |
CGFloat position = [self positionForElement:elementIndex]; | |
[self.scrollView setContentOffset:CGPointMake(position, 0) animated:animated]; | |
} | |
#pragma mark - UIScrollViewDelegate | |
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { | |
TCLog(@"Did end decelerating at %.2f", scrollView.contentOffset.x); | |
NSInteger selectedElement = [self elementForPosition:[self currentOffsetPosition]]; | |
BOOL shouldNotify = self.selectedElement != selectedElement && !self.notifyOnClick && self.notifyOnScrollStop; | |
[self setSelectedElement:selectedElement animated:NO]; | |
if (shouldNotify) { | |
self.onElementSelected(selectedElement); | |
} | |
} | |
- (void)scrollViewDidScroll:(UIScrollView *)scrollView { | |
NSInteger selectedElement = [self elementForPosition:[self currentOffsetPosition]]; | |
BOOL shouldNotify = self.selectedElement != selectedElement && !self.notifyOnClick && !self.notifyOnScrollStop; | |
[self setCurrentPage:[self pageForPosition:[self currentOffsetPosition]]]; | |
if (shouldNotify) { | |
_selectedElement = selectedElement; | |
self.onElementSelected(selectedElement); | |
} | |
[self refreshViews]; | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment