web-dev-qa-db-ja.com

UICollectionViewでレイアウトを動的に設定すると、説明できないcontentOffsetの変更が発生します

Appleのドキュメント(およびWWDC 2012で宣伝)によると、UICollectionViewのレイアウトを動的に設定し、変更をアニメーション化することも可能です。

通常、コレクションビューの作成時にレイアウトオブジェクトを指定しますが、コレクションビューのレイアウトを動的に変更することもできます。レイアウトオブジェクトは、collectionViewLayoutプロパティに格納されます。このプロパティを設定すると、変更をアニメーション化せずに、レイアウトが直接更新されます。変更をアニメーション化する場合は、代わりにsetCollectionViewLayout:animated:メソッドを呼び出す必要があります。

ただし、実際には、UICollectionViewcontentOffsetに不可解で無効な変更を加え、セルを不正確に移動させ、機能を事実上使用できなくすることがわかりました。問題を説明するために、ストーリーボードにドロップされたデフォルトのコレクションビューコントローラーに添付できる次のサンプルコードをまとめました。

#import <UIKit/UIKit.h>

@interface MyCollectionViewController : UICollectionViewController
@end

@implementation MyCollectionViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"CELL"];
    self.collectionView.collectionViewLayout = [[UICollectionViewFlowLayout alloc] init];
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return 1;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell *cell = [self.collectionView dequeueReusableCellWithReuseIdentifier:@"CELL" forIndexPath:indexPath];
    cell.backgroundColor = [UIColor whiteColor];
    return cell;
}

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSLog(@"contentOffset=(%f, %f)", self.collectionView.contentOffset.x, self.collectionView.contentOffset.y);
    [self.collectionView setCollectionViewLayout:[[UICollectionViewFlowLayout alloc] init] animated:YES];
    NSLog(@"contentOffset=(%f, %f)", self.collectionView.contentOffset.x, self.collectionView.contentOffset.y);
}

@end

コントローラーはUICollectionViewFlowLayoutにデフォルトのviewDidLoadを設定し、画面に単一のセルを表示します。セルが選択されると、コントローラーは別のデフォルトUICollectionViewFlowLayoutを作成し、animated:YESフラグを使用してコレクションビューに設定します。予想される動作は、セルが移動しないことです。ただし、実際の動作は、セルが画面外にスクロールすることです。この時点では、セルを画面上にスクロールして戻すことさえできません。

コンソールログを見ると、contentOffsetが不可解に変更されていることがわかります(私のプロジェクトでは、(0、0)から(0、205)に)。 非アニメーションの場合の解決策i.e. animated:NO)の解決策を投稿しましたが、アニメーションが必要なので、誰かに解決策や回避策があるかどうかを知りたいと思いますアニメーションケース。

補足として、カスタムレイアウトをテストし、同じ動作を取得しました。

67
Timothy Moose

私はこれまで何日も髪を引っ張っていましたが、私の状況に役立つ解決策を見つけました。私の場合、iPadの写真アプリのような折りたたみ式の写真レイアウトがあります。写真が重ねられたアルバムが表示され、アルバムをタップすると写真が展開されます。だから私は2つの別々のUICollectionViewLayoutsであり、_[self.collectionView setCollectionViewLayout:myLayout animated:YES]_でそれらの間で切り替えています。アニメーションの前にセルがジャンプするという正確な問題があり、contentOffsetであることに気付きました。 contentOffsetですべてを試しましたが、アニメーション中にジャンプしました。上記のタイラーのソリューションは機能しましたが、アニメーションをいじっていました。

それから、スクリーン上にいくつかのアルバムがあり、スクリーンをいっぱいにするのに十分ではないときにのみ起こることに気づきました。推奨されるように、私のレイアウトは-(CGSize)collectionViewContentSizeをオーバーライドします。アルバムの数が少ない場合、コレクションビューのコンテンツサイズは、ビューのコンテンツサイズよりも小さくなります。コレクションのレイアウトを切り替えると、ジャンプが発生します。

そのため、レイアウトにminHeightというプロパティを設定し、コレクションビューの親の高さに設定します。次に、-(CGSize)collectionViewContentSizeに戻る前に高さをチェックします。高さが最小高さ以上であることを確認します。

本当の解決策ではありませんが、今はうまく機能しています。コレクションビューのcontentSizeを、少なくとも含まれているビューの長さに設定してみます。

編集: UICollectionViewFlowLayoutから継承する場合、Manicaesarは簡単な回避策を追加しました:

_-(CGSize)collectionViewContentSize { //Workaround
    CGSize superSize = [super collectionViewContentSize];
    CGRect frame = self.collectionView.frame;
    return CGSizeMake(fmaxf(superSize.width, CGRectGetWidth(frame)), fmaxf(superSize.height, CGRectGetHeight(frame)));
}
_
29
tassinari

UICollectionViewLayoutにはオーバーライド可能なメソッドが含まれています targetContentOffsetForProposedContentOffset: これにより、レイアウトの変更中に適切なコンテンツオフセットを提供でき、これにより正しくアニメーション化されます。これはiOS 7.0以降で利用可能です

36
cdemiris99

私自身の質問に遅れて答えて飛び込みます。

TLLayoutTransitioning ライブラリは、iOS7のインタラクティブな移行APIを再タスクして非インタラクティブなレイアウトからレイアウトへの移行を行うことにより、この問題に対する優れたソリューションを提供します。コンテンツオフセットの問題を解決し、いくつかの機能を追加することで、setCollectionViewLayoutの代替として効果的に使用できます。

  1. アニメーション期間
  2. 30以上のイージングカーブ(Warren Mooreの AHEasingライブラリ 提供)
  3. 複数のコンテンツオフセットモード

カスタムイージング曲線は、AHEasingFunction関数として定義できます。最終コンテンツオフセットは、最小、中央、上、左、下、または右の配置オプションを使用して、1つ以上のインデックスパスに関して指定できます。

意味を確認するには、例のワークスペースで Resize demo を実行し、オプションを試してみてください。

使い方はこんな感じです。まず、TLTransitionLayoutのインスタンスを返すようにView Controllerを構成します。

- (UICollectionViewTransitionLayout *)collectionView:(UICollectionView *)collectionView transitionLayoutForOldLayout:(UICollectionViewLayout *)fromLayout newLayout:(UICollectionViewLayout *)toLayout
{
    return [[TLTransitionLayout alloc] initWithCurrentLayout:fromLayout nextLayout:toLayout];
}

次に、setCollectionViewLayoutを呼び出す代わりに、transitionToCollectionViewLayout:toLayoutカテゴリで定義されているUICollectionView-TLLayoutTransitioningを呼び出します。

UICollectionViewLayout *toLayout = ...; // the layout to transition to
CGFloat duration = 2.0;
AHEasingFunction easing = QuarticEaseInOut;
TLTransitionLayout *layout = (TLTransitionLayout *)[collectionView transitionToCollectionViewLayout:toLayout duration:duration easing:easing completion:nil];

この呼び出しは、インタラクティブなトランジションを開始し、内部で、指定された期間とイージング関数でトランジションの進行を駆動するCADisplayLinkコールバックを開始します。

次のステップでは、最終的なコンテンツオフセットを指定します。任意の値を指定できますが、UICollectionView-TLLayoutTransitioningで定義されているtoContentOffsetForLayoutメソッドは、1つ以上のインデックスパスに対するコンテンツオフセットを計算するエレガントな方法を提供します。たとえば、特定のセルを可能な限りコレクションビューの中心に近づけるには、transitionToCollectionViewLayoutの直後に次の呼び出しを行います。

NSIndexPath *indexPath = ...; // the index path of the cell to center
TLTransitionLayoutIndexPathPlacement placement = TLTransitionLayoutIndexPathPlacementCenter;
CGPoint toOffset = [collectionView toContentOffsetForLayout:layout indexPaths:@[indexPath] placement:placement];
layout.toContentOffset = toOffset;
7
Timothy Moose

この問題は私にも噛み付き、移行コードのバグのようです。私が知ることができることから、遷移前のビューレイアウトの中心に最も近いセルに焦点を合わせようとします。ただし、遷移前のビューの中心にセルが存在しない場合、遷移後のセルの中心に配置しようとします。 alwaysBounceVertical/Horizo​​ntalをYESに設定し、単一のセルでビューをロードしてからレイアウトの移行を実行すると、これは非常に明確です。

これを回避するには、レイアウトの更新をトリガーした後、特定のセル(この例では最初のセルが表示されるセル)にフォーカスするようにコレクションに明示的に指示しました。

[self.collectionView setCollectionViewLayout:[self generateNextLayout] animated:YES];

// scroll to the first visible cell
if ( 0 < self.collectionView.indexPathsForVisibleItems.count ) {
    NSIndexPath *firstVisibleIdx = [[self.collectionView indexPathsForVisibleItems] objectAtIndex:0];
    [self.collectionView scrollToItemAtIndexPath:firstVisibleIdx atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES];
}
7
tyler

簡単です。

新しいレイアウトとcollectionViewのcontentOffsetを同じアニメーションブロックでアニメーション化します。

[UIView animateWithDuration:0.3 animations:^{
                                 [self.collectionView setCollectionViewLayout:self.someLayout animated:YES completion:nil];
                                 [self.collectionView setContentOffset:CGPointMake(0, -64)];
                             } completion:nil];

self.collectionView.contentOffset定数。

5
nikolsky

Swift 3

UICollectionViewLayoutサブクラス。方法cdemiris99

override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
    return collectionView?.contentOffset ?? CGPoint.zero
}

私は自分のカスタム layout を使用して自分でテストしました

3
iWheelBuy

レイアウトからの移行時に変化しないコンテンツオフセットを単に探している場合は、カスタムレイアウトを作成し、いくつかのメソッドをオーバーライドして、古いcontentOffsetを追跡して再利用できます。

@interface CustomLayout ()

@property (nonatomic) NSValue *previousContentOffset;

@end

@implementation CustomLayout

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    CGPoint previousContentOffset = [self.previousContentOffset CGPointValue];
    CGPoint superContentOffset = [super targetContentOffsetForProposedContentOffset:proposedContentOffset];
    return self.previousContentOffset != nil ? previousContentOffset : superContentOffset ;
}

- (void)prepareForTransitionFromLayout:(UICollectionViewLayout *)oldLayout
{
    self.previousContentOffset = [NSValue valueWithCGPoint:self.collectionView.contentOffset];
    return [super prepareForTransitionFromLayout:oldLayout];
}

- (void)finalizeLayoutTransition
{
    self.previousContentOffset = nil;
    return [super finalizeLayoutTransition];
}
@end

これはすべて、prepareForTransitionFromLayoutのレイアウト遷移の前に以前のコンテンツオフセットを保存し、targetContentOffsetForProposedContentOffsetの新しいコンテンツオフセットを上書きし、finalizeLayoutTransitionでクリアすることです。とても簡単です

2
Isaac liu

エクスペリエンスの追加に役立つ場合:コンテンツのサイズ、コンテンツのインセットを設定したかどうか、またはその他の明らかな要因に関係なく、この問題に永続的に遭遇しました。したがって、私の回避策はやや抜本的でした。まず、UICollectionViewをサブクラス化し、不適切なコンテンツオフセット設定に対処するために追加しました。

- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated
{
    if(_declineContentOffset) return;
    [super setContentOffset:contentOffset];
}

- (void)setContentOffset:(CGPoint)contentOffset
{
    if(_declineContentOffset) return;
    [super setContentOffset:contentOffset];
}

- (void)setCollectionViewLayout:(UICollectionViewLayout *)layout animated:(BOOL)animated
{
    _declineContentOffset ++;
    [super setCollectionViewLayout:layout animated:animated];
    _declineContentOffset --;
}

- (void)setContentSize:(CGSize)contentSize
{
    _declineContentOffset ++;
    [super setContentSize:contentSize];
    _declineContentOffset --;
}

私はそれを誇りに思っていませんが、唯一の実行可能な解決策は、setCollectionViewLayout:animated:の呼び出しに起因する独自のコンテンツオフセットを設定するコレクションビューによる試みを完全に拒否することです。経験的には、この変更は直接の呼び出しで直接発生するように見えますが、これは明らかにインターフェイスやドキュメントでは保証されていませんが、Core Animationの観点からは理にかなっているので、おそらく仮定に50%しか不快ではありません。

ただし、2番目の問題がありました。UICollectionViewは、新しいコレクションビューレイアウトで同じ場所に残っていたビューに少しジャンプし、約240ポイント押し下げてから元の位置にアニメートしていました。なぜかはわかりませんが、実際に動いていないセルに追加されたCAAnimationsを切断することで、コードを修正して対処しました。

- (void)setCollectionViewLayout:(UICollectionViewLayout *)layout animated:(BOOL)animated
{
    // collect up the positions of all existing subviews
    NSMutableDictionary *positionsByViews = [NSMutableDictionary dictionary];
    for(UIView *view in [self subviews])
    {
        positionsByViews[[NSValue valueWithNonretainedObject:view]] = [NSValue valueWithCGPoint:[[view layer] position]];
    }

    // apply the new layout, declining to allow the content offset to change
    _declineContentOffset ++;
    [super setCollectionViewLayout:layout animated:animated];
    _declineContentOffset --;

    // run through the subviews again...
    for(UIView *view in [self subviews])
    {
        // if UIKit has inexplicably applied animations to these views to move them back to where
        // they were in the first place, remove those animations
        CABasicAnimation *positionAnimation = (CABasicAnimation *)[[view layer] animationForKey:@"position"];
        NSValue *sourceValue = positionsByViews[[NSValue valueWithNonretainedObject:view]];
        if([positionAnimation isKindOfClass:[CABasicAnimation class]] && sourceValue)
        {
            NSValue *targetValue = [NSValue valueWithCGPoint:[[view layer] position]];

            if([targetValue isEqualToValue:sourceValue])
                [[view layer] removeAnimationForKey:@"position"];
        }
    }
}

これは、実際に移動するビューを阻害したり、誤って移動したりするようには見えません(周囲のすべてが約240ポイント下がって、正しい位置にアニメートされると期待しているように)。

これが私の現在の解決策です。

1
Tommy

おそらく、2週間ほどかけて、さまざまなレイアウトをスムーズに切り替えられるようにしようとしています。提案されたオフセットのオーバーライドがiOS 10.2で機能していることがわかりましたが、それより前のバージョンではまだ問題が発生します。私の状況を少し悪化させるのは、スクロールの結果として別のレイアウトに移行する必要があるため、ビューがスクロールと移行の両方を同時に行うことです。

Tommyの答えは、10.2より前のバージョンで私のために働いた唯一のものでした。現在、次のことを行っています。

class HackedCollectionView: UICollectionView {

    var ignoreContentOffsetChanges = false

    override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
        guard ignoreContentOffsetChanges == false else { return }
        super.setContentOffset(contentOffset, animated: animated)
    }

    override var contentOffset: CGPoint {
        get {
            return super.contentOffset
        }
        set {
            guard ignoreContentOffsetChanges == false else { return }
            super.contentOffset = newValue
        }
    }

    override func setCollectionViewLayout(_ layout: UICollectionViewLayout, animated: Bool) {
        guard ignoreContentOffsetChanges == false else { return }
        super.setCollectionViewLayout(layout, animated: animated)
    }

    override var contentSize: CGSize {
        get {
            return super.contentSize
        }
        set {
            guard ignoreContentOffsetChanges == false else { return }
            super.contentSize = newValue
        }
    }

}

次に、レイアウトを設定するときにこれを行います...

let theContentOffsetIActuallyWant = CGPoint(x: 0, y: 100)
UIView.animate(withDuration: animationDuration,
               delay: 0, options: animationOptions,
               animations: {
                collectionView.setCollectionViewLayout(layout, animated: true, completion: { completed in
                    // I'm also doing something in my layout, but this may be redundant now
                    layout.overriddenContentOffset = nil
                })
                collectionView.ignoreContentOffsetChanges = true
}, completion: { _ in
    collectionView.ignoreContentOffsetChanges = false
    collectionView.setContentOffset(theContentOffsetIActuallyWant, animated: false)
})
0
Mark Bridges