Skip to content

Instantly share code, notes, and snippets.

@redent
Created September 6, 2013 09:12
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save redent/6461423 to your computer and use it in GitHub Desktop.
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
//
// 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