web-dev-qa-db-fra.com

La définition dynamique de la présentation sur UICollectionView entraîne une modification inexplicable du contenuOffset

Selon la documentation d'Apple (et présentée à la WWDC 2012), il est possible de définir dynamiquement la mise en page sur UICollectionView et même d'animer les modifications:

Vous spécifiez normalement un objet de présentation lors de la création d'une vue de collection, mais vous pouvez également modifier la mise en page d'une vue de collection de manière dynamique. L'objet de présentation est stocké dans la propriété collectionViewLayout. La définition de cette propriété met à jour la mise en page immédiatement, sans animer les modifications. Si vous souhaitez animer les modifications, vous devez appeler la méthode setCollectionViewLayout: animated:.

Cependant, dans la pratique, j'ai constaté que UICollectionView apportait des modifications inexplicables et même non valides à contentOffset, ce qui entraînait un déplacement incorrect des cellules, rendant ainsi la fonctionnalité pratiquement inutilisable. Pour illustrer le problème, j'ai rassemblé l'exemple de code suivant qui peut être associé à un contrôleur de vue de collection par défaut déposé dans un storyboard:

#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

Le contrôleur définit une variable UICollectionViewFlowLayout par défaut dans viewDidLoad et affiche une seule cellule à l'écran. Lorsque les cellules sont sélectionnées, le contrôleur crée une autre valeur par défaut UICollectionViewFlowLayout et la définit dans la vue de collection avec l'indicateur animated:YES. Le comportement attendu est que la cellule ne bouge pas. Le comportement réel, cependant, est que la cellule défile en dehors de l'écran, auquel cas il n'est même pas possible de faire défiler la cellule à l'écran.

L'examen du journal de la console révèle que le contentOffset a changé inexplicablement (dans mon projet, de (0, 0) à (0, 205)). J'ai posté une solution pour la solution pour le cas non animé (i.e. animated:NO), mais comme j'ai besoin d'animation, je suis très intéressé de savoir si quelqu'un a une solution ou une solution de contournement pour le cas animé.

En passant, j'ai testé des mises en page personnalisées et obtenons le même comportement.

66
Timothy Moose

Cela fait des jours que je m'arrache les cheveux et trouve une solution à ma situation qui pourrait m'aider . Dans mon cas, j'ai une mise en page réductrice comme dans l'application de photos sur l'ipad. Il affiche les albums avec les photos les unes sur les autres et lorsque vous appuyez sur un album, il agrandit les photos. Donc, ce que j’ai, c’est deux UICollectionViewLayouts distincts et que je bascule entre eux avec [self.collectionView setCollectionViewLayout:myLayout animated:YES] Je rencontrais votre problème exact avec les cellules qui sautaient avant l’animation et me suis rendu compte que c’était la contentOffset. J'ai tout essayé avec la contentOffset mais cela a quand même sauté pendant l'animation. La solution de tyler ci-dessus a fonctionné, mais l'animation persiste.

Ensuite, j'ai remarqué que cela ne se produit que lorsqu'il y avait quelques albums à l'écran, pas assez pour remplir l'écran. Ma disposition remplace -(CGSize)collectionViewContentSize comme recommandé. Lorsqu'il n'y a que quelques albums, la taille du contenu de la vue de collection est inférieure à celle du contenu des vues. Cela provoque le saut lorsque je bascule entre les dispositions de collection.

J'ai donc défini une propriété sur mes mises en page appelée minHeight et l'a définie sur la hauteur du parent de la vue de collection. Ensuite, je vérifie la hauteur avant de rentrer dans -(CGSize)collectionViewContentSize. Je m'assure que la hauteur est> = la hauteur minimale.

Pas une vraie solution mais ça fonctionne bien maintenant. J'essaierais de définir la contentSize de votre vue de collection sur au moins la longueur de sa vue contenant.

edit: Manicaesar a ajouté une solution de contournement simple si vous héritez de UICollectionViewFlowLayout:

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

UICollectionViewLayout contient la méthode remplaçable targetContentOffsetForProposedContentOffset: qui vous permet de fournir le décalage de contenu approprié lors d'un changement de présentation, ce qui animera correctement. Ceci est disponible dans iOS 7.0 et supérieur

31
cdemiris99

Sauter avec une réponse tardive à ma propre question.

La bibliothèque TLLayoutTransitioning constitue une excellente solution à ce problème en attribuant de nouvelles tâches aux API de transition interactives d'iOS7 pour effectuer des transitions non interactives de mise en forme à disposition. Il fournit efficacement une alternative à setCollectionViewLayout, résolvant le problème de décalage de contenu et ajoutant plusieurs fonctionnalités:

  1. Durée de l'animation
  2. Plus de 30 courbes d'assouplissement (gracieuseté de la bibliothèque AHEasing de Warren Moore )
  3. Plusieurs modes de décalage de contenu

Les courbes d'accélération personnalisées peuvent être définies comme des fonctions AHEasingFunction. Le décalage final du contenu peut être spécifié en termes d'un ou plusieurs chemins d'index avec les options d'emplacement Minimal, Centre, Haut, Gauche, Bas ou Droite.

Pour voir ce que je veux dire, essayez d'exécuter la démo Resize dans l'espace de travail Exemples et de vous amuser avec les options.

L'usage est comme ça. Commencez par configurer votre contrôleur de vue pour renvoyer une instance de TLTransitionLayout:

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

Ensuite, au lieu d'appeler setCollectionViewLayout, appelez transitionToCollectionViewLayout:toLayout défini dans la catégorie 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];

Cet appel initie une transition interactive et, en interne, un rappel CADisplayLink qui contrôle la progression de la transition avec la durée et la fonction d'accélération spécifiées.

L'étape suivante consiste à spécifier un décalage de contenu final. Vous pouvez spécifier n'importe quelle valeur arbitraire, mais la méthode toContentOffsetForLayout définie dans UICollectionView-TLLayoutTransitioning constitue un moyen élégant de calculer les décalages de contenu par rapport à un ou plusieurs chemins d'index. Par exemple, pour qu'une cellule spécifique se trouve le plus près possible du centre de la vue Collection, effectuez l'appel suivant immédiatement après la variable 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

Ce problème m’a également touché et il semble y avoir un bogue dans le code de transition. D'après ce que je peux dire, il essaie de se concentrer sur la cellule la plus proche du centre de la présentation de la vue antérieure à la transition. Toutefois, s'il ne se trouve aucune cellule au centre de la vue avant la transition, le système essaie toujours de se centrer à l'endroit où la cellule se situerait après la transition. Ceci est très clair si vous définissez alwaysBounceVertical/Horizontal sur YES, chargez la vue avec une seule cellule, puis effectuez une transition de présentation. 

J'ai pu contourner ce problème en indiquant explicitement à la collection de se concentrer sur une cellule spécifique (la première cellule visible, dans cet exemple) après avoir déclenché la mise à jour de la présentation. 

[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

Facile.

Animez votre nouvelle mise en page et le contenu de collectionView dans le même bloc d'animation.

[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 restera constant.

5
nikolsky

Swift 3.

UICollectionViewLayout sous-classe. La façon dont cdemiris99 suggérait:

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

Je l'ai testé moi-même en utilisant ma propre configuration layout

3
iWheelBuy

Si vous cherchez simplement à ce que le contenu ne soit pas modifié lors de la transition d'une mise en page, vous pouvez créer une mise en page personnalisée et remplacer plusieurs méthodes pour garder la trace de l'ancien contentOffset et le réutiliser:

@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

Cela consiste simplement à enregistrer le décalage de contenu précédent avant la transition de la disposition dans prepareForTransitionFromLayout, à écraser le nouveau décalage de contenu à targetContentOffsetForProposedContentOffset et à le supprimer dans finalizeLayoutTransition. Assez simple

2
Isaac liu

Si cela peut contribuer à enrichir l'expérience: j'ai rencontré ce problème de manière persistante, quelle que soit la taille de mon contenu, que j'avais défini un encart de contenu ou tout autre facteur évident. Donc, ma solution de contournement était un peu drastique. J'ai tout d'abord sous-classé UICollectionView et ajouté pour lutter contre le paramètre de décalage de contenu inapproprié:

- (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 --;
}

Je n'en suis pas fier, mais la seule solution réalisable semble être de rejeter complètement toute tentative de la vue collection de définir son propre décalage de contenu résultant d'un appel à setCollectionViewLayout:animated:. De façon empirique, il semble que ce changement se produise directement dans l'appel immédiat, ce qui n'est évidemment pas garanti par l'interface ou la documentation, mais qui a du sens du point de vue de Core Animation.

Un autre problème se posait toutefois: UICollectionView ajoutait maintenant un petit saut aux vues qui restaient au même endroit sur une nouvelle présentation de vue de collection - les réduisant d’environ 240 points et les animant ensuite à leur position initiale. Je ne comprends pas pourquoi mais j'ai néanmoins modifié mon code pour y remédier en coupant les CAAnimationname__s qui avaient été ajoutés à toutes les cellules qui, en réalité, ne bougeaient pas:

- (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"];
        }
    }
}

Cela ne semble pas empêcher les vues qui bougent réellement, ni les faire bouger incorrectement (comme s’ils s’attendaient à ce que tout autour d’eux soit en dessous de 240 points et s’anime à la bonne position avec eux).

C'est donc ma solution actuelle.

1
Tommy

J'ai probablement passé environ deux semaines maintenant à essayer de faire passer différentes mises en page de manière à ce qu'elles passent facilement l'une de l'autre. J'ai constaté que le remplacement de l'offset proposé fonctionne dans iOS 10.2, mais le problème persiste dans les versions antérieures. Ce qui aggrave un peu ma situation, c’est que j’ai besoin de passer à une autre mise en page à la suite d’un défilement. Par conséquent, la vue défile et passe en même temps. 

La réponse de Tommy a été la seule chose qui a fonctionné pour moi dans les versions antérieures à la version 10.2. Je fais la chose suivante maintenant. 

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
        }
    }

}

Ensuite, lorsque je règle la mise en page, je le fais ...

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