web-dev-qa-db-fra.com

UICollectionView s'aligne sur une cellule lors d'un défilement horizontal

Je sais que certaines personnes ont déjà posé cette question, mais ils parlaient tous de UITableViews ou UIScrollViews et je ne pouvais pas obtenir la solution acceptée qui fonctionnerait pour moi. Ce que je voudrais, c’est l’effet de capture lors du défilement horizontal de ma UICollectionView - un peu comme ce qui se passe dans l’AppStore iOS. iOS 9+ est ma version cible, veuillez donc regarder les changements UIKit avant de répondre à cette question.

Merci.

34
Mark Bourke

A l'origine, j'utilisais Objective-C, mais depuis, je suis passé à Swift et la réponse initialement acceptée n'était pas suffisante.

J'ai fini par créer une sous-classe UICollectionViewLayout qui fournit la meilleure expérience (imo) par opposition aux autres fonctions qui modifient le décalage du contenu ou quelque chose de similaire lorsque l'utilisateur a cessé de faire défiler.

class SnappingCollectionViewLayout: UICollectionViewFlowLayout {

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }

        var offsetAdjustment = CGFloat.greatestFiniteMagnitude
        let horizontalOffset = proposedContentOffset.x + collectionView.contentInset.left

        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.bounds.size.width, height: collectionView.bounds.size.height)

        let layoutAttributesArray = super.layoutAttributesForElements(in: targetRect)

        layoutAttributesArray?.forEach({ (layoutAttributes) in
            let itemOffset = layoutAttributes.frame.Origin.x
            if fabsf(Float(itemOffset - horizontalOffset)) < fabsf(Float(offsetAdjustment)) {
                offsetAdjustment = itemOffset - horizontalOffset
            }
        })

        return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    }
}

Pour la décélération de l'impression la plus naturelle avec la sous-classe de mise en page actuelle, veillez à définir les éléments suivants:

collectionView?.decelerationRate = UIScrollViewDecelerationRateFast

63
Mark Bourke

Voici un calcul simple que j’utilise (dans Swift):

func snapToNearestCell(_ collectionView: UICollectionView) {
    for i in 0..<collectionView.numberOfItems(inSection: 0) {

        let itemWithSpaceWidth = collectionViewFlowLayout.itemSize.width + collectionViewFlowLayout.minimumLineSpacing
        let itemWidth = collectionViewFlowLayout.itemSize.width

        if collectionView.contentOffset.x <= CGFloat(i) * itemWithSpaceWidth + itemWidth / 2 {                
            let indexPath = IndexPath(item: i, section: 0)
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
            break
        }
    }
}

Appelez où vous en avez besoin. Je l'appelle dans

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    snapToNearestCell(scrollView)
}

Et

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
    snapToNearestCell(scrollView)
}

D'où provient collectionViewFlowLayout:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // Set up collection view
    collectionViewFlowLayout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout
}
19
Nam

Swift 3 version of @ Iowa15 répondre

func scrollToNearestVisibleCollectionViewCell() {
    let visibleCenterPositionOfScrollView = Float(collectionView.contentOffset.x + (self.collectionView!.bounds.size.width / 2))
    var closestCellIndex = -1
    var closestDistance: Float = .greatestFiniteMagnitude
    for i in 0..<collectionView.visibleCells.count {
        let cell = collectionView.visibleCells[i]
        let cellWidth = cell.bounds.size.width
        let cellCenter = Float(cell.frame.Origin.x + cellWidth / 2)

        // Now calculate closest cell
        let distance: Float = fabsf(visibleCenterPositionOfScrollView - cellCenter)
        if distance < closestDistance {
            closestDistance = distance
            closestCellIndex = collectionView.indexPath(for: cell)!.row
        }
    }
    if closestCellIndex != -1 {
        self.collectionView!.scrollToItem(at: IndexPath(row: closestCellIndex, section: 0), at: .centeredHorizontally, animated: true)
    }
}

Doit être implémenté dans UIScrollViewDelegate:

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    scrollToNearestVisibleCollectionViewCell()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        scrollToNearestVisibleCollectionViewCell()
    }
}
14
Mette

D'après les réponses de Mete et les commentaires de Chris Chute,

Voici une extension Swift 4 qui fera exactement ce que veut OP. Il a été testé sur des vues de collection imbriquées à une ou deux rangées et fonctionne parfaitement.

extension UICollectionView {
    func scrollToNearestVisibleCollectionViewCell() {
        self.decelerationRate = UIScrollViewDecelerationRateFast
        let visibleCenterPositionOfScrollView = Float(self.contentOffset.x + (self.bounds.size.width / 2))
        var closestCellIndex = -1
        var closestDistance: Float = .greatestFiniteMagnitude
        for i in 0..<self.visibleCells.count {
            let cell = self.visibleCells[i]
            let cellWidth = cell.bounds.size.width
            let cellCenter = Float(cell.frame.Origin.x + cellWidth / 2)

            // Now calculate closest cell
            let distance: Float = fabsf(visibleCenterPositionOfScrollView - cellCenter)
            if distance < closestDistance {
                closestDistance = distance
                closestCellIndex = self.indexPath(for: cell)!.row
            }
        }
        if closestCellIndex != -1 {
            self.scrollToItem(at: IndexPath(row: closestCellIndex, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
}

Vous devez implémenter le protocole UIScrollViewDelegate pour votre vue de collection, puis ajouter ces deux méthodes:

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    self.collectionView.scrollToNearestVisibleCollectionViewCell()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        self.collectionView.scrollToNearestVisibleCollectionViewCell()
    }
}
8
Vahid Amiri

Si vous voulez un comportement natif simple, sans personnalisation: 

collectionView.pagingEnabled = YES;

Cela ne fonctionne correctement que lorsque la taille des éléments de disposition de la vue de collection est identique et que la propriété UICollectionViewCell's clipToBounds est définie sur YES.

4
NSDestr0yer

Vous avez une réponse de SO post ici et docs ici

First Ce que vous pouvez faire est de définir le délégué de scrollview de votre vue de collection dans votre classe en faisant de votre classe un délégué de scrollview

MyViewController : SuperViewController<... ,UIScrollViewDelegate>

Ensuite, configurez votre contrôleur de vue en tant que délégué

UIScrollView *scrollView = (UIScrollView *)super.self.collectionView;
scrollView.delegate = self;

Ou bien faites-le dans le constructeur d'interface en appuyant sur Ctrl + Maj en cliquant sur la vue de votre collection, puis en maintenant la touche Contrôle enfoncée ou en faisant un clic droit, faites-la glisser vers votre contrôleur de vue et sélectionnez Délégué. (Vous devriez savoir comment faire cela). Cela ne marche pas. UICollectionView étant une sous-classe de UIScrollView, vous pourrez maintenant le voir dans le générateur d'interface en maintenant la touche Ctrl enfoncée et en cliquant sur.

Next implémente la méthode déléguée - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView

MyViewController.m

... 

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{

}

La documentation précise que:

Paramètres

scrollView | Objet de vue de défilement qui décélère le défilement de la vue du contenu.

Discussion La vue de défilement appelle cette méthode lors du défilement le mouvement s'arrête. La propriété de décélération de UIScrollView contrôle la décélération.

Disponibilité Disponible dans iOS 2.0 et versions ultérieures.

Puis à l'intérieur de cette méthode, vérifiez quelle cellule était la plus proche du centre de la vue de défilement lorsqu'elle a cessé de défiler

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
  //NSLog(@"%f", truncf(scrollView.contentOffset.x + (self.pictureCollectionView.bounds.size.width / 2)));

float visibleCenterPositionOfScrollView = scrollView.contentOffset.x + (self.pictureCollectionView.bounds.size.width / 2);

//NSLog(@"%f", truncf(visibleCenterPositionOfScrollView / imageArray.count));


NSInteger closestCellIndex;

for (id item in imageArray) {
    // equation to use to figure out closest cell
    // abs(visibleCenter - cellCenterX) <= (cellWidth + cellSpacing/2)

    // Get cell width (and cell too)
    UICollectionViewCell *cell = (UICollectionViewCell *)[self collectionView:self.pictureCollectionView cellForItemAtIndexPath:[NSIndexPath indexPathWithIndex:[imageArray indexOfObject:item]]];
    float cellWidth = cell.bounds.size.width;

    float cellCenter = cell.frame.Origin.x + cellWidth / 2;

    float cellSpacing = [self collectionView:self.pictureCollectionView layout:self.pictureCollectionView.collectionViewLayout minimumInteritemSpacingForSectionAtIndex:[imageArray indexOfObject:item]];

    // Now calculate closest cell

    if (fabsf(visibleCenterPositionOfScrollView - cellCenter) <= (cellWidth + (cellSpacing / 2))) {
        closestCellIndex = [imageArray indexOfObject:item];
        break;
    }
}

if (closestCellIndex != nil) {

[self.pictureCollectionView scrollToItemAtIndexPath:[NSIndexPath indexPathWithIndex:closestCellIndex] atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES];

// This code is untested. Might not work.

}
3
Minebomber

Accrocher à la cellule la plus proche, en respectant la vitesse de défilement.

Fonctionne sans aucun problème.

import UIKit

class SnapCenterLayout: UICollectionViewFlowLayout {
  override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }
    let parent = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)

    let itemSpace = itemSize.width + minimumInteritemSpacing
    var currentItemIdx = round(collectionView.contentOffset.x / itemSpace)

    // Skip to the next cell, if there is residual scrolling velocity left.
    // This helps to prevent glitches
    let vX = velocity.x
    if vX > 0 {
      currentItemIdx += 1
    } else if vX < 0 {
      currentItemIdx -= 1
    }

    let nearestPageOffset = currentItemIdx * itemSpace
    return CGPoint(x: nearestPageOffset,
                   y: parent.y)
  }
}
3
Richard Topchiy

Voici ma mise en place

let middlePoint = Int(scrollView.contentOffset.x + UIScreen.main.bounds.width / 2)
    if let indexPath = self.cvCollectionView.indexPathForItem(at: CGPoint(x: middlePoint, y: 0)) {
        self.cvCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
    }

Implémentez vos délégués de vue de défilement comme ceci

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    self.snapToNearestCell(scrollView: scrollView)
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    self.snapToNearestCell(scrollView: scrollView)
}

En outre, pour une meilleure capture

self.cvCollectionView.decelerationRate = UIScrollViewDecelerationRateFast

Fonctionne comme un charme

3
Sourav Chandra

Je viens de trouver ce que je pense être la meilleure solution possible à ce problème:

Commencez par ajouter une cible à la reconnaissance gesture existante de collectionView:

[self.collectionView.panGestureRecognizer addTarget:self action:@selector(onPan:)];

Demandez au sélecteur de désigner une méthode qui prend un UIPanGestureRecognizer en tant que paramètre:

- (void)onPan:(UIPanGestureRecognizer *)recognizer {};

Ensuite, dans cette méthode, forcez collectionView à faire défiler la page jusqu'à la cellule appropriée une fois le geste de panoramique terminé ... J'ai fait cela en récupérant les éléments visibles dans la vue de collection et en déterminant l'élément sur lequel je souhaite faire défiler, en fonction de la direction de la poêle.

if (recognizer.state == UIGestureRecognizerStateEnded) {

        // Get the visible items
        NSArray<NSIndexPath *> *indexes = [self.collectionView indexPathsForVisibleItems];
        int index = 0;

        if ([(UIPanGestureRecognizer *)recognizer velocityInView:self.view].x > 0) {
            // Return the smallest index if the user is swiping right
            for (int i = index;i < indexes.count;i++) {
                if (indexes[i].row < indexes[index].row) {
                    index = i;
                }
            }
        } else {
            // Return the biggest index if the user is swiping left
            for (int i = index;i < indexes.count;i++) {
                if (indexes[i].row > indexes[index].row) {
                    index = i;
                }
            }
        }
        // Scroll to the selected item
        [self.collectionView scrollToItemAtIndexPath:indexes[index] atScrollPosition:UICollectionViewScrollPositionLeft animated:YES];
    }

N'oubliez pas que dans mon cas, seuls deux éléments peuvent être visibles à la fois. Je suis sûr que cette méthode peut être adaptée à plus d'éléments cependant.

2
Julius

Une modification de la réponse ci-dessus que vous pouvez également essayer:

-(void)scrollToNearestVisibleCollectionViewCell {
    float visibleCenterPositionOfScrollView = _collectionView.contentOffset.x + (self.collectionView.bounds.size.width / 2);

    NSInteger closestCellIndex = -1;
    float closestDistance = FLT_MAX;
    for (int i = 0; i < _collectionView.visibleCells.count; i++) {
        UICollectionViewCell *cell = _collectionView.visibleCells[i];
        float cellWidth = cell.bounds.size.width;

        float cellCenter = cell.frame.Origin.x + cellWidth / 2;

        // Now calculate closest cell
        float distance = fabsf(visibleCenterPositionOfScrollView - cellCenter);
        if (distance < closestDistance) {
            closestDistance = distance;
            closestCellIndex = [_collectionView indexPathForCell:cell].row;
        }
    }

    if (closestCellIndex != -1) {
        [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:closestCellIndex inSection:0] atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES];
    }
}
2
Iowa15

Ceci provient d'une vidéo WWDC 2012 pour une solution Objective-C. J'ai sous-classé UICollectionViewFlowLayout et ajouté ce qui suit.

-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
    {
        CGFloat offsetAdjustment = MAXFLOAT;
        CGFloat horizontalCenter = proposedContentOffset.x + (CGRectGetWidth(self.collectionView.bounds) / 2);

        CGRect targetRect = CGRectMake(proposedContentOffset.x, 0.0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);
        NSArray *array = [super layoutAttributesForElementsInRect:targetRect];

        for (UICollectionViewLayoutAttributes *layoutAttributes in array)
        {
            CGFloat itemHorizontalCenter = layoutAttributes.center.x;
            if (ABS(itemHorizontalCenter - horizontalCenter) < ABS(offsetAdjustment))
            {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter;
            }
        }

        return CGPointMake(proposedContentOffset.x + offsetAdjustment, proposedContentOffset.y);
    }

Et la raison pour laquelle je suis arrivé à cette question était pour le claquement avec une sensation native, ce que la réponse acceptée de Mark m'a apportée ... ceci que j'ai mis dans le contrôleur de vue de collectionView.

collectionView.decelerationRate = UIScrollViewDecelerationRateFast;
1
mrcrowley

Cette solution donne une animation meilleure et plus fluide.

Swift 3

Pour insérer le premier et le dernier élément au centre, ajoutez des encarts:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {

    return UIEdgeInsetsMake(0, cellWidth/2, 0, cellWidth/2)
}

Utilisez ensuite la variable targetContentOffset dans la méthode scrollViewWillEndDragging pour modifier la position finale.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    let numOfItems = collectionView(mainCollectionView, numberOfItemsInSection:0)
    let totalContentWidth = scrollView.contentSize.width + mainCollectionViewFlowLayout.minimumInteritemSpacing - cellWidth
    let stopOver = totalContentWidth / CGFloat(numOfItems)

    var targetX = round((scrollView.contentOffset.x + (velocity.x * 300)) / stopOver) * stopOver
    targetX = max(0, min(targetX, scrollView.contentSize.width - scrollView.frame.width))

    targetContentOffset.pointee.x = targetX
}

Peut-être que dans votre cas, la totalContentWidth est calculée différemment, p.e. sans minimumInteritemSpacing, ajustez donc cela en conséquence . Vous pouvez également jouer avec le 300 utilisé dans la velocity

0
Roland Keesom

Swift 4.2. Simple. Pour itemSize fixe. Sens d'écoulement horizontal.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
        let floatingPage = targetContentOffset.pointee.x/scrollView.bounds.width
        let rule: FloatingPointRoundingRule = velocity.x > 0 ? .up : .down
        let page = CGFloat(Int(floatingPage.rounded(rule)))
        targetContentOffset.pointee.x = page*(layout.itemSize.width + layout.minimumLineSpacing)
    }

}
0

Voici une version de Swift 3.0, qui devrait fonctionner pour les directions horizontale et verticale selon la suggestion de Mark ci-dessus:

  override func targetContentOffset(
    forProposedContentOffset proposedContentOffset: CGPoint,
    withScrollingVelocity velocity: CGPoint
  ) -> CGPoint {

    guard
      let collectionView = collectionView
    else {
      return super.targetContentOffset(
        forProposedContentOffset: proposedContentOffset,
        withScrollingVelocity: velocity
      )
    }

    let realOffset = CGPoint(
      x: proposedContentOffset.x + collectionView.contentInset.left,
      y: proposedContentOffset.y + collectionView.contentInset.top
    )

    let targetRect = CGRect(Origin: proposedContentOffset, size: collectionView.bounds.size)

    var offset = (scrollDirection == .horizontal)
      ? CGPoint(x: CGFloat.greatestFiniteMagnitude, y:0.0)
      : CGPoint(x:0.0, y:CGFloat.greatestFiniteMagnitude)

    offset = self.layoutAttributesForElements(in: targetRect)?.reduce(offset) {
      (offset, attr) in
      let itemOffset = attr.frame.Origin
      return CGPoint(
        x: abs(itemOffset.x - realOffset.x) < abs(offset.x) ? itemOffset.x - realOffset.x : offset.x,
        y: abs(itemOffset.y - realOffset.y) < abs(offset.y) ? itemOffset.y - realOffset.y : offset.y
      )
    } ?? .zero

    return CGPoint(x: proposedContentOffset.x + offset.x, y: proposedContentOffset.y + offset.y)
  }
0
nikwest