diff --git a/Classes/AQGridView.h b/Classes/AQGridView.h index 333fbc9..d279a70 100644 --- a/Classes/AQGridView.h +++ b/Classes/AQGridView.h @@ -172,6 +172,7 @@ extern NSString * const AQGridViewSelectionDidChangeNotification; - (CGRect) rectForItemAtIndex: (NSUInteger) index; - (AQGridViewCell *) cellForItemAtIndex: (NSUInteger) index; - (NSUInteger) indexForItemAtPoint: (CGPoint) point; +- (NSUInteger) indexForCell: (AQGridViewCell *) cell; - (AQGridViewCell *) cellForItemAtPoint: (CGPoint) point; - (NSArray *) visibleCells; diff --git a/Classes/AQGridView.m b/Classes/AQGridView.m index 1a2d146..4b5fe82 100644 --- a/Classes/AQGridView.m +++ b/Classes/AQGridView.m @@ -407,11 +407,26 @@ - (void) updateContentRectWithOldMaxLocation: (CGPoint) oldMaxLocation gridSize: - (void) handleGridViewBoundsChanged: (CGRect) oldBounds toNewBounds: (CGRect) bounds { + CGSize oldGridSize = [_gridData sizeForEntireGrid]; + BOOL wasAtBottom = CGRectGetMaxY(oldBounds) == oldGridSize.height; + [_gridData gridViewDidChangeBoundsSize: bounds.size]; _flags.numColumns = [_gridData numberOfItemsPerRow]; + CGSize newGridSize = [_gridData sizeForEntireGrid]; CGPoint oldMaxLocation = CGPointMake(CGRectGetMaxX(oldBounds), CGRectGetMaxY(oldBounds)); - [self updateContentRectWithOldMaxLocation: oldMaxLocation gridSize: [_gridData sizeForEntireGrid]]; + [self updateContentRectWithOldMaxLocation: oldMaxLocation gridSize: newGridSize]; + + if ( (wasAtBottom) && (newGridSize.height > oldGridSize.height) ) + { + CGRect contentRect = self.bounds; + if ( CGRectGetMaxY(contentRect) < newGridSize.height ) + { + contentRect.origin.y += (newGridSize.height - oldGridSize.height); + self.contentOffset = contentRect.origin; + } + } + [self updateVisibleGridCellsNow]; _flags.allCellsNeedLayout = 1; } @@ -601,6 +616,15 @@ - (NSUInteger) indexForItemAtPoint: (CGPoint) point return ( [_gridData itemIndexForPoint: point] ); } +- (NSUInteger) indexForCell: (AQGridViewCell *) cell +{ + NSUInteger index = [_visibleCells indexOfObject:cell]; + if (index == NSNotFound) + return NSNotFound; + + return _visibleIndices.location + index; +} + - (AQGridViewCell *) cellForItemAtPoint: (CGPoint) point { return ( [self cellForItemAtIndex: [_gridData itemIndexForPoint: point]] ); @@ -697,6 +721,19 @@ - (void) fixCellsFromAnimation self.animatingCells = nil; _revealingIndices.length = _revealingIndices.location = 0; + NSMutableSet * removals = [[NSMutableSet alloc] init]; + for ( UIView * view in self.subviews ) + { + if ( [view isKindOfClass: [AQGridViewCell class]] == NO ) + continue; + + if ( [_visibleCells containsObject: view] == NO ) + [removals addObject: view]; + } + + [removals makeObjectsPerformSelector: @selector(removeFromSuperview)]; + [removals release]; + // update the content size/offset based on the new grid data CGPoint oldMaxLocation = CGPointMake(CGRectGetMaxX(self.bounds), CGRectGetMaxY(self.bounds)); [self updateContentRectWithOldMaxLocation: oldMaxLocation gridSize: [_gridData sizeForEntireGrid]]; @@ -719,11 +756,29 @@ - (void) endUpdateAnimations if ( _updateInfo.numberOfUpdates == 0 ) { //_reloadingSuspendedCount--; + _flags.isAnimatingUpdates = 0; + _flags.updating = 0; [_updateInfo release]; _updateInfo = nil; return; } + NSUInteger expectedItemCount = [_updateInfo numberOfItemsAfterUpdates]; + NSUInteger actualItemCount = [_dataSource numberOfItemsInGridView: self]; + if ( expectedItemCount != actualItemCount ) + { + NSUInteger numAdded = [[_updateInfo sortedInsertItems] count]; + NSUInteger numDeleted = [[_updateInfo sortedDeleteItems] count]; + + //_reloadingSuspendedCount--; + _flags.isAnimatingUpdates = 0; + _flags.updating = 0; + [_updateInfo release]; + _updateInfo = nil; + + [NSException raise: NSInternalInconsistencyException format: @"Invalid number of items in AQGridView: Started with %u, added %u, deleted %u. Expected %u items after changes, but got %u", (unsigned)_gridData.numberOfItems, (unsigned)numAdded, (unsigned)numDeleted, (unsigned)expectedItemCount, (unsigned)actualItemCount]; + } + [_updateInfo cleanupUpdateItems]; [UIView beginAnimations: @"CellUpdates" context: nil]; @@ -819,26 +874,6 @@ - (NSUInteger) indexOfSelectedItem return ( _selectedIndex ); } -- (void) selectItemAtIndex: (NSUInteger) index animated: (BOOL) animated - scrollPosition: (AQGridViewScrollPosition) scrollPosition -{ - if ( _selectedIndex != NSNotFound ) - [self deselectItemAtIndex: _selectedIndex animated: NO]; - - _selectedIndex = index; - [self scrollToItemAtIndex: index atScrollPosition: AQGridViewScrollPositionNone animated: animated]; -} - -- (void) deselectItemAtIndex: (NSUInteger) index animated: (BOOL) animated -{ - AQGridViewCell * cell = [self cellForItemAtIndex: index]; - if ( cell != nil ) - [cell setSelected: NO animated: animated]; - - if ( _selectedIndex == index ) - _selectedIndex = NSNotFound; -} - - (void) highlightItemAtIndex: (NSUInteger) index animated: (BOOL) animated scrollPosition: (AQGridViewScrollPosition) position { if ( [_highlightedIndices containsIndex: index] ) @@ -876,6 +911,11 @@ - (void) unhighlightItemAtIndex: (NSUInteger) index animated: (BOOL) animated return; [_highlightedIndices removeIndex: index]; + + // don't remove highlighting if the cell is actually the selected cell + if ( index == _selectedIndex ) + return; + AQGridViewCell * cell = [self cellForItemAtIndex: index]; if ( cell != nil ) [cell setHighlighted: NO animated: animated]; @@ -909,7 +949,7 @@ - (void) _selectItemAtIndex: (NSUInteger) index animated: (BOOL) animated return; // already selected this item if ( _selectedIndex != NSNotFound ) - [self _deselectItemAtIndex: _selectedIndex animated: animated notifyDelegate: NO]; + [self _deselectItemAtIndex: _selectedIndex animated: animated notifyDelegate: notifyDelegate]; if ( _flags.allowsSelection == 0 ) return; @@ -931,6 +971,20 @@ - (void) _selectItemAtIndex: (NSUInteger) index animated: (BOOL) animated if ( notifyDelegate && _flags.delegateDidSelectItem ) [self.delegate gridView: self didSelectItemAtIndex: index]; + + // ensure that the selected item is no longer marked as just 'highlighted' (that's an intermediary state) + [_highlightedIndices removeIndex: index]; +} + +- (void) selectItemAtIndex: (NSUInteger) index animated: (BOOL) animated + scrollPosition: (AQGridViewScrollPosition) scrollPosition +{ + [self _selectItemAtIndex: index animated: animated scrollPosition: scrollPosition notifyDelegate: NO]; +} + +- (void) deselectItemAtIndex: (NSUInteger) index animated: (BOOL) animated +{ + [self _deselectItemAtIndex: index animated: animated notifyDelegate: NO]; } #pragma mark - @@ -1029,6 +1083,43 @@ - (void) _userSelectItemAtIndex: (NSNumber *) indexNum _pendingSelectionIndex = NSNotFound; } +- (BOOL) _gestureRecognizerIsHandlingTouches: (NSSet *) touches +{ + // see if the touch is (possibly) being tracked by a gesture recognizer + for ( id recognizer in self.gestureRecognizers ) + { + switch ( [recognizer state] ) + { + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: + case UIGestureRecognizerStateFailed: + continue; + + default: + break; + } + + if ( [recognizer numberOfTouches] == [touches count] ) + { + // simple version: + // pick a touch from our event's set, and see if it's in the recognizer's set + UITouch * touch = [touches anyObject]; + CGPoint touchLocation = [touch locationInView: self]; + + for ( NSUInteger i = 0; i < [recognizer numberOfTouches]; i++ ) + { + CGPoint test = [recognizer locationOfTouch: i inView: self]; + if ( CGPointEqualToPoint(test, touchLocation) ) + { + return ( YES ); + } + } + } + } + + return ( NO ); +} + - (void) touchesBegan: (NSSet *) touches withEvent: (UIEvent *) event { _flags.ignoreTouchSelect = ([self isDragging] ? 1 : 0); @@ -1080,12 +1171,20 @@ - (void) touchesMoved: (NSSet *) touches withEvent: (UIEvent *) event { if ( _flags.ignoreTouchSelect == 0 ) { + Class cls = NSClassFromString(@"UILongPressGestureRecognizer"); + if ( (cls != Nil) && ([cls instancesRespondToSelector: @selector(setNumberOfTouchesRequired:)]) ) + { + if ( [self _gestureRecognizerIsHandlingTouches: touches] ) + goto passToSuper; // I feel all icky now + } + //[self _cancelContentTouchUsingEvent: event forced: NO]; [self highlightItemAtIndex: NSNotFound animated: NO scrollPosition: AQGridViewScrollPositionNone]; _flags.ignoreTouchSelect = 1; _touchedContentView = nil; } +passToSuper: [super touchesMoved: touches withEvent: event]; } @@ -1180,6 +1279,9 @@ - (void) updateVisibleGridCellsNow if ( _reloadingSuspendedCount > 0 ) return; + if ( _flags.isAnimatingUpdates || _flags.updating ) + return; + _reloadingSuspendedCount++; NSIndexSet * newVisibleIndices = [_gridData indicesOfCellsInRect: [self gridViewVisibleBounds]]; @@ -1456,8 +1558,8 @@ - (void) viewWillRotateToInterfaceOrientation: (UIInterfaceOrientation) orientat // to avoid cell pop-in or pop-out: // if we're switching to landscape, don't update cells until after the transition. // if we're switching to portrait, update cells first. - if ( UIInterfaceOrientationIsLandscape(orientation) ) - _reloadingSuspendedCount++; + //if ( UIInterfaceOrientationIsLandscape(orientation) ) + // _reloadingSuspendedCount++; } - (void) viewDidRotate diff --git a/Classes/AQGridViewCell.h b/Classes/AQGridViewCell.h index 8020cf0..a577ff5 100644 --- a/Classes/AQGridViewCell.h +++ b/Classes/AQGridViewCell.h @@ -65,6 +65,7 @@ typedef enum { UIColor * _backgroundColor; UIColor * _separatorColor; UIColor * _selectionGlowColor; + CGFloat _selectionGlowShadowRadius; UIView * _bottomSeparatorView; UIView * _rightSeparatorView; NSTimer * _fadeTimer; @@ -103,6 +104,7 @@ typedef enum { @property (nonatomic, getter=isSelected) BOOL selected; // default is NO @property (nonatomic, getter=isHighlighted) BOOL highlighted; // default is NO @property (nonatomic, retain) UIColor * selectionGlowColor; // default is dark grey, ignored if selectionStyle != AQGridViewCellSelectionStyleGlow +@property (nonatomic) CGFloat selectionGlowShadowRadius; // default is 12.0, ignored if selectionStyle != AQGridViewCellSelectionStyleGlow // this can be overridden by subclasses to return a subview's layer to which to add the glow // the default implementation returns the contentView's layer diff --git a/Classes/AQGridViewCell.m b/Classes/AQGridViewCell.m index 06823d9..3bc609b 100644 --- a/Classes/AQGridViewCell.m +++ b/Classes/AQGridViewCell.m @@ -49,6 +49,7 @@ @implementation AQGridViewCell @synthesize contentView=_contentView, backgroundView=_backgroundView, selectedBackgroundView=_selectedBackgroundView; @synthesize reuseIdentifier=_reuseIdentifier, selectionGlowColor=_selectionGlowColor; +@synthesize selectionGlowShadowRadius=_selectionGlowShadowRadius; - (id) initWithFrame: (CGRect) frame reuseIdentifier: (NSString *) reuseIdentifier { @@ -68,6 +69,8 @@ - (id) initWithFrame: (CGRect) frame reuseIdentifier: (NSString *) reuseIdentifi _selectionColorInfo = CFDictionaryCreateMutable( kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks ); self.backgroundColor = [UIColor whiteColor]; + _selectionGlowShadowRadius = 12.0f; + return ( self ); } @@ -256,10 +259,16 @@ - (void) highlightSubviewsOfView: (UIView *) aView CFDictionarySetValue( _selectionColorInfo, view, info ); } - id value = [view valueForKey: @"highlighted"]; - if ( value == nil ) - value = [NSNumber numberWithBool: NO]; - [info setObject: value forKey: @"highlighted"]; + // don't overwrite any prior cache of a view's original highlighted state. + // this is because 'highlighted' will be set, then 'selected', which can perform 'highlight' again before the animation completes + if ( [info objectForKey: @"highlighted"] == nil ) + { + id value = [view valueForKey: @"highlighted"]; + if ( value == nil ) + value = [NSNumber numberWithBool: NO]; + [info setObject: value forKey: @"highlighted"]; + } + [view setValue: [NSNumber numberWithBool: YES] forKey: @"highlighted"]; } @@ -340,12 +349,6 @@ - (void) _beginBackgroundHighlight: (BOOL) highlightOn animated: (BOOL) animated } else { - [UIView setAnimationsEnabled: NO]; - // find all non-opaque subviews and make opaque again, with original background colors - [self makeSubviewsOfViewOpaqueAgain: self]; - [UIView setAnimationsEnabled: YES]; - - // now we're animating once more _selectedBackgroundView.alpha = 0.0; } @@ -392,6 +395,11 @@ - (void) highlightAnimationStopped: (NSString * __unused) animationID context: ( } else { + [UIView setAnimationsEnabled: NO]; + // find all non-opaque subviews and make opaque again, with original background colors + [self makeSubviewsOfViewOpaqueAgain: self]; + [UIView setAnimationsEnabled: YES]; + _cellFlags.highlighted = 0; [_selectedBackgroundView removeFromSuperview]; CFDictionaryRemoveAllValues( _selectionColorInfo ); @@ -457,7 +465,7 @@ - (void) setHighlighted: (BOOL) value animated: (BOOL) animated else theLayer.shadowColor = [[UIColor darkGrayColor] CGColor]; - theLayer.shadowRadius = 12.0; + theLayer.shadowRadius = self.selectionGlowShadowRadius; // add or remove the 'shadow' as appropriate if ( value ) diff --git a/Classes/AQGridViewData.m b/Classes/AQGridViewData.m index 7c418d5..cf0ed71 100644 --- a/Classes/AQGridViewData.m +++ b/Classes/AQGridViewData.m @@ -65,6 +65,8 @@ - (id) copyWithZone: (NSZone *) zone theCopy->_layoutDirection = _layoutDirection; theCopy->_topPadding = _topPadding; theCopy->_bottomPadding = _bottomPadding; + theCopy->_leftPadding = _leftPadding; + theCopy->_rightPadding = _rightPadding; theCopy->_numberOfItems = _numberOfItems; theCopy->_reorderedIndex = _reorderedIndex; return ( theCopy ); @@ -96,7 +98,11 @@ - (NSUInteger) itemIndexForPoint: (CGPoint) point NSUInteger x = (NSUInteger)floorf(point.x); NSUInteger col = x / (NSUInteger)_actualCellSize.width; - return ( (row * [self numberOfItemsPerRow]) + col ); + NSUInteger result = (row * [self numberOfItemsPerRow]) + col; + if ( result >= self.numberOfItems ) + result = NSNotFound; + + return ( result ); } - (CGRect) cellRectForPoint: (CGPoint) point @@ -145,8 +151,11 @@ - (CGSize) sizeForEntireGrid if ( _numberOfItems % numPerRow != 0 ) numRows++; - return ( CGSizeMake(((CGFloat)ceilf(_actualCellSize.width * numPerRow)) + _leftPadding + _rightPadding, - ((CGFloat)ceilf((CGFloat)numRows * _actualCellSize.height)) + _topPadding + _bottomPadding) ); + CGFloat height = ( ((CGFloat)ceilf((CGFloat)numRows * _actualCellSize.height)) + _topPadding + _bottomPadding ); + if (height < _gridView.bounds.size.height) + height = _gridView.bounds.size.height + 1; + + return ( CGSizeMake(((CGFloat)ceilf(_actualCellSize.width * numPerRow)) + _leftPadding + _rightPadding, height) ); } - (NSUInteger) numberOfItemsPerRow diff --git a/Classes/AQGridViewUpdateInfo.h b/Classes/AQGridViewUpdateInfo.h index 81d44a1..61995d9 100644 --- a/Classes/AQGridViewUpdateInfo.h +++ b/Classes/AQGridViewUpdateInfo.h @@ -98,6 +98,7 @@ - (NSArray *) sortedReloadItems; - (AQGridViewData *) newGridViewData; +- (NSUInteger) numberOfItemsAfterUpdates; - (NSUInteger) newIndexForOldIndex: (NSUInteger) oldIndex; diff --git a/Classes/AQGridViewUpdateInfo.m b/Classes/AQGridViewUpdateInfo.m index cdf92eb..beb63e0 100644 --- a/Classes/AQGridViewUpdateInfo.m +++ b/Classes/AQGridViewUpdateInfo.m @@ -239,7 +239,7 @@ - (void) updateNewGridDataAndCreateMappingTables } - (void) cleanupUpdateItems -{ +{ // sort the lists in ascending order [_insertItems sortUsingSelector: @selector(inverseCompare:)]; [_deleteItems sortUsingSelector: @selector(inverseCompare:)]; @@ -349,6 +349,11 @@ - (AQGridViewData *) newGridViewData return ( [[_newGridData retain] autorelease] ); } +- (NSUInteger) numberOfItemsAfterUpdates +{ + return ( _newGridData.numberOfItems + [_insertItems count] - [_deleteItems count] ); +} + - (UIImageView *) _imageViewForView: (UIView *) view { UIGraphicsBeginImageContext(view.bounds.size); diff --git a/Examples/SpringBoard/Classes/SpringBoardViewController.m b/Examples/SpringBoard/Classes/SpringBoardViewController.m index bc54447..e2fea30 100644 --- a/Examples/SpringBoard/Classes/SpringBoardViewController.m +++ b/Examples/SpringBoard/Classes/SpringBoardViewController.m @@ -199,6 +199,11 @@ - (void) moveActionGestureRecognizerStateChanged: (UIGestureRecognizer *) recogn { CGPoint p = [recognizer locationInView: _gridView]; NSUInteger index = [_gridView indexForItemAtPoint: p]; + if ( index == NSNotFound ) + { + // index is the last available location + index = [_icons count] - 1; + } // update the data store id obj = [[_icons objectAtIndex: _dragOriginIndex] retain]; @@ -282,6 +287,14 @@ - (void) moveActionGestureRecognizerStateChanged: (UIGestureRecognizer *) recogn // update empty cell to follow, if necessary NSUInteger index = [_gridView indexForItemAtPoint: [recognizer locationInView: _gridView]]; + + // don't do anything if it's over an unused grid cell + if ( index == NSNotFound ) + { + // snap back to the last possible index + index = [_icons count] - 1; + } + if ( index != _emptyCellIndex ) { NSLog( @"Moving empty cell from %u to %u", _emptyCellIndex, index ); diff --git a/LICENSE b/LICENSE index e130ad6..751bed1 100644 --- a/LICENSE +++ b/LICENSE @@ -5,5 +5,5 @@ Redistribution and use in source and binary forms, with or without modification, Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -Neither the name of the nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +Neither the name of Kobo Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.textile b/README.textile index ee3fbaf..abe6c32 100644 --- a/README.textile +++ b/README.textile @@ -1,8 +1,8 @@ h1=. AQGridView -h2=. Winner of the _Best Developer Tool/Helper_ award at iPadDevCamp 2010 in San Jose +h3=. Winner of the _Best Developer Tool/Helper_ award at iPadDevCamp 2010 in San Jose -p=. Version 1.0 -- 17 April 2010 +p=. Version 1.1 -- 26 May 2010 p=. By "Jim Dovey":mailto:jimdovey@mac.com
Originally written for the "Kobo iPad Application":http://itunes.apple.com/ca/app/ebooks-by-kobo-hd/id364742849?mt=8 @@ -64,7 +64,7 @@ h2. Future Directions * *Section support*. This will need a large amount of refactoring to support moves between sections, and will need a new way of keeping track of visible cell indices (it currently uses an NSRange). * *High-performance rendering*. If cells don't need to update their content after being drawn, each row could be composited into a single view for reduced load on the CoreAnimation renderer. This would need support in the grid view for displaying these composited rows, and would also need special support in KBGridViewCell to mark individual cells as needing dynamic updates, so they could be skipped when compositing and displayed normally on top of the composited row. KVO would be used to keep track of this. NB: This would also need special handling for the 'glow' selection style (or possibly the tracking cell would always be placed on screen, regardless of its dynamism requirements). -* *Content adjustments*. There are possibly still a couple of deeply-buried bugs in the cell movement code inside *KBGridViewUpdateInfo*. These are a pain to track down, and the code in that class could possibly use some cleanup. This is also something which whould need to change a lot for section support (it makes heavy use of *NSIndexSet* right now). +* *Content adjustments*. There are possibly still a couple of deeply-buried bugs in the cell movement code inside *KBGridViewUpdateInfo*. These are a pain to track down, and the code in that class could possibly use some cleanup. This is also something which would need to change a lot for section support (it makes heavy use of *NSIndexSet* right now). h2. Known Bugs