diff --git a/PSCollectionView.h b/PSCollectionView.h index 0bbe669..30275ea 100644 --- a/PSCollectionView.h +++ b/PSCollectionView.h @@ -31,20 +31,24 @@ #pragma mark - Public Properties -@property (nonatomic, retain) UIView *headerView; -@property (nonatomic, retain) UIView *footerView; -@property (nonatomic, retain) UIView *emptyView; -@property (nonatomic, retain) UIView *loadingView; +@property (nonatomic, strong) UIView *headerView; +@property (nonatomic, strong) UIView *footerView; +@property (nonatomic, strong) UIView *emptyView; +@property (nonatomic, strong) UIView *loadingView; +@property (nonatomic, assign, readwrite) CGFloat margin; @property (nonatomic, assign, readonly) CGFloat colWidth; @property (nonatomic, assign, readonly) NSInteger numCols; @property (nonatomic, assign) NSInteger numColsLandscape; @property (nonatomic, assign) NSInteger numColsPortrait; -@property (nonatomic, assign) id collectionViewDelegate; -@property (nonatomic, assign) id collectionViewDataSource; +@property (nonatomic, assign) BOOL animateLayoutChanges; +@property (nonatomic, weak) id collectionViewDelegate; +@property (nonatomic, weak) id collectionViewDataSource; #pragma mark - Public Methods +- (void)invalidateLayout; + /** Reloads the collection view This is similar to UITableView reloadData) @@ -55,7 +59,12 @@ Dequeues a reusable view that was previously initialized This is similar to UITableView dequeueReusableCellWithIdentifier */ -- (UIView *)dequeueReusableView; +- (PSCollectionViewCell *)dequeueReusableViewWithIdentifier:(NSString *)reuseIdentifier; + +- (void)insertItemAtIndexPath:(NSIndexPath *)indexPath; +- (void)removeItemAtIndexPath:(NSIndexPath *)indexPath; + +- (void)performBatchUpdates:(void (^)(void))updates completion:(void (^)(void))completion; @end @@ -64,7 +73,7 @@ @protocol PSCollectionViewDelegate @optional -- (void)collectionView:(PSCollectionView *)collectionView didSelectView:(PSCollectionViewCell *)view atIndex:(NSInteger)index; +- (void)collectionView:(PSCollectionView *)collectionView didSelectView:(PSCollectionViewCell *)view atIndexPath:(NSIndexPath *)indexPath; @end @@ -73,8 +82,12 @@ @protocol PSCollectionViewDataSource @required -- (NSInteger)numberOfViewsInCollectionView:(PSCollectionView *)collectionView; -- (PSCollectionViewCell *)collectionView:(PSCollectionView *)collectionView viewAtIndex:(NSInteger)index; -- (CGFloat)heightForViewAtIndex:(NSInteger)index; +- (NSUInteger)numberOfSectionsInCollectionView:(PSCollectionView *)collectionView; +- (NSUInteger)collectionView:(PSCollectionView *)collectionView numberOfViewsInSection:(NSUInteger)section; +- (PSCollectionViewCell *)collectionView:(PSCollectionView *)collectionView viewAtIndexPath:(NSIndexPath *)indexPath; +- (CGFloat)collectionView:(PSCollectionView *)collectionView heightForViewAtIndexPath:(NSIndexPath *)indexPath; + +@optional +- (UIView *)collectionView:(PSCollectionView *)collectionView sectionHeaderForSection:(NSUInteger)section; @end diff --git a/PSCollectionView.m b/PSCollectionView.m index 768c76c..470cf55 100644 --- a/PSCollectionView.m +++ b/PSCollectionView.m @@ -23,27 +23,25 @@ #import "PSCollectionView.h" #import "PSCollectionViewCell.h" +#import "PSCollectionViewLayoutAttributes.h" +#import "PSCollectionViewItemLayoutAttributes.h" +#import "PSCollectionViewSectionViewLayoutAttributes.h" -#define kMargin 8.0 +#define kDefaultMargin 8.0 +#define kAnimationDuration 0.3f -static inline NSString * PSCollectionKeyForIndex(NSInteger index) { - return [NSString stringWithFormat:@"%d", index]; -} - -static inline NSInteger PSCollectionIndexForKey(NSString *key) { - return [key integerValue]; -} +#define kPSCollectionViewCellReuseBufferRows 5 #pragma mark - UIView Category @interface UIView (PSCollectionView) -@property(nonatomic) CGFloat left; -@property(nonatomic) CGFloat top; -@property(nonatomic, readonly) CGFloat right; -@property(nonatomic, readonly) CGFloat bottom; -@property(nonatomic) CGFloat width; -@property(nonatomic) CGFloat height; +@property(nonatomic, assign) CGFloat left; +@property(nonatomic, assign) CGFloat top; +@property(nonatomic, assign, readonly) CGFloat right; +@property(nonatomic, assign, readonly) CGFloat bottom; +@property(nonatomic, assign) CGFloat width; +@property(nonatomic, assign) CGFloat height; @end @@ -109,361 +107,741 @@ @implementation PSCollectionViewTapGestureRecognizer @end -@interface PSCollectionView () +@interface PSCollectionView () @property (nonatomic, assign, readwrite) CGFloat colWidth; @property (nonatomic, assign, readwrite) NSInteger numCols; -@property (nonatomic, assign) UIInterfaceOrientation orientation; - -@property (nonatomic, retain) NSMutableSet *reuseableViews; -@property (nonatomic, retain) NSMutableDictionary *visibleViews; -@property (nonatomic, retain) NSMutableArray *viewKeysToRemove; -@property (nonatomic, retain) NSMutableDictionary *indexToRectMap; - - -/** - Forces a relayout of the collection grid - */ -- (void)relayoutViews; - -/** - Stores a view for later reuse - TODO: add an identifier like UITableView - */ -- (void)enqueueReusableView:(PSCollectionViewCell *)view; - -/** - Magic! - */ -- (void)removeAndAddCellsIfNecessary; @end -@implementation PSCollectionView - -// Public Views -@synthesize -headerView = _headerView, -footerView = _footerView, -emptyView = _emptyView, -loadingView = _loadingView; - -// Public -@synthesize -colWidth = _colWidth, -numCols = _numCols, -numColsLandscape = _numColsLandscape, -numColsPortrait = _numColsPortrait, -collectionViewDelegate = _collectionViewDelegate, -collectionViewDataSource = _collectionViewDataSource; - -// Private -@synthesize -orientation = _orientation, -reuseableViews = _reuseableViews, -visibleViews = _visibleViews, -viewKeysToRemove = _viewKeysToRemove, -indexToRectMap = _indexToRectMap; +@implementation PSCollectionView { + BOOL _batchUpdateInProgress; + UIInterfaceOrientation _orientation; + + BOOL _initialLayoutDataInitialized; + + NSMutableDictionary *_reusableViews; + + NSMutableArray *_colXOffsets; + NSInteger _numSections; + NSMutableArray *_sectionColumnHeights; //contains an array of arrays, first array is positioned by section number, values are the heights of each column within each section + NSMutableDictionary *_sectionItems; //key is section number, value is array with position is by index, value is PSCollectionViewLayoutAttribute objects + NSMutableArray *_sectionHeaders; +} #pragma mark - Init/Memory -- (id)initWithFrame:(CGRect)frame { +- (id)initWithFrame:(CGRect)frame +{ self = [super initWithFrame:frame]; if (self) { self.alwaysBounceVertical = YES; + + self.margin = kDefaultMargin; self.colWidth = 0.0; self.numCols = 0; self.numColsPortrait = 0; self.numColsLandscape = 0; - self.orientation = [UIApplication sharedApplication].statusBarOrientation; - - self.reuseableViews = [NSMutableSet set]; - self.visibleViews = [NSMutableDictionary dictionary]; - self.viewKeysToRemove = [NSMutableArray array]; - self.indexToRectMap = [NSMutableDictionary dictionary]; + self.animateLayoutChanges = YES; + + _numSections = 1; + _orientation = [UIApplication sharedApplication].statusBarOrientation; + _reusableViews = [NSMutableDictionary dictionary]; + _sectionItems = [NSMutableDictionary dictionary]; + _sectionHeaders = [NSMutableArray array]; + + PSCollectionViewTapGestureRecognizer *recognizer = [[PSCollectionViewTapGestureRecognizer alloc] initWithTarget:self action:@selector(didSelectView:)]; + [recognizer setCancelsTouchesInView:NO]; + [self addGestureRecognizer:recognizer]; + + [self invalidateLayout]; } return self; } -- (void)dealloc { +- (void)dealloc +{ // clear delegates self.delegate = nil; self.collectionViewDataSource = nil; self.collectionViewDelegate = nil; - - // release retains - self.headerView = nil; - self.footerView = nil; - self.emptyView = nil; - self.loadingView = nil; - - self.reuseableViews = nil; - self.visibleViews = nil; - self.viewKeysToRemove = nil; - self.indexToRectMap = nil; - [super dealloc]; } #pragma mark - Setters +- (void)setNumColsLandscape:(NSInteger)numColsLandscape +{ + _numColsLandscape = numColsLandscape; + [self invalidateLayout]; +} + +- (void)setNumColsPortrait:(NSInteger)numColsPortrait +{ + _numColsPortrait = numColsPortrait; + [self invalidateLayout]; +} + +- (void)setMargin:(CGFloat)margin +{ + _margin = margin; + [self invalidateLayout]; +} + - (void)setLoadingView:(UIView *)loadingView { - if (_loadingView && [_loadingView respondsToSelector:@selector(removeFromSuperview)]) { - [_loadingView removeFromSuperview]; - } - [_loadingView release], _loadingView = nil; - _loadingView = [loadingView retain]; - - [self addSubview:_loadingView]; + [_loadingView removeFromSuperview]; + _loadingView = nil; + + if (loadingView) { + _loadingView = loadingView; + [self addSubview:_loadingView]; + } + + [self invalidateLayout]; +} + +- (void)setEmptyView:(UIView *)emptyView { + [_emptyView removeFromSuperview]; + _emptyView = nil; + + if (emptyView) { + _emptyView = emptyView; + _emptyView.hidden = YES; + [self addSubview:_emptyView]; + } + + [self invalidateLayout]; +} + +- (void)setHeaderView:(UIView *)headerView { + [_headerView removeFromSuperview]; + _headerView = nil; + + if (headerView) { + _headerView = headerView; + [self addSubview:_headerView]; + } + + [self invalidateLayout]; } -#pragma mark - DataSource +- (void)setFooterView:(UIView *)footerView { + [_footerView removeFromSuperview]; + _footerView = nil; + + if (footerView) { + _footerView = footerView; + [self addSubview:_footerView]; + } + + [self invalidateLayout]; +} + +- (void)setFrame:(CGRect)frame +{ + [super setFrame:frame]; + [self invalidateLayout]; +} -- (void)reloadData { - [self relayoutViews]; +#pragma mark - Reset + +- (void)reloadData +{ + _initialLayoutDataInitialized = NO; + + for (NSArray *sectionItems in [_sectionItems allValues]) { + for (PSCollectionViewItemLayoutAttributes *attributes in sectionItems) { + [self enqueueReusableView:attributes.visibleCell]; + } + } + + [self initializeRequiredLayoutData]; + + [self invalidateLayout]; + [self setNeedsLayout]; +} + +- (void)resetSectionHeadersFooters +{ + for (PSCollectionViewSectionViewLayoutAttributes *sectionAttributes in _sectionHeaders) { + [sectionAttributes.view removeFromSuperview]; + } + _sectionHeaders = [NSMutableArray array]; + + //retrieve the section header and footers + for (NSUInteger i = 0; i < _numSections; i++) { + PSCollectionViewSectionViewLayoutAttributes *headerAttributes = [[PSCollectionViewSectionViewLayoutAttributes alloc] init]; + if ([self.collectionViewDataSource respondsToSelector:@selector(collectionView:sectionHeaderForSection:)]) { + UIView *sectionHeader = [self.collectionViewDataSource collectionView:self sectionHeaderForSection:i]; + headerAttributes.view = sectionHeader; + [self addSubview:sectionHeader]; + } + [_sectionHeaders addObject:headerAttributes]; + } +} + +- (void)initializeRequiredLayoutData +{ + _sectionItems = [NSMutableDictionary dictionary]; + + _numSections = [self.collectionViewDataSource numberOfSectionsInCollectionView:self]; + //ensure there are entries for earlier sections + for (NSUInteger i = [_sectionColumnHeights count]; i < _numSections; i++) { + [_sectionColumnHeights addObject:[NSMutableArray array]]; + [self resetColumnHeightsInSection:i]; + } + + for (NSUInteger i = 0; i < _numSections; i++) { + //recreate items for the number of views that will appear in the grid + NSInteger numCells = [self.collectionViewDataSource collectionView:self numberOfViewsInSection:i]; + NSMutableArray *items = [NSMutableArray arrayWithCapacity:numCells]; + for (NSUInteger i = 0; i < numCells; i++) { + [items addObject:[[PSCollectionViewItemLayoutAttributes alloc] init]]; + } + [_sectionItems setObject:items forKey:@(i)]; + } + + [self resetSectionHeadersFooters]; + + _initialLayoutDataInitialized = YES; } #pragma mark - View -- (void)layoutSubviews { +- (void)invalidateItemLayoutAttributes:(PSCollectionViewItemLayoutAttributes *)attributes +{ + attributes.valid = NO; + attributes.frame = CGRectZero; + attributes.previouslyVisible = NO; +} + +- (void)invalidateSectionLayoutAttributes:(PSCollectionViewSectionViewLayoutAttributes *)attributes +{ + attributes.valid = NO; + attributes.previouslyVisible = NO; +} + +- (void)invalidateLayout +{ + for (PSCollectionViewSectionViewLayoutAttributes *sectionHeader in _sectionHeaders) { + [self invalidateSectionLayoutAttributes:sectionHeader]; + } + for (NSArray *sectionItems in [_sectionItems allValues]) { + [sectionItems enumerateObjectsWithOptions:NSEnumerationConcurrent usingBlock:^(PSCollectionViewItemLayoutAttributes *attributes, NSUInteger idx, BOOL *stop) { + [self invalidateItemLayoutAttributes:attributes]; + }]; + } + + self.numCols = UIInterfaceOrientationIsPortrait(_orientation) ? self.numColsPortrait : self.numColsLandscape; + + //reset the column offsets + _colXOffsets = nil; + + [self resetColumnHeights]; + if (self.numCols == 0) { + self.colWidth = 0.0f; + } else { + self.colWidth = floorf((self.width - self.margin * (self.numCols + 1)) / self.numCols); + } + + [self resetSectionHeadersFooters]; +} + +- (void)resetColumnHeights +{ + //sections are assumed to be the same height, this will change as items are added to each sections + _sectionColumnHeights = [NSMutableArray array]; + for (NSUInteger i = 0; i < _numSections; i++) { + [_sectionColumnHeights addObject:[NSMutableArray array]]; //ensure the array has a position for this section, this simplifies the resetColumnHeightsInSection method + [self resetColumnHeightsInSection:i]; + } +} + +- (void)resetColumnHeightsInSection:(NSUInteger)section +{ + //sections are assumed to be the same height, this will change as items are added to each sections + NSNumber *marginHeight = @(self.margin); + NSMutableArray *colHeights = [NSMutableArray array]; + for (NSUInteger i = 0; i < self.numCols; i++) { + [colHeights addObject:marginHeight]; + } + _sectionColumnHeights[section] = colHeights; +} + +- (void)layoutSubviews +{ [super layoutSubviews]; - + UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation; - if (self.orientation != orientation) { - self.orientation = orientation; - [self relayoutViews]; - } else { - [self removeAndAddCellsIfNecessary]; - } + if (_orientation != orientation) { + _orientation = orientation; + [self invalidateLayout]; + } + + [self performLayout]; } -- (void)relayoutViews { - self.numCols = UIInterfaceOrientationIsPortrait(self.orientation) ? self.numColsPortrait : self.numColsLandscape; - - // Reset all state - [self.visibleViews enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - PSCollectionViewCell *view = (PSCollectionViewCell *)obj; - [self enqueueReusableView:view]; - }]; - [self.visibleViews removeAllObjects]; - [self.viewKeysToRemove removeAllObjects]; - [self.indexToRectMap removeAllObjects]; - - if (self.emptyView) { - [self.emptyView removeFromSuperview]; - } - [self.loadingView removeFromSuperview]; - - // This is where we should layout the entire grid first - NSInteger numViews = [self.collectionViewDataSource numberOfViewsInCollectionView:self]; - - CGFloat totalHeight = 0.0; - CGFloat top = kMargin; - - // Add headerView if it exists - if (self.headerView) { - self.headerView.top = kMargin; - top = self.headerView.top; - [self addSubview:self.headerView]; - top += self.headerView.height; - top += kMargin; - } - - if (numViews > 0) { - // This array determines the last height offset on a column - NSMutableArray *colOffsets = [NSMutableArray arrayWithCapacity:self.numCols]; - for (int i = 0; i < self.numCols; i++) { - [colOffsets addObject:[NSNumber numberWithFloat:top]]; - } - - // Calculate index to rect mapping - self.colWidth = floorf((self.width - kMargin * (self.numCols + 1)) / self.numCols); - for (NSInteger i = 0; i < numViews; i++) { - NSString *key = PSCollectionKeyForIndex(i); - - // Find the shortest column - NSInteger col = 0; - CGFloat minHeight = [[colOffsets objectAtIndex:col] floatValue]; - for (int i = 1; i < [colOffsets count]; i++) { - CGFloat colHeight = [[colOffsets objectAtIndex:i] floatValue]; - - if (colHeight < minHeight) { - col = i; - minHeight = colHeight; - } - } - - CGFloat left = kMargin + (col * kMargin) + (col * self.colWidth); - CGFloat top = [[colOffsets objectAtIndex:col] floatValue]; - CGFloat colHeight = [self.collectionViewDataSource heightForViewAtIndex:i]; - if (colHeight == 0) { - colHeight = self.colWidth; - } - - if (top != top) { - // NaN - } - - CGRect viewRect = CGRectMake(left, top, self.colWidth, colHeight); - - // Add to index rect map - [self.indexToRectMap setObject:NSStringFromCGRect(viewRect) forKey:key]; - - // Update the last height offset for this column - CGFloat test = top + colHeight + kMargin; - - if (test != test) { - // NaN - } - [colOffsets replaceObjectAtIndex:col withObject:[NSNumber numberWithFloat:test]]; - } - - for (NSNumber *colHeight in colOffsets) { - totalHeight = (totalHeight < [colHeight floatValue]) ? [colHeight floatValue] : totalHeight; - } - } else { - totalHeight = self.height; - - // If we have an empty view, show it - if (self.emptyView) { - self.emptyView.frame = CGRectMake(kMargin, top, self.width - kMargin * 2, self.height - top - kMargin); - [self addSubview:self.emptyView]; - } - } - - // Add footerView if exists - if (self.footerView) { - self.footerView.top = totalHeight; - [self addSubview:self.footerView]; - totalHeight += self.footerView.height; - totalHeight += kMargin; - } - - self.contentSize = CGSizeMake(self.width, totalHeight); - - [self removeAndAddCellsIfNecessary]; -} - -- (void)removeAndAddCellsIfNecessary { - static NSInteger bufferViewFactor = 5; - static NSInteger topIndex = 0; - static NSInteger bottomIndex = 0; - - NSInteger numViews = [self.collectionViewDataSource numberOfViewsInCollectionView:self]; - - if (numViews == 0) return; - - // Find out what rows are visible - CGRect visibleRect = CGRectMake(self.contentOffset.x, self.contentOffset.y, self.width, self.height); - - // Remove all rows that are not inside the visible rect - [self.visibleViews enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - PSCollectionViewCell *view = (PSCollectionViewCell *)obj; - CGRect viewRect = view.frame; - if (!CGRectIntersectsRect(visibleRect, viewRect)) { - [self enqueueReusableView:view]; - [self.viewKeysToRemove addObject:key]; - } - }]; - - [self.visibleViews removeObjectsForKeys:self.viewKeysToRemove]; - [self.viewKeysToRemove removeAllObjects]; - - if ([self.visibleViews count] == 0) { - topIndex = 0; - bottomIndex = numViews; - } else { - NSArray *sortedKeys = [[self.visibleViews allKeys] sortedArrayUsingComparator:^(id obj1, id obj2) { - if ([obj1 integerValue] < [obj2 integerValue]) { - return (NSComparisonResult)NSOrderedAscending; - } else if ([obj1 integerValue] > [obj2 integerValue]) { - return (NSComparisonResult)NSOrderedDescending; - } else { - return (NSComparisonResult)NSOrderedSame; - } - }]; - topIndex = [[sortedKeys objectAtIndex:0] integerValue]; - bottomIndex = [[sortedKeys lastObject] integerValue]; - - topIndex = MAX(0, topIndex - (bufferViewFactor * self.numCols)); - bottomIndex = MIN(numViews, bottomIndex + (bufferViewFactor * self.numCols)); - } - // NSLog(@"topIndex: %d, bottomIndex: %d", topIndex, bottomIndex); - - // Add views - for (NSInteger i = topIndex; i < bottomIndex; i++) { - NSString *key = PSCollectionKeyForIndex(i); - CGRect rect = CGRectFromString([self.indexToRectMap objectForKey:key]); - - // If view is within visible rect and is not already shown - if (![self.visibleViews objectForKey:key] && CGRectIntersectsRect(visibleRect, rect)) { - // Only add views if not visible - PSCollectionViewCell *newView = [self.collectionViewDataSource collectionView:self viewAtIndex:i]; - newView.frame = CGRectFromString([self.indexToRectMap objectForKey:key]); - [self addSubview:newView]; - - // Setup gesture recognizer - if ([newView.gestureRecognizers count] == 0) { - PSCollectionViewTapGestureRecognizer *gr = [[[PSCollectionViewTapGestureRecognizer alloc] initWithTarget:self action:@selector(didSelectView:)] autorelease]; - gr.delegate = self; - [newView addGestureRecognizer:gr]; - newView.userInteractionEnabled = YES; - } - - [self.visibleViews setObject:newView forKey:key]; - } - } +- (void)performLayout +{ + if (_initialLayoutDataInitialized == NO) { + [self initializeRequiredLayoutData]; + } + + __block BOOL recalculateContentSize = NO; + + //layout header + CGSize headerSize = [self.headerView sizeThatFits:CGSizeMake(self.width, CGFLOAT_MAX)]; + if (self.headerView.height != headerSize.height) { + self.headerView.height = headerSize.height; + self.headerView.width = self.width; + + //since the height was changed, the layout needs to be adjusted to handle it + [self invalidateLayout]; + recalculateContentSize = YES; + } + + //handle displaying and hiding the empty view + if (self.emptyView) { + BOOL hasItems = NO; + for (NSArray *sectonItems in [_sectionItems allValues]) { + if ([sectonItems count] > 0) { + hasItems = YES; + } + } + if (hasItems == NO) { + self.emptyView.frame = CGRectMake(self.margin, self.margin, self.width - self.margin * 2, self.height - self.margin * 2); + self.emptyView.hidden = NO; + } else { + self.emptyView.hidden = YES; + } + } + + if (_colXOffsets == nil) { + _colXOffsets = [NSMutableArray arrayWithCapacity:self.numCols]; + CGFloat left = self.margin; + for (int i = 0; i < self.numCols; i++) { + [_colXOffsets addObject:@(left)]; + left += self.colWidth + self.margin; + } + } + + //calculate the positions of all cells, but skip the cells that had no change + //a cell has a change if its layout is marked as invalid + for (NSUInteger section = 0; section < _numSections; section++) { + NSNumber *sectionNumber = @(section); + + //layout the section header + PSCollectionViewSectionViewLayoutAttributes *sectionHeaderAttributes = [self sectionHeaderAttributesForSection:section]; + if (sectionHeaderAttributes.view && sectionHeaderAttributes.valid == NO) { + UIView *sectionHeader = sectionHeaderAttributes.view; + CGSize headerSize = [sectionHeader sizeThatFits:CGSizeMake(self.width, CGFLOAT_MAX)]; + CGFloat yOffset = [self yOffsetForBeginningOfSection:section]; + CGRect frame = CGRectMake(CGRectGetMinX(self.bounds), yOffset, self.width, headerSize.height); + sectionHeaderAttributes.valid = YES; + + //animations shouldn't happen if the section header hasn't had a frame yet + if (self.animateLayoutChanges && sectionHeaderAttributes.previouslyVisible && CGRectEqualToRect(sectionHeader.frame, CGRectZero) == NO) { + [UIView animateWithDuration:kAnimationDuration animations:^{ + sectionHeader.frame = frame; + }]; + } else { + [UIView setAnimationsEnabled:NO]; + sectionHeader.frame = frame; + [UIView setAnimationsEnabled:YES]; + } + sectionHeaderAttributes.previouslyVisible = YES; + + recalculateContentSize = YES; + } + + //layout the section items + NSMutableArray *sectionItems = _sectionItems[sectionNumber]; + [sectionItems enumerateObjectsUsingBlock:^(PSCollectionViewItemLayoutAttributes *itemAttributes, NSUInteger idx, BOOL *stop) { + if (itemAttributes.valid == NO) { + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:idx inSection:section]; + + //ensure we have the height for this item + CGFloat height = itemAttributes.frame.size.height; + if (height == 0.0f) { + height = [self.collectionViewDataSource collectionView:self heightForViewAtIndexPath:indexPath]; + } + + //find the shortest column + NSUInteger shortestColumn = [self shortestColumnInSection:section]; + CGFloat colXOffset = [_colXOffsets[shortestColumn] floatValue]; + CGFloat yOffset = [self yOffsetForItemInSection:section column:shortestColumn]; + CGRect frame = CGRectMake(colXOffset, yOffset, self.colWidth, height); + itemAttributes.frame = frame; + itemAttributes.currentColumn = shortestColumn; + itemAttributes.valid = YES; + + //update the column heights + [self updateHeightOfColumn:shortestColumn inSection:section withAdditionalHeight:height + self.margin]; + recalculateContentSize = YES; + + if (self.animateLayoutChanges && itemAttributes.previouslyVisible && itemAttributes.visibleCell) { + [UIView animateWithDuration:kAnimationDuration animations:^{ + itemAttributes.visibleCell.frame = itemAttributes.frame; + }]; + } else { + [UIView setAnimationsEnabled:NO]; + itemAttributes.visibleCell.frame = itemAttributes.frame; + [UIView setAnimationsEnabled:YES]; + } + } + }]; + }; + + + //Lays out items that are now visible and hides cells that are no longer visible + CGRect visibleRect = CGRectMake(self.contentOffset.x, self.contentOffset.y, self.width, self.height); + [_sectionItems enumerateKeysAndObjectsUsingBlock:^(NSNumber *sectionNumber, NSArray *sectionItems, BOOL *stop) { + for (NSUInteger i = 0; i < [sectionItems count]; i++) { + PSCollectionViewItemLayoutAttributes *itemAttributes = sectionItems[i]; + BOOL visibleCell = CGRectIntersectsRect(visibleRect, itemAttributes.frame); + if (visibleCell == NO && itemAttributes.visibleCell) { + //Cell isn't visible, hide it + [self enqueueReusableView:itemAttributes.visibleCell]; + itemAttributes.visibleCell = nil; + } else if (visibleCell && itemAttributes.visibleCell == nil) { + //Cell is now visible, add it in + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:[sectionNumber integerValue]]; + PSCollectionViewCell *newCell = [self.collectionViewDataSource collectionView:self viewAtIndexPath:indexPath]; + itemAttributes.visibleCell = newCell; + [self addSubview:newCell]; + + if (self.animateLayoutChanges && itemAttributes.previouslyVisible == NO) { + itemAttributes.previouslyVisible = YES; + [UIView setAnimationsEnabled:NO]; + newCell.frame = itemAttributes.frame; + newCell.alpha = 0.0f; + [UIView setAnimationsEnabled:YES]; + [UIView animateWithDuration:kAnimationDuration delay:0.0f options:UIViewAnimationOptionAllowUserInteraction animations:^{ + newCell.alpha = 1.0f; + } completion:nil]; + } else { + [UIView setAnimationsEnabled:NO]; + newCell.frame = itemAttributes.frame; + [UIView setAnimationsEnabled:YES]; + } + } + } + }]; + + //get the height of the longest column in the last section + CGFloat availableHeight = self.height; + if (_numSections > 0) { + NSUInteger lastSection = _numSections - 1; + NSUInteger longestColumnInSection = [self longestColumnInSection:lastSection]; + CGFloat longestColumnHeight = [self yOffsetForItemInSection:lastSection column:longestColumnInSection]; + availableHeight -= longestColumnHeight; + } + if (availableHeight <= 0.0f) { + availableHeight = CGFLOAT_MAX; + } + CGSize footerSize = [self.footerView sizeThatFits:CGSizeMake(self.width, availableHeight)]; + if (self.footerView.height != footerSize.height) { + self.footerView.height = footerSize.height; + recalculateContentSize = YES; + } + + //only update content size when it is needed + if (recalculateContentSize) { + [self updateContentSizeForColumnHeightChange]; + } +} + +- (void)insertItemAtIndexPath:(NSIndexPath *)indexPath +{ + PSCollectionViewItemLayoutAttributes *attributes = [[PSCollectionViewItemLayoutAttributes alloc] init]; + + NSNumber *section = @(indexPath.section); + NSMutableArray *sectionItems = [_sectionItems objectForKey:section]; + if (sectionItems == nil) { + sectionItems = [NSMutableArray array]; + [_sectionItems setObject:sectionItems forKey:section]; + } + [sectionItems insertObject:attributes atIndex:indexPath.item]; + + [self invalidateLayoutOfItemsAfterIndexPath:indexPath]; + + //erform layout if not in a batch update + if (_batchUpdateInProgress == NO) { + [self performLayout]; + } +} + +- (void)removeItemAtIndexPath:(NSIndexPath *)indexPath +{ + NSNumber *section = @(indexPath.section); + NSMutableArray *sectionItems = [_sectionItems objectForKey:section]; + if (sectionItems) { + PSCollectionViewItemLayoutAttributes *attributes = sectionItems[indexPath.item]; + [self enqueueReusableView:attributes.visibleCell]; + + [sectionItems removeObjectAtIndex:indexPath.item]; + [self invalidateLayoutOfItemsAfterIndexPath:indexPath]; + + //perform layout if not in a batch update + if (_batchUpdateInProgress == NO) { + [self performLayout]; + } + } +} + +- (void)performBatchUpdates:(void (^)(void))updates completion:(void (^)(void))completion +{ + _batchUpdateInProgress = YES; + if (updates) { + updates(); + } + //perform layout to apply the changes + [self performLayout]; + _batchUpdateInProgress = NO; + if (completion) { + completion(); + } +} + +- (void)invalidateLayoutOfItemsAfterIndexPath:(NSIndexPath *)indexPath +{ + //invalidate all section headers for all subsequent sections + for (NSUInteger section = indexPath.section + 1; section < [_sectionHeaders count]; section++) { + PSCollectionViewSectionViewLayoutAttributes *sectionHeader = _sectionHeaders[section]; + [self invalidateSectionLayoutAttributes:sectionHeader]; + } + + [_sectionItems enumerateKeysAndObjectsUsingBlock:^(NSNumber *sectionNumber, NSMutableArray *sectionItems, BOOL *stop) { + NSInteger section = [sectionNumber integerValue]; + if (section > indexPath.section) { + //invalidate all items in this section + //the column heights don't need to be invalidated since the section as a whole will be moved + for (PSCollectionViewItemLayoutAttributes *attributes in sectionItems) { + [self invalidateItemLayoutAttributes:attributes]; + } + [self resetColumnHeightsInSection:section]; + } else if (indexPath.section == section) { + //invalidate only items after this item + for (int i=indexPath.item; i < [sectionItems count]; i++) { + PSCollectionViewItemLayoutAttributes *attributes = sectionItems[i]; + [self invalidateItemLayoutAttributes:attributes]; + } + [self resetColumnHeightsInSection:section]; + } + }]; + + //get the max Y values from the previous elements in each column (only need to get numCols number of elements) + //this does not update the content size since that will be done in performLayout once all the batched changes are applied + //calculate until all columns have been updated + NSMutableIndexSet *completedColumns = [NSMutableIndexSet indexSet]; + NSInteger i = indexPath.item - 1; + NSMutableArray *sectionItems = [_sectionItems objectForKey:@(indexPath.section)]; + CGFloat yOffsetOfSection = [self yOffsetForBeginningOfSection:indexPath.section]; + while (i >= 0) { + PSCollectionViewItemLayoutAttributes *attributes = sectionItems[i]; + if (attributes.valid && [completedColumns containsIndex:attributes.currentColumn] == NO) { + PSCollectionViewSectionViewLayoutAttributes *sectionHeader = [self sectionHeaderAttributesForSection:indexPath.section]; + + NSMutableArray *colHeights = _sectionColumnHeights[indexPath.section]; + CGFloat height = CGRectGetMaxY(attributes.frame) - yOffsetOfSection; + colHeights[attributes.currentColumn] = @(height - sectionHeader.view.height + self.margin); + [completedColumns addIndex:attributes.currentColumn]; + } + //if all columns have been updated, stop checking + if ([completedColumns count] == self.numCols) { + break; + } + + i--; + } +} + +- (void)updateContentSizeForColumnHeightChange +{ + //calculate the height of all combined sections + CGFloat totalHeight = 0.0f; + + if (self.headerView) { + totalHeight += self.headerView.height; + } + + //heights of all sections + for (NSUInteger i = 0; i < _numSections; i++) { + totalHeight += [self heightOfSection:i]; + } + + if (self.footerView) { + //position the footer view correctly + self.footerView.frame = CGRectMake(0, totalHeight, self.width, self.footerView.height); + + totalHeight += self.footerView.frame.size.height; + } + self.contentSize = CGSizeMake(self.width, totalHeight); +} + +#pragma mark - Helpers + +- (PSCollectionViewSectionViewLayoutAttributes *)sectionHeaderAttributesForSection:(NSUInteger)section +{ + if (section < [_sectionHeaders count]) { + return _sectionHeaders[section]; + } + return nil; +} + +- (CGFloat)heightOfSection:(NSUInteger)section +{ + CGFloat height = 0.0f; + + PSCollectionViewSectionViewLayoutAttributes *header = [self sectionHeaderAttributesForSection:section]; + if (header.view) { + height += header.view.height; + } + + height += [self heightOfLongestColumnInSection:section]; + + return height; +} + +- (CGFloat)yOffsetForBeginningOfSection:(NSUInteger)section +{ + //the yoffset is the height of all previous sections, plus the height of the column in the current section + CGFloat height = 0.0f; + + //collection view header + if (self.headerView) { + height += self.headerView.height; + } + + //add the heights of all previous sections + if (section > 0) { + for (NSUInteger i = 0; i < section; i++) { + height += [self heightOfSection:i]; + } + } + + return height; +} + +- (CGFloat)yOffsetForItemInSection:(NSUInteger)section column:(NSUInteger)column +{ + //the yoffset is the height of all previous sections, plus the height of the column in the current section + CGFloat height = [self yOffsetForBeginningOfSection:section]; + + //section header height + PSCollectionViewSectionViewLayoutAttributes *sectionHeaderAttributes = [self sectionHeaderAttributesForSection:section]; + if (sectionHeaderAttributes.view) { + height += sectionHeaderAttributes.view.height; + } + + NSArray *columnHeights = _sectionColumnHeights[section]; + height += [columnHeights[column] floatValue]; + return height; +} + +- (void)updateHeightOfColumn:(NSInteger)column inSection:(NSUInteger)section withAdditionalHeight:(CGFloat)height +{ + NSMutableArray *columnHeights = _sectionColumnHeights[section]; + NSNumber *currentHeight = columnHeights[column]; + columnHeights[column] = @([currentHeight floatValue] + height); +} + +- (NSUInteger)shortestColumnInSection:(NSUInteger)section +{ + NSInteger col = 0; + NSMutableArray *sectionColumnHeights = _sectionColumnHeights[section]; + CGFloat minHeight = [sectionColumnHeights[col] floatValue]; + for (int i = 1; i < [sectionColumnHeights count]; i++) { + CGFloat colHeight = [sectionColumnHeights[i] floatValue]; + if (colHeight < minHeight) { + col = i; + minHeight = colHeight; + } + } + return col; +} + +- (NSUInteger)longestColumnInSection:(NSUInteger)section +{ + NSInteger col = 0; + NSMutableArray *columnHeights = _sectionColumnHeights[section]; + CGFloat maxHeight = [columnHeights[col] floatValue]; + for (int i = 1; i < [columnHeights count]; i++) { + CGFloat colHeight = [columnHeights[i] floatValue]; + if (colHeight > maxHeight) { + col = i; + maxHeight = colHeight; + } + } + return col; +} + +- (CGFloat)heightOfLongestColumnInSection:(NSUInteger)section +{ + NSUInteger longestColumn = [self longestColumnInSection:section]; + return [_sectionColumnHeights[section][longestColumn] floatValue]; } #pragma mark - Reusing Views -- (PSCollectionViewCell *)dequeueReusableView { - PSCollectionViewCell *view = [self.reuseableViews anyObject]; - if (view) { - // Found a reusable view, remove it from the set - [view retain]; - [self.reuseableViews removeObject:view]; - [view autorelease]; - } - - return view; +- (PSCollectionViewCell *)dequeueReusableViewWithIdentifier:(NSString *)reuseIdentifier +{ + if ([reuseIdentifier length] == 0) { + return nil; + } + + NSMutableSet *reusableViewsForIdentifier = [_reusableViews objectForKey:reuseIdentifier]; + if (reusableViewsForIdentifier) { + PSCollectionViewCell *view = [reusableViewsForIdentifier anyObject]; + if (view) { + // Found a reusable view, remove it from the set + [reusableViewsForIdentifier removeObject:view]; + return view; + } + } + return nil; } -- (void)enqueueReusableView:(PSCollectionViewCell *)view { - if ([view respondsToSelector:@selector(prepareForReuse)]) { - [view performSelector:@selector(prepareForReuse)]; - } +- (void)enqueueReusableView:(PSCollectionViewCell *)view +{ + if (view == nil) { + return; + } + + [view prepareForReuse]; view.frame = CGRectZero; - [self.reuseableViews addObject:view]; + view.alpha = 1.0f; + + NSMutableSet *reusableViewsForIdentifier = [_reusableViews objectForKey:view.reuseIdentifier]; + if (reusableViewsForIdentifier == nil && [view.reuseIdentifier length] > 0) { + [_reusableViews setObject:[NSMutableSet set] forKey:view.reuseIdentifier]; + } + [reusableViewsForIdentifier addObject:view]; [view removeFromSuperview]; } #pragma mark - Gesture Recognizer -- (void)didSelectView:(UITapGestureRecognizer *)gestureRecognizer { - NSString *rectString = NSStringFromCGRect(gestureRecognizer.view.frame); - NSArray *matchingKeys = [self.indexToRectMap allKeysForObject:rectString]; - NSString *key = [matchingKeys lastObject]; - if ([gestureRecognizer.view isMemberOfClass:[[self.visibleViews objectForKey:key] class]]) { - if (self.collectionViewDelegate && [self.collectionViewDelegate respondsToSelector:@selector(collectionView:didSelectView:atIndex:)]) { - NSInteger matchingIndex = PSCollectionIndexForKey([matchingKeys lastObject]); - [self.collectionViewDelegate collectionView:self didSelectView:(PSCollectionViewCell *)gestureRecognizer.view atIndex:matchingIndex]; - } - } -} - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { - if (![gestureRecognizer isMemberOfClass:[PSCollectionViewTapGestureRecognizer class]]) return YES; - - NSString *rectString = NSStringFromCGRect(gestureRecognizer.view.frame); - NSArray *matchingKeys = [self.indexToRectMap allKeysForObject:rectString]; - NSString *key = [matchingKeys lastObject]; - - if ([touch.view isMemberOfClass:[[self.visibleViews objectForKey:key] class]]) { - return YES; - } else { - return NO; - } +- (void)didSelectView:(UITapGestureRecognizer *)gestureRecognizer +{ + CGPoint tapPoint = [gestureRecognizer locationInView:self]; + + __block PSCollectionViewItemLayoutAttributes *selectedCell = nil; + __block NSIndexPath *selectedIndexPath = nil; + [_sectionItems enumerateKeysAndObjectsUsingBlock:^(NSNumber *sectionNumber, NSArray *sectionItems, BOOL *stop) { + [sectionItems enumerateObjectsUsingBlock:^(PSCollectionViewItemLayoutAttributes *candidate, NSUInteger idx, BOOL *stop) { + if (candidate.valid && CGRectContainsPoint(candidate.frame, tapPoint)) { + selectedCell = candidate; + selectedIndexPath = [NSIndexPath indexPathForItem:idx inSection:[sectionNumber integerValue]]; + *stop = YES; + } + }]; + }]; + + PSCollectionViewCell *cell = selectedCell.visibleCell; + if (cell) { + [self.collectionViewDelegate collectionView:self didSelectView:cell atIndexPath:selectedIndexPath]; + } } @end diff --git a/PSCollectionViewCell.h b/PSCollectionViewCell.h index b37551b..24c50a3 100644 --- a/PSCollectionViewCell.h +++ b/PSCollectionViewCell.h @@ -25,7 +25,10 @@ @interface PSCollectionViewCell : UIView -@property (nonatomic, retain) id object; +@property (nonatomic, strong) id object; +@property (nonatomic, readonly, copy) NSString *reuseIdentifier; + +- (id)initWithFrame:(CGRect)frame reuseIdentifier:(NSString *)reuseIdentifier; - (void)prepareForReuse; - (void)fillViewWithObject:(id)object; diff --git a/PSCollectionViewCell.m b/PSCollectionViewCell.m index 4ffb17f..118dc80 100644 --- a/PSCollectionViewCell.m +++ b/PSCollectionViewCell.m @@ -23,35 +23,29 @@ #import "PSCollectionViewCell.h" -@interface PSCollectionViewCell () - -@end - @implementation PSCollectionViewCell -@synthesize -object = _object; - -- (id)initWithFrame:(CGRect)frame { - self = [super initWithFrame:frame]; - if (self) { - } - return self; -} - -- (void)dealloc { - self.object = nil; - [super dealloc]; +- (id)initWithFrame:(CGRect)frame reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithFrame:frame]; + if (self) { + _reuseIdentifier = [reuseIdentifier copy]; + } + return self; } -- (void)prepareForReuse { +- (void)prepareForReuse +{ + self.object = nil; } -- (void)fillViewWithObject:(id)object { +- (void)fillViewWithObject:(id)object +{ self.object = object; } -+ (CGFloat)heightForViewWithObject:(id)object inColumnWidth:(CGFloat)columnWidth { ++ (CGFloat)heightForViewWithObject:(id)object inColumnWidth:(CGFloat)columnWidth +{ return 0.0; } diff --git a/PSCollectionViewItemLayoutAttributes.h b/PSCollectionViewItemLayoutAttributes.h new file mode 100644 index 0000000..10854b7 --- /dev/null +++ b/PSCollectionViewItemLayoutAttributes.h @@ -0,0 +1,15 @@ +// +// PSCollectionViewItemLayoutAttributes.h +// ShopByShopify +// +// Created by Adam Becevello on 2012-12-20. +// Copyright (c) 2012 Shopify Inc. All rights reserved. +// + +#import "PSCollectionViewLayoutAttributes.h" + +@interface PSCollectionViewItemLayoutAttributes : PSCollectionViewLayoutAttributes + +@property (nonatomic, strong) PSCollectionViewCell *visibleCell; + +@end diff --git a/PSCollectionViewItemLayoutAttributes.m b/PSCollectionViewItemLayoutAttributes.m new file mode 100644 index 0000000..d8fe27e --- /dev/null +++ b/PSCollectionViewItemLayoutAttributes.m @@ -0,0 +1,13 @@ +// +// PSCollectionViewItemLayoutAttributes.m +// ShopByShopify +// +// Created by Adam Becevello on 2012-12-20. +// Copyright (c) 2012 Shopify Inc. All rights reserved. +// + +#import "PSCollectionViewItemLayoutAttributes.h" + +@implementation PSCollectionViewItemLayoutAttributes + +@end diff --git a/PSCollectionViewLayoutAttributes.h b/PSCollectionViewLayoutAttributes.h new file mode 100644 index 0000000..c26164d --- /dev/null +++ b/PSCollectionViewLayoutAttributes.h @@ -0,0 +1,19 @@ +// +// PSCollectionViewLayoutAttributes.h +// ShopByShopify +// +// Created by Adam Becevello on 2012-12-14. +// Copyright (c) 2012 Shopify Inc. All rights reserved. +// + +#import +#import "PSCollectionViewCell.h" + +@interface PSCollectionViewLayoutAttributes : NSObject + +@property (nonatomic, assign) CGRect frame; +@property (nonatomic, assign) NSUInteger currentColumn; +@property (nonatomic, assign) BOOL valid; +@property (nonatomic, assign) BOOL previouslyVisible; + +@end diff --git a/PSCollectionViewLayoutAttributes.m b/PSCollectionViewLayoutAttributes.m new file mode 100644 index 0000000..1efa726 --- /dev/null +++ b/PSCollectionViewLayoutAttributes.m @@ -0,0 +1,25 @@ +// +// PSCollectionViewLayoutAttributes.m +// ShopByShopify +// +// Created by Adam Becevello on 2012-12-14. +// Copyright (c) 2012 Shopify Inc. All rights reserved. +// + +#import "PSCollectionViewLayoutAttributes.h" + +@implementation PSCollectionViewLayoutAttributes + +- (id)init +{ + self = [super init]; + if (self) { + self.frame = CGRectZero; + self.currentColumn = 0; + self.valid = NO; + self.previouslyVisible = NO; + } + return self; +} + +@end diff --git a/PSCollectionViewSectionViewLayoutAttributes.h b/PSCollectionViewSectionViewLayoutAttributes.h new file mode 100644 index 0000000..21007aa --- /dev/null +++ b/PSCollectionViewSectionViewLayoutAttributes.h @@ -0,0 +1,15 @@ +// +// PSCollectionViewSectionViewLayoutAttributes.h +// ShopByShopify +// +// Created by Adam Becevello on 2012-12-20. +// Copyright (c) 2012 Shopify Inc. All rights reserved. +// + +#import "PSCollectionViewLayoutAttributes.h" + +@interface PSCollectionViewSectionViewLayoutAttributes : PSCollectionViewLayoutAttributes + +@property (nonatomic, readwrite, strong) UIView *view; + +@end diff --git a/PSCollectionViewSectionViewLayoutAttributes.m b/PSCollectionViewSectionViewLayoutAttributes.m new file mode 100644 index 0000000..ac5df23 --- /dev/null +++ b/PSCollectionViewSectionViewLayoutAttributes.m @@ -0,0 +1,13 @@ +// +// PSCollectionViewSectionViewLayoutAttributes.m +// ShopByShopify +// +// Created by Adam Becevello on 2012-12-20. +// Copyright (c) 2012 Shopify Inc. All rights reserved. +// + +#import "PSCollectionViewSectionViewLayoutAttributes.h" + +@implementation PSCollectionViewSectionViewLayoutAttributes + +@end diff --git a/README.md b/README.md index fdb80b9..af0ecfa 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,15 @@ It's a Pinterest style scroll view designed to be used similar to a UITableView. It supports Portrait and Landscape orientations. -I built this as a hack to show my friends. Any suggestions or improvements are very welcome! +This version is an almost complete rewrite of the layout code to support additional features such as: + 1. Multiple sections + 2. Section headers and footers + 3. Improved performance and reduced memory usage, especially during fast scrolling operations. -Coming soon... A fully functional demo app. +Due to the inclusion of multiple section support, this fork is not API compatible with the original PSCollectionView found at https://github.com/ptshih/PSCollectionView. +Thanks to Peter Shih (https://github.com/ptshih) for writing the original PSCollectionView which forms the basis of this version. + +The rest of this README is the same as the README in the original PSCollectionView repository. What is PSCollectionViewCell? --- @@ -32,9 +38,7 @@ It shows an example of using PSCollectionView and a subclass of PSCollectionView ARC --- -PSCollectionView, by default, is not ARC ready. - -However, there is an 'arc' branch that has been converted for use with ARC projects. +This version of PSCollectionView is ARC ready. How to use: --- @@ -128,4 +132,4 @@ THE SOFTWARE. Questions? --- -Feel free to send me an email if you have any questions implementing PSCollectionView! \ No newline at end of file +Feel free to send me an email if you have any questions implementing PSCollectionView!