iOS 7のMessage appのようなUIを作ってみた

UICollectionView + UIKitDynamics


実装方法

UICollectionViewFlowLayoutとUIKitDynamicsを連携させます。

  1. UICollectionviewFlowLayoutのサブクラスを作成し、CollectionViewに設定する。
  2. 作成したサブクラスは、UIDyanicsAnimatorのインスタンスを持ち、LayoutAttributesに対して、DynamicBehaviorを作成し、UIDynamicsAnimatorが管理する。
  3. CollectionViewには、UIDynamicAnimatorの管理するDynamicBehaviorのLayoutAttributesオブジェクトを返すようにする。

手順

UICollectionviewFlowLayoutのサブクラスを作成する


@interface DynamicsFlowLayout : UICollectionViewFlowLayout
    @property (nonatomic) UIDynamicAnimator* animator;
    @property (nonatomic) NSMutableSet* visibleIndexPathsSet;
    @property (nonatomic) CGFloat latestDelta;
@end

  • 作成したLayoutクラスにダイナミックアニメーターのインスタンスを保持する

- (id)init
{
    self = [super init];
    if (nil == self) {
        return nil;
    }
    
    // Create dynamic animator
    self.animator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self];
    self.visibleIndexPathsSet = [NSMutableSet set];
    
    // Configure self
    self.itemSize = CGSizeMake(310, 60);
    self.minimumInteritemSpacing = 1;
    self.minimumLineSpacing = 5;
    
    return self;
}


prepareLayoutメソッドをオーバーライドしUIAttachmentBehaviorを作成する

  • 画面に表示されなくなったbehaiviorを削除する

- (void)prepareLayout
{
    // Invoke super
    [super prepareLayout];
    
    // Need to overflow our actual visible rect slightly to avoid flickering.
    // Get visible rect
    CGRect  originalRect = (CGRect){.origin = self.collectionView.bounds.origin, .size = self.collectionView.frame.size};
    CGRect  visibleRect = CGRectInset(originalRect, -100, -100);
    
    // Get attributes in visible rect
    NSArray*    itemsInVisibleRectArray;
    itemsInVisibleRectArray = [super layoutAttributesForElementsInRect:visibleRect];
    
    // Get their index paths
    NSSet   *itemsIndexPathsInVisibleRectSet;
    itemsIndexPathsInVisibleRectSet = [NSSet setWithArray:[itemsInVisibleRectArray valueForKey:@"indexPath"]];
    
    
    //
    // Step 1: Remove any behaviours that are no longer visible.
    //
    NSPredicate*    predicate;
    predicate = [NSPredicate predicateWithBlock:^BOOL(UIAttachmentBehavior* behaviour, NSDictionary* bindings){
        BOOL    currentlyVisible = ([itemsIndexPathsInVisibleRectSet member:[[[behaviour items] firstObject] indexPath]] != nil);
        return !currentlyVisible;
    }];
    NSArray*    noLongerVisibleBehaviours;
    noLongerVisibleBehaviours = [self.animator.behaviors filteredArrayUsingPredicate:predicate];
    [noLongerVisibleBehaviours enumerateObjectsUsingBlock:^(id obj, NSUInteger index, BOOL *stop){
        [self.animator removeBehavior:obj];
        [self.visibleIndexPathsSet removeObject:[[[obj items] firstObject] indexPath]];
    }];
    
                                …中略…
} 

  • 新しく画面に表示されるattributeから、behaviourを作成する

//
    // Step 2: Add any newly visible behaviours.
    // A "newly visible" item is one that is in the itemsInVisibleRect(Set|Array) but not in the visibleIndexPathsSet
    predicate = [NSPredicate predicateWithBlock:^BOOL(UICollectionViewLayoutAttributes* item, NSDictionary* bindings){
        BOOL    currentlyVisible = [self.visibleIndexPathsSet member:item.indexPath] != nil;
        return !currentlyVisible;
    }];
    NSArray*    newlyVisibleItems;
    newlyVisibleItems = [itemsInVisibleRectArray filteredArrayUsingPredicate:predicate];
    
    
    // Get touch location
    CGPoint touchLocation;
    touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];
    
    
    [newlyVisibleItems enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes* item, NSUInteger idx, BOOL *stop){
        
        // Get center
        CGPoint center = item.center;
        
        // Create attachment behavior
        UIAttachmentBehavior *springBehaviour = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:center];
        
        // Configure behaviour
        springBehaviour.length = 0.0f;
        springBehaviour.damping = 0.8f;
        springBehaviour.frequency = 1.0f;
        
        if (!CGPointEqualToPoint(CGPointZero, touchLocation)) {
            CGFloat yDistanceFromTouch;
            CGFloat xDistanceFromTouch;
            CGFloat scrollResistance;
            yDistanceFromTouch = fabsf(touchLocation.y - springBehaviour.anchorPoint.y);
            xDistanceFromTouch = fabsf(touchLocation.x - springBehaviour.anchorPoint.x);
            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.animator addBehavior:springBehaviour];
        [self.visibleIndexPathsSet addObject:item.indexPath];
    }];

  • layoutAttributesForElementsInRect:メソッドをオーバーライドし、dynamic animatorのitemsInRect:メソッドを呼び、dynamic animatorに追加されているattachment behaviorのlayout attributesを返す

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

  • layoutAttributesForItemAtIndexPath:メソッドをオーバーライドし、dynamic animatorのlayoutAttributesForCellAtIndexPath:メソッドを呼び、indexPathで指定されたlayout attributesを返す

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

  • shouldInvalidateLayoutForBoundsChange:メソッドをオーバーライドし、layout attributeの位置を更新する

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds{
    // Calc content offset's y
    UIScrollView*   scrollView;
    CGFloat         delta;
    scrollView = self.collectionView;
    delta = newBounds.origin.y - scrollView.bounds.origin.y;
    
    self.latestDelta = delta;
    
    // Get touch location
    CGPoint touchLocation;
    touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];
    
    [self.animator.behaviors enumerateObjectsUsingBlock:^(UIAttachmentBehavior *springBehaviour, NSUInteger idx, BOOL * stop){
        // Calc distancez
        CGFloat yDistanceFromTouch;
        CGFloat xDistanceFromTouch;
        CGFloat scrollResistance;
        yDistanceFromTouch = fabsf(touchLocation.y - springBehaviour.anchorPoint.y);
        xDistanceFromTouch = fabsf(touchLocation.x - springBehaviour.anchorPoint.x);
        scrollResistance = (yDistanceFromTouch + xDistanceFromTouch) / 1500.0f;
        
        // Get layout attribute
        UICollectionViewLayoutAttributes*   item;
        item = springBehaviour.items.firstObject;
        
        // Decide layout attribute center
        CGPoint center = item.center;
        if (delta < 0) {
            center.y += MAX(delta, delta * scrollResistance);
        }
        else {
            center.y += MIN(delta, delta * scrollResistance);
        }
        item.center = center;
        
        // Update dynamic item
        [self.animator updateItemUsingCurrentState:item];
    }];
    
    return NO;
}

以上でiOS 7のMessage appのようなUIが実現できます。



パフォーマンスについて

prepareLayout:で画面の外に消えたattributesのdynamic behaviorをanimatorからremoveし、 dynamic behaviorを追加する際は、新しく画面に表示される分だけ追加するようにすることで パフォーマンスを考慮した実装となっている。参考情報がわかりやすいのです。


所感

UIDynamicsAnimatorがUICollectionViewLayoutとシームレスに連携できるようになっていてすごい。


参考情報

obj io
UICollectionView-Spring-Demo