Last active
August 29, 2015 14:22
-
-
Save jaredsinclair/fa72f0917c6a147b3c54 to your computer and use it in GitHub Desktop.
Rough-n-dirty implementation of the unusual horizontal paging used by Twitter.app's inline promoted app cards.
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
typedef NS_ENUM(NSInteger, BLVScrollDirection) { | |
// These are by the content offset, | |
// not by the direction your finger moves. | |
BLVScrollDirection_RightToLeft, | |
BLVScrollDirection_LeftToRight | |
}; | |
@interface BLVNonStandardPagingCalculation : NSObject | |
@property (nonatomic, assign) NSInteger centeredCardIndex; // Defaults to NSNotFound | |
@property (nonatomic, assign) CGPoint targetContentOffset; | |
+ (instancetype)adjustedTargetContentOffsetForCollectionView:(UICollectionView *)collectionView | |
targetContentOffset:(CGPoint)targetContentOffset | |
cardWidth:(CGFloat)cardWidth | |
spaceBetweenCards:(CGFloat)spaceBetweenCards | |
numberOfCards:(NSInteger)numberOfCards | |
previouslyCenteredCardIndex:(NSInteger)previouslyCenteredCardIndex | |
scrollDirection:(BLVScrollDirection)scrollDirection; | |
@end | |
@implementation BLVNonStandardPagingCalculation | |
+ (BLVNonStandardPagingCalculation *)adjustedTargetContentOffsetForCollectionView:(UICollectionView *)collectionView targetContentOffset:(CGPoint)targetContentOffset cardWidth:(CGFloat)cardWidth spaceBetweenCards:(CGFloat)spaceBetweenCards numberOfCards:(NSInteger)numberOfCards previouslyCenteredCardIndex:(NSInteger)previouslyCenteredCardIndex scrollDirection:(BLVScrollDirection)scrollDirection { | |
// Scroll view paging must be *disabled* for this to work. You invoke | |
// this method from inside: | |
// | |
// scrollViewWillEndDragging: withVelocity: targetContentOffset: | |
// | |
// and set the target content offset to the result of the calculation | |
// below. | |
// | |
// Also, it feels better if you also increase the decelerationRate | |
// to UIScrollViewDecelerationRateFast, which will approximate the | |
// feel of a scroll view with paging enabled. | |
CGRect targetRect = collectionView.bounds; | |
targetRect.origin.x = targetContentOffset.x; | |
NSArray *allAttributes = [collectionView.collectionViewLayout | |
layoutAttributesForElementsInRect:targetRect]; | |
BOOL rightToLeft = (scrollDirection == BLVScrollDirection_RightToLeft); | |
CGFloat visibleWidth = collectionView.bounds.size.width; | |
CGFloat contentWidth = collectionView.contentSize.width; | |
CGFloat currentOffset = collectionView.contentOffset.x; | |
CGPoint currentVisibleCenter = collectionView.contentOffset; | |
currentVisibleCenter.x += visibleWidth/2.0f; | |
CGPoint targetVisibleCenter = targetContentOffset; | |
targetVisibleCenter.x += visibleWidth/2.0f; | |
NSInteger firstPage = 0; | |
NSInteger numberOfPages = numberOfCards; | |
NSInteger lastPage = MAX(0, numberOfPages-1); | |
BOOL multipleCardsPerPage = (visibleWidth > cardWidth * 2.0f); | |
UIEdgeInsets contentInset = collectionView.contentInset; | |
CGFloat cardMargin = spaceBetweenCards; | |
BOOL isFullyScrolledRight = (currentOffset + visibleWidth >= contentWidth + contentInset.right); | |
BOOL isFullyScrolledLeft = (currentOffset <= 0-contentInset.left); | |
BOOL isFullyScrolled = (isFullyScrolledRight || isFullyScrolledLeft); | |
/* | |
Now that we've set up all the convenience local variables, | |
we need to find the optimal card to advance to. We want the | |
leftmost card to be left-aligned, the right-most card to be | |
right-aligned, and all other cards to come to rest in a | |
horizontally-centered position, relative to the collection view | |
as a whole. | |
If we're not fully scrolled, we proceed by finding out which card's | |
frame will roughly contain the visible center point by the time | |
deceleration has finished. We optimize the card selection by forcing | |
the scroll view to advance one card further than it otherwise might | |
if the naively-selected target card is the same as the currently- | |
centered card. | |
*/ | |
UICollectionViewLayoutAttributes *targetAttributes; | |
if (isFullyScrolled) { | |
NSInteger targetPage = (isFullyScrolledRight) ? lastPage : firstPage; | |
NSIndexPath *targetPath; | |
targetPath = [NSIndexPath indexPathForItem:targetPage inSection:0]; | |
targetAttributes = [collectionView layoutAttributesForItemAtIndexPath:targetPath]; | |
} | |
else { | |
for (UICollectionViewLayoutAttributes *attributes in allAttributes) { | |
CGRect slightlyExpandedFrame; | |
slightlyExpandedFrame = CGRectInset(attributes.frame, -cardMargin, -cardMargin); | |
if (CGRectContainsPoint(slightlyExpandedFrame, targetVisibleCenter)) { | |
NSIndexPath *indexPath = attributes.indexPath; | |
NSInteger page = indexPath.row; | |
if (rightToLeft) { | |
if (CGRectContainsPoint(slightlyExpandedFrame, currentVisibleCenter) | |
&& page > 0 | |
&& (page == previouslyCenteredCardIndex || !multipleCardsPerPage)) { | |
// go to previous card if possible | |
NSIndexPath *targetPath; | |
targetPath = [NSIndexPath indexPathForItem:page-1 inSection:0]; | |
targetAttributes = [collectionView layoutAttributesForItemAtIndexPath:targetPath]; | |
break; | |
} else { | |
targetAttributes = attributes; | |
break; | |
} | |
} | |
else /* leftToRight */ { | |
if (CGRectContainsPoint(slightlyExpandedFrame, currentVisibleCenter) | |
&& page < lastPage | |
&& (page == previouslyCenteredCardIndex || !multipleCardsPerPage)) { | |
// go to next card if possible | |
NSIndexPath *targetPath; | |
targetPath = [NSIndexPath indexPathForItem:page+1 inSection:0]; | |
targetAttributes = [collectionView layoutAttributesForItemAtIndexPath:targetPath]; | |
break; | |
} else { | |
targetAttributes = attributes; | |
break; | |
} | |
} | |
} | |
} | |
} | |
BLVNonStandardPagingCalculation *result; | |
result = [BLVNonStandardPagingCalculation new]; | |
if (targetAttributes) { | |
NSIndexPath *indexPath = targetAttributes.indexPath; | |
NSInteger page = indexPath.row; | |
if (page == firstPage) { | |
result.targetContentOffset = CGPointMake(0 - contentInset.left, targetContentOffset.y); | |
result.centeredCardIndex = NSNotFound; | |
} | |
else if (page == lastPage) { | |
CGFloat contentWidth = collectionView.contentSize.width; | |
CGFloat targetX = roundf(contentWidth - visibleWidth + contentInset.right); | |
result.targetContentOffset = CGPointMake(targetX, targetContentOffset.y); | |
result.centeredCardIndex = NSNotFound; | |
} | |
else { | |
CGFloat offset = roundf((visibleWidth - cardWidth)/2.0f); | |
CGFloat targetX = roundf(targetAttributes.frame.origin.x - offset); | |
result.targetContentOffset = CGPointMake(targetX, targetContentOffset.y); | |
result.centeredCardIndex = page; | |
} | |
} else { | |
result.targetContentOffset = targetContentOffset; | |
result.centeredCardIndex = NSNotFound; | |
} | |
return result; | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This turns out to be more complicated than a single utility method can handle. There's some external state that has to be tracked, as well as some other configuration:
minimumLineSpacingForSectionAtIndex:
method of UICollectionViewFlowLayout)0
).scrollViewWillEndDragging:withVelocity:targetContentOffset:
and update the target content offset with the result from this utility.I was lazy and also made you have to pass in some basic measurements about the card metrics. I could have queried this from the collection view attributes, but whatever. The current implementation works and feels good.