From 255d8849e9aafeeb2a24e21f98bca623cf086f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Ekstr=C3=B6m?= Date: Mon, 18 Nov 2019 14:39:51 +0100 Subject: [PATCH] Add proper support for scrollToItemAtIndexPath:scrollPosition:animated Handling correct scrolling to items required special handling in this class, since UICollectionView does not expect layouts to have items outside the scrollable bounds. It was solved by swizzling the method and forwarding it to the layout (if the layout is the correct class). Also adds a new public API: - (void)setHorizontalOffset:(CGFloat)offset forSectionAtIndex:(NSUInteger)index animated:(BOOL)animated; This might improve AppleTV support, hopefully fixing the problem described in #17 --- JEKScrollableSectionCollectionViewLayout.h | 5 + JEKScrollableSectionCollectionViewLayout.m | 103 +++++++++++++++++++-- README.md | 2 +- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/JEKScrollableSectionCollectionViewLayout.h b/JEKScrollableSectionCollectionViewLayout.h index ef93439..4d52638 100644 --- a/JEKScrollableSectionCollectionViewLayout.h +++ b/JEKScrollableSectionCollectionViewLayout.h @@ -35,6 +35,11 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL showsSectionBackgrounds; extern NSString * const JEKCollectionElementKindSectionBackground; +/** + Allows you to programmatically scroll a section. + */ +- (void)setHorizontalOffset:(CGFloat)offset forSectionAtIndex:(NSUInteger)index animated:(BOOL)animated; + @end @interface JEKScrollViewConfiguration : NSObject diff --git a/JEKScrollableSectionCollectionViewLayout.m b/JEKScrollableSectionCollectionViewLayout.m index 1090baf..2ee1053 100644 --- a/JEKScrollableSectionCollectionViewLayout.m +++ b/JEKScrollableSectionCollectionViewLayout.m @@ -7,11 +7,12 @@ // #import "JEKScrollableSectionCollectionViewLayout.h" +#import static NSString * const JEKScrollableCollectionViewLayoutScrollViewKind = @"JEKScrollableCollectionViewLayoutScrollViewKind"; NSString * const JEKCollectionElementKindSectionBackground = @"JEKCollectionElementKindSectionBackground"; -@class JEKScrollableSectionInfo; +@class JEKScrollableSectionInfo, JEKScrollableSectionDecorationView; @interface JEKScrollableSectionDecorationViewLayoutAttributes : UICollectionViewLayoutAttributes @property (nonatomic, strong) JEKScrollableSectionInfo *section; @@ -19,6 +20,7 @@ @interface JEKScrollableSectionDecorationViewLayoutAttributes : UICollectionView @interface JEKScrollableSectionInfo : NSObject @property (nonatomic, weak) JEKScrollableSectionCollectionViewLayout *layout; +@property (nonatomic, weak) JEKScrollableSectionDecorationView *currentDecorationView; // Will be set when only when section is visible @property (nonatomic, assign) CGPoint offset; @property (nonatomic, assign) CGFloat interItemSpacing; @property (nonatomic, assign) UIEdgeInsets insets; @@ -135,7 +137,7 @@ - (void)layoutSectionsIfNeeded section.headerSize = [self headerSizeForSection:index]; section.footerSize = [self footerSizeForSection:index]; section.numberOfItems = [self.collectionView numberOfItemsInSection:index]; - section.shouldUseFlowLayout = [self shouldUseFlowLayoutInSection: index]; + section.shouldUseFlowLayout = [self shouldUseFlowLayoutInSection:index]; NSMutableArray *itemSizes = [NSMutableArray new]; for (NSInteger item = 0; item < section.numberOfItems; ++item) { CGSize itemSize = [self itemSizeForIndexPath:[NSIndexPath indexPathForItem:item inSection:section.index]]; @@ -250,6 +252,58 @@ - (UICollectionViewLayoutInvalidationContext *)invalidationContextForBoundsChang return context; } +/** + Offsets a horizontal section to accomodate for scrollToItemAtIndexPath:. + This function has to have special handling since UICollectionView doesn't expect + that there can be cells outside the scrollable bounds. + */ +- (void)scrollToItemAtIndexPath:(NSIndexPath *)targetIndexPath scrollPosition:(UICollectionViewScrollPosition)scrollPosition animated:(BOOL)animated +{ + JEKScrollableSectionInfo *section = self.sections[targetIndexPath.section]; + + // We can't offset a flow layout section horizontally + if (section.shouldUseFlowLayout) { + return; + } + + CGRect itemFrame = [section layoutAttributesForItemAtIndex:targetIndexPath.item].frame; + + CGRect targetFrame = itemFrame; + if (scrollPosition & UICollectionViewScrollPositionCenteredHorizontally) { + targetFrame.origin.x = self.collectionViewContentSize.width / 2.0 - targetFrame.size.width / 2.0; + } else if (scrollPosition & UICollectionViewScrollPositionRight) { + targetFrame.origin.x = (self.collectionViewContentSize.width - targetFrame.size.width) - 10.0; + } else if (scrollPosition & UICollectionViewScrollPositionLeft) { + targetFrame.origin.x = 10.0; + } + + CGFloat horizontalOffsetDifference = targetFrame.origin.x - itemFrame.origin.x; + CGFloat newSectionOffset = section.offset.x - horizontalOffsetDifference; + [self setHorizontalOffset:newSectionOffset forSection:section animated:animated]; +} + +- (void)setHorizontalOffset:(CGFloat)offset forSectionAtIndex:(NSUInteger)index animated:(BOOL)animated +{ + [self setHorizontalOffset:offset forSection:self.sections[index] animated:animated]; +} + +- (void)setHorizontalOffset:(CGFloat)offset forSection:(JEKScrollableSectionInfo *)section animated:(BOOL)animated +{ + self.offsetCache[@(section.index)] = @(-offset); + + // If the section is visible, we can use its scrollview to handle the animation for us + if (animated && section.currentDecorationView) { + [section.currentDecorationView.scrollView setContentOffset:CGPointMake(offset, 0) animated:YES]; + } + + // Otherwise, invalidate the layout + else { + JEKScrollableSectionLayoutInvalidationContext *invalidationContext = [JEKScrollableSectionLayoutInvalidationContext new]; + invalidationContext.invalidatedSection = section; + [self invalidateLayoutWithContext:invalidationContext]; + } +} + #define DELEGATE_RESPONDS_TO_SELECTOR(SEL) ([self.collectionView.delegate conformsToProtocol:@protocol(JEKCollectionViewDelegateScrollableSectionLayout)] &&\ [self.collectionView.delegate respondsToSelector:SEL]) #define DELEGATE (id)self.collectionView.delegate @@ -267,11 +321,7 @@ - (BOOL)shouldUseFlowLayoutInSection:(NSInteger)section - (void)scrollViewDidScroll:(UIScrollView *)scrollView { NSUInteger section = scrollView.tag; - self.offsetCache[@(section)] = @(-scrollView.contentOffset.x); - - JEKScrollableSectionLayoutInvalidationContext *invalidationContext = [JEKScrollableSectionLayoutInvalidationContext new]; - invalidationContext.invalidatedSection = self.sections[section]; - [self invalidateLayoutWithContext:invalidationContext]; + [self setHorizontalOffset:scrollView.contentOffset.x forSectionAtIndex:section animated:NO]; if (DELEGATE_RESPONDS_TO_SELECTOR(@selector(collectionView:layout:section:didScrollToOffset:))) { [DELEGATE collectionView:self.collectionView layout:self section:section didScrollToOffset:scrollView.contentOffset.x]; @@ -395,6 +445,7 @@ - (void)applyLayoutAttributes:(JEKScrollableSectionDecorationViewLayoutAttribute { [super applyLayoutAttributes:layoutAttributes]; self.section = layoutAttributes.section; + self.section.currentDecorationView = self; self.scrollView.tag = layoutAttributes.indexPath.section; [self applyScrollViewConfiguration:[self.section.layout scrollViewConfigurationForSection:layoutAttributes.indexPath.section]]; [self.scrollView setContentOffset:CGPointMake(-layoutAttributes.section.offset.x, 0.0) animated:NO]; @@ -615,6 +666,13 @@ - (JEKScrollableSectionDecorationViewLayoutAttributes *)decorationViewAttributes return intersectingAttributes; } +- (JEKScrollableSectionDecorationView *)currentDecorationView +{ + // Make sure we can't access old references to decoration views from sections that aren't visible, since + // they may be in the reuse queue and not in correct state + return _currentDecorationView.superview ? _currentDecorationView : nil; +} + @end @implementation JEKScrollViewConfiguration @@ -642,3 +700,34 @@ - (instancetype)init @end @implementation JEKScrollableSectionLayoutInvalidationContext @end + + +/** + A UICollectionView-swizzle that forwards calls to scrollToItemAtIndexPath:atScrollPosition:animated: + to the layout object. This is needed since this layout must scroll horizontal sections for these + calls to work correctly. + */ +@interface UICollectionView (JEKScrollableSectionLayoutCollectionViewLayout) @end + +@implementation UICollectionView (JEKScrollableSectionLayoutCollectionViewLayout) + ++ (void)load +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Method original = class_getInstanceMethod(self, @selector(scrollToItemAtIndexPath:atScrollPosition:animated:)); + Method hook = class_getInstanceMethod(self, @selector(jek_scrollToItemAtIndexPath:atScrollPosition:animated:)); + method_exchangeImplementations(original, hook); + }); +} + +- (void)jek_scrollToItemAtIndexPath:(NSIndexPath *)indexPath atScrollPosition:(UICollectionViewScrollPosition)scrollPosition animated:(BOOL)animated +{ + [self jek_scrollToItemAtIndexPath:indexPath atScrollPosition:scrollPosition animated:animated]; + if ([self.collectionViewLayout isKindOfClass:JEKScrollableSectionCollectionViewLayout.class]) { + JEKScrollableSectionCollectionViewLayout *layout = (JEKScrollableSectionCollectionViewLayout *)self.collectionViewLayout; + [layout scrollToItemAtIndexPath:indexPath scrollPosition:scrollPosition animated:animated]; + } +} + +@end diff --git a/README.md b/README.md index 25de9e4..cbe1ac1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ and you have to set the measurements in code. Check the example project for a fu - Properly supports inserts/deletes/moves (even between different sections) - ... since it does not create multiple `UICollectionView`s like this problem is normally solved - (almost) drop in replacement for `UICollectionViewFlowLayout` -- A simple layout object - doesn't need to subclass or modify `UICollectionView` in any way +- A simple layout object - doesn't need to subclass `UICollectionView` - ... leading to efficient reuse of cells and support for prefetching - Section background views (as optional supplementary views)