13

I've been replicating the 2013 WWDC Session 217 "Exploring Scroll Views on iOS 7". I'm using Xcode 7 beta 2 and my project is iOS 9 only.

I’m trying to use a UIDynamicAnimator with my UICollectionViewLayout in a way similar to the one presented in session 217 to imitate Messages.app feel. My UICollectionViewLayout is a custom one and for some reason my cells seem to bounce in circular motion in my project.

This is my custom layout code.

// Didn't write this code myself, but should be pretty simple to follow. @Goles  


#import "VVSpringCollectionViewFlowLayout.h"  

@interface VVSpringCollectionViewFlowLayout()  
@property (nonatomic, strong) UIDynamicAnimator *animator;  
@end  

@implementation VVSpringCollectionViewFlowLayout  

-(id)init {  
    if (self = [super init]) {  
        _springDamping = 0.5;  
        _springFrequency = 0.8;  
        _resistanceFactor = 500;  
    }  
    return self;  
}  

- (id)initWithCoder:(nonnull NSCoder *)aDecoder {  
  self = [super initWithCoder:aDecoder];  
  if (self) {  
        _springDamping = 0.5;  
        _springFrequency = 0.8;  
        _resistanceFactor = 500;  
  }  
  return self;  
}  

-(void)prepareLayout {  
    [super prepareLayout];  

    if (!_animator) {  
        _animator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self];  
        CGSize contentSize = [self collectionViewContentSize];  
        NSArray *items = [super layoutAttributesForElementsInRect:CGRectMake(0, 0, contentSize.width, contentSize.height)];  

        for (UICollectionViewLayoutAttributes *item in items) {  
            UIAttachmentBehavior *spring = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:item.center];  

            spring.length = 0;  
            spring.damping = self.springDamping;  
            spring.frequency = self.springFrequency;  

            [_animator addBehavior:spring];  
        }  
    }  
}  

-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {  
    return [_animator itemsInRect:rect];  
}  

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {  
    return [_animator layoutAttributesForCellAtIndexPath:indexPath];  
}  

-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {  
    UIScrollView *scrollView = self.collectionView;  
    CGFloat scrollDelta = newBounds.origin.y - scrollView.bounds.origin.y;  
    CGPoint touchLocation = [scrollView.panGestureRecognizer locationInView:scrollView];  

    for (UIAttachmentBehavior *spring in _animator.behaviors) {  
       CGPoint anchorPoint = spring.anchorPoint;  
       CGFloat distanceFromTouch = fabs(touchLocation.y - anchorPoint.y);  
       CGFloat scrollResistance = distanceFromTouch / self.resistanceFactor;  

       id<UIDynamicItem> item = [spring.items firstObject];  
       CGPoint center = item.center;  

       if (scrollDelta > 0) {  
            center.y += MIN(scrollDelta, scrollDelta * scrollResistance);  
       }  

       item.center = center;  
       [_animator updateItemUsingCurrentState:item];  
    }  
    return NO;  
}  

@end  

What could be going on here that's causing this circular motion? I'm only changing the Y axis property of my UIAttachmentAttributes center.

center.y += MIN(scrollDelta, scrollDelta * scrollResistance);  

What am I missing here? (tried this exact layout in other project and seems to work).

EDIT:

I uploaded a sample project(removed), the Custom Collection View Layout Class is called VVSpringCollectionViewFlowLayout.m, haven't had much time to look into this too much myself since I've had a lot to do at work lately.

When the sample project runs (Xcode 7 beta or up), you'll be prompted with a slider, drag all the way to the right to visualized the Collection View Cells.

Cœur
  • 37,241
  • 25
  • 195
  • 267
Goles
  • 11,599
  • 22
  • 79
  • 140

2 Answers2

5

Code below should help you out / point you in the right direction. It has some extras in as well - like cleaning up unnecessary animator behaviours if not in view, tracking touch so animator behaviour from that point. Stripped out of an old project so should just work. Github example project with demo creation video included - https://github.com/serendipityapps/SpringyCollectionView

@interface VVSpringCollectionViewFlowLayout ()

@property (nonatomic, strong) UIDynamicAnimator *dynamicAnimator;
@property (nonatomic, strong) NSMutableSet *visibleIndexPathsSet;
@property (nonatomic, assign) CGFloat latestDelta;

@end

@implementation VVSpringCollectionViewFlowLayout

- (id)init {
    if (self = [super init]) {
        self.dynamicAnimator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self];
        self.visibleIndexPathsSet = [NSMutableSet set];
    }
    return self;
}

- (id)initWithCoder:(nonnull NSCoder *)aDecoder {
    if (self = [super initWithCoder:aDecoder]) {
        self.dynamicAnimator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self];
        self.visibleIndexPathsSet = [NSMutableSet set];
    }
    return self;
}

-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
    return [self.dynamicAnimator itemsInRect:rect];
}

-(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    return [self.dynamicAnimator layoutAttributesForCellAtIndexPath:indexPath];
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForSupplementaryViewOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    return [self.dynamicAnimator layoutAttributesForSupplementaryViewOfKind:kind atIndexPath:indexPath];
}


-(void)prepareLayout {
    [super prepareLayout];

    // Need to enlarge visible rect slightly to avoid flickering.
    CGRect visibleRect = CGRectInset((CGRect){.origin = self.collectionView.bounds.origin, .size = self.collectionView.frame.size}, -100, -100);

    NSArray *itemsInVisibleRectArray = [super layoutAttributesForElementsInRect:visibleRect];

    NSArray *cells = [itemsInVisibleRectArray filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UICollectionViewLayoutAttributes *item, NSDictionary *bindings) {
        return !item.representedElementKind;
    }]];

    NSSet *itemsIndexPathsInVisibleRectSet = [NSSet setWithArray:[cells valueForKey:@"indexPath"]];

    // Remove any behaviours that are no longer visible.
    NSArray *noLongerVisibleBehavioursCells = [self.dynamicAnimator.behaviors filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UIAttachmentBehavior *behaviour, NSDictionary *bindings) {

        UICollectionViewLayoutAttributes *item= (UICollectionViewLayoutAttributes*)[[behaviour items] firstObject];
        if (!item.representedElementKind) {
            BOOL currentlyVisible = [itemsIndexPathsInVisibleRectSet member:[item indexPath]] != nil;
            return !currentlyVisible;
        }
        else {
            return NO;
        }
    }]];

    [noLongerVisibleBehavioursCells enumerateObjectsUsingBlock:^(UIAttachmentBehavior *behaviour, NSUInteger index, BOOL *stop) {
        UICollectionViewLayoutAttributes *item = (UICollectionViewLayoutAttributes*)[[behaviour items] firstObject];
        [self.dynamicAnimator removeBehavior:behaviour];
        [self.visibleIndexPathsSet removeObject:[item indexPath]];
    }];


    // Add any newly visible behaviours.
    CGPoint touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];

    // A "newly visible" item is one that is in the itemsInVisibleRect(Set|Array) but not in the visibleIndexPathsSet
    NSArray *newlyVisibleItems = [cells filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UICollectionViewLayoutAttributes *item, NSDictionary *bindings) {
        BOOL currentlyVisible = [self.visibleIndexPathsSet member:item.indexPath] != nil;
        return !currentlyVisible;
    }]];

    [newlyVisibleItems enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *item, NSUInteger idx, BOOL *stop) {
        CGPoint center = item.center;
        UIAttachmentBehavior *springBehaviour = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:center];

        springBehaviour.length = 0.0f;
        springBehaviour.damping = 0.8f;
        springBehaviour.frequency = 1.0f;

        // If our touchLocation is not (0,0), we'll need to adjust our item's center "in flight"
        if (!CGPointEqualToPoint(CGPointZero, touchLocation)) {
            CGFloat yDistanceFromTouch = fabs(touchLocation.y - springBehaviour.anchorPoint.y);
            CGFloat xDistanceFromTouch = fabs(touchLocation.x - springBehaviour.anchorPoint.x);
            CGFloat scrollResistance = (yDistanceFromTouch + xDistanceFromTouch) / 1500.0f;

            if (self.latestDelta < 0) {
                center.y += MAX(self.latestDelta, self.latestDelta*scrollResistance);
            }
            else {
                center.y += MIN(self.latestDelta, self.latestDelta*scrollResistance);
            }
            item.center = center;
        }

        [self.dynamicAnimator addBehavior:springBehaviour];
        [self.visibleIndexPathsSet addObject:item.indexPath];
    }];
}


-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {

    UIScrollView *scrollView = self.collectionView;
    CGFloat delta = newBounds.origin.y - scrollView.bounds.origin.y;

    self.latestDelta = delta;

    CGPoint touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];

    __block UIDynamicAnimator *weakDynamicAnimator = self.dynamicAnimator;

    [self.dynamicAnimator.behaviors enumerateObjectsUsingBlock:^(UIAttachmentBehavior *springBehaviour, NSUInteger idx, BOOL *stop) {

        CGFloat yDistanceFromTouch = fabs(touchLocation.y - springBehaviour.anchorPoint.y);
        CGFloat xDistanceFromTouch = fabs(touchLocation.x - springBehaviour.anchorPoint.x);
        CGFloat scrollResistance = (yDistanceFromTouch + xDistanceFromTouch) / 1500.0f;

        UICollectionViewLayoutAttributes *item = (UICollectionViewLayoutAttributes*)[springBehaviour.items firstObject];
        CGPoint center = item.center;
        if (delta < 0) {
            center.y += MAX(delta, delta*scrollResistance);
        }
        else {
            center.y += MIN(delta, delta*scrollResistance);
        }
        item.center = center;

        [weakDynamicAnimator updateItemUsingCurrentState:item];
    }];

    return NO;
}

@end

Answer for SampleCode: The Sample code is displaying this odd wobbling effect because the size of items is being generated programmatically and is not rounded - the precision from the calculation creates problems for UIDynamics and the Physics engine and it can never reach equilibrium. Simply rounding the generated item size gives the physics a chance. see NoteCollectionViewController.swift line 77.

func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
    let w = round(CellAspectRatio.width * collectionView.frame.width)
    let h = round(CellAspectRatio.height * collectionView.frame.height)
    return CGSizeMake(w, h)
}
Dave Roberts
  • 672
  • 3
  • 9
  • Hi, here is a link to a minimal project showing class working as intended, https://www.dropbox.com/s/m6wsfqbaim3xhmq/SpringyCollectionView.zip?dl=0 – Dave Roberts Jul 13 '15 at 22:20
  • The project didn't actually compile, but anyway... for some reason I'm seeing the aforementioned behavior in my project. This doesn't answer the original question... I might go ahead and publish a small sample project exposing the issue. – Goles Jul 14 '15 at 06:12
  • Hey, No idea why not compiling on your system. Here is a 5 min video of creating the project from scratch. https://www.dropbox.com/s/2ffptx2wfdbmm5g/OriginalSpringyDemo.mp4?dl=0 Maybe you will spot something. I tried several variations in my project, like ticking bounces horizontally etc, but can't reproduce your issue yet. A sample project would be great to see the issue first hand. – Dave Roberts Jul 14 '15 at 09:32
  • Hey Dave, I'll provide a sample project ;) – Goles Jul 15 '15 at 02:24
  • I added a sample project in case you want to take a look... the problem might be around the way I add the collection view in my view hierarchy... don't really know what could be going on – Goles Aug 10 '15 at 00:22
  • See amended answer - in short, round your item size to give the physics a chance to sort itself out. – Dave Roberts Aug 10 '15 at 15:10
  • Nailed it, neat bug btw ;) – Goles Aug 10 '15 at 21:22
  • Great answer. Here's the Swift version modeled after yours: https://github.com/lacyrhoades/CasualUX/blob/master/SpringyCollectionViewFlowLayout/SpringyCollectionViewFlowLayout.swift – snakeoil Jan 19 '16 at 02:04
  • 1
    @Goles i've got fixed it. Look at my example project(+CocoaPods) https://github.com/CoolCodeFactory/DynamicWaveCollectionView – Dmitrii Cooler Jul 12 '16 at 22:38
0

Well the accepted answer is mostly correct. It actually can fail again with same symptoms on iPhone + with the screen scale of 3. Pulling the center with the center at some irrational number with 1/3 or 2/3 point will again cause perpetual circular motion.

Round to an even number 2.0 * floorf((number/2.0) + 0.5) - modified from another post. This will make sure the center is whole number and not irrational. Then by pulling the center in only one dimension will make bug be gone.

ayip
  • 2,473
  • 1
  • 19
  • 30