Skip to content

Instantly share code, notes, and snippets.

@jaredsinclair
Last active August 29, 2015 14:22
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jaredsinclair/fa72f0917c6a147b3c54 to your computer and use it in GitHub Desktop.
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.
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
@jaredsinclair
Copy link
Author

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:

  • Need to keep track of the current relative scroll direction (sadly, not a native property)
  • Need the index of the previously-centered card (used when optimizing the selection of which card to center next, if any). Hang onto the previously-centered card index from the previous use of this method (as long as it's still valid).
  • Every card must have the same width
  • The spacing between cards should be consistent (ideally using the minimumLineSpacingForSectionAtIndex: method of UICollectionViewFlowLayout)
  • The collection view's layout should be a single horizontal row
  • I assume that there's only one section (section index 0).
  • Paging must be disabled on the scroll view
  • The deceleration rate should be increased to a fast rate, to mimic the thick feel of a paging-enabled scroll view
  • Implement 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment