Paging UIScrollView par incréments inférieurs à la taille de l'image

87

J'ai une vue de défilement qui est la largeur de l'écran mais seulement environ 70 pixels de haut. Il contient de nombreuses icônes 50 x 50 (avec un espace autour d'elles) que je souhaite que l'utilisateur puisse choisir. Mais je veux toujours que la vue de défilement se comporte de manière paginée, en s'arrêtant toujours avec une icône au centre exact.

Si les icônes avaient la largeur de l'écran, ce ne serait pas un problème car la pagination de UIScrollView s'en chargerait. Mais comme mes petites icônes sont bien inférieures à la taille du contenu, cela ne fonctionne pas.

J'ai déjà vu ce comportement dans un appel d'application AllRecipes. Je ne sais tout simplement pas comment faire.

Comment faire fonctionner la pagination sur une base de taille par icône?

Henning
la source

Réponses:

123

Essayez de réduire votre vue de défilement à la taille de l'écran (dans le sens de la largeur), mais décochez la case "Clip Subviews" dans IB. Ensuite, superposez une vue transparente, userInteractionEnabled = NO par-dessus (en pleine largeur), qui remplace hitTest: withEvent: pour retourner votre vue de défilement. Cela devrait vous donner ce que vous recherchez. Voir cette réponse pour plus de détails.

Ben Gottlieb
la source
1
Je ne sais pas trop de quoi vous parlez, je vais regarder la réponse que vous indiquez.
Henning
8
C'est une belle solution. Je n'avais pas pensé à remplacer la fonction hitTest: function et cela supprime à peu près le seul inconvénient de cette approche. Bien fait.
Ben Gotow
Belle solution! M'a vraiment aidé.
DevDevDev
8
Cela fonctionne très bien, mais je recommande au lieu de simplement renvoyer le scrollView, de renvoyer [scrollView hitTest: point withEvent: event] pour que les événements passent correctement. Par exemple, dans une vue de défilement pleine de UIButtons, les boutons fonctionneront toujours de cette façon.
greenisus
2
notez que cela ne fonctionnera PAS avec une tableview ou une collectionView car cela ne rendra pas les choses en dehors du cadre. Voir ma réponse ci
vish
63

Il existe également une autre solution qui est probablement un peu meilleure que de superposer une vue de défilement avec une autre vue et de remplacer hitTest.

Vous pouvez sous-classer UIScrollView et remplacer son pointInside. Ensuite, la vue de défilement peut répondre aux touches en dehors de son cadre. Bien sûr, le reste est le même.

@interface PagingScrollView : UIScrollView {

    UIEdgeInsets responseInsets;
}

@property (nonatomic, assign) UIEdgeInsets responseInsets;

@end


@implementation PagingScrollView

@synthesize responseInsets;

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    CGPoint parentLocation = [self convertPoint:point toView:[self superview]];
    CGRect responseRect = self.frame;
    responseRect.origin.x -= responseInsets.left;
    responseRect.origin.y -= responseInsets.top;
    responseRect.size.width += (responseInsets.left + responseInsets.right);
    responseRect.size.height += (responseInsets.top + responseInsets.bottom);

    return CGRectContainsPoint(responseRect, parentLocation);
}

@end
Divisé
la source
1
Oui! de plus, si les limites de votre scrollview sont les mêmes que celles de son parent, vous pouvez simplement retourner yes à partir de la méthode pointInside. merci
cocoatoucher
5
J'aime cette solution, bien que je doive changer parentLocation en "[self convertPoint: point toView: self.superview]" pour qu'elle fonctionne correctement.
amrox
Vous avez raison, convertPoint est bien meilleur que ce que j'y ai écrit.
Split le
6
directement return [self.superview pointInside:[self convertPoint:point toView:self.superview] withEvent:event];@amrox @Split vous n'avez pas besoin de responseInsets si vous avez un superview qui sert de vue de découpage.
Cœur
Cette solution semblait tomber en panne lorsque j'arrivais à la fin de ma liste d'articles si la dernière page d'articles ne remplissait pas complètement la page. C'est un peu difficile à expliquer, mais si j'affiche 3,5 cellules par page et que j'ai 5 éléments, la deuxième page me montrera soit 2 cellules avec un espace vide à droite, soit elle affichera 3 cellules avec un début cellule partielle. Le problème était que si j'étais dans l'état où il me montrait un espace vide et que je sélectionnais une cellule, la pagination se corrigeait pendant la transition de navigation ... Je posterai ma solution ci-dessous.
tyler
18

Je vois beaucoup de solutions, mais elles sont très complexes. Un moyen beaucoup plus simple d'avoir de petites pages tout en gardant toutes les zones défilantes consiste à réduire le défilement et à déplacer le scrollView.panGestureRecognizervers votre vue parent. Voici les étapes:

  1. Réduisez votre taille de scrollViewLa taille de ScrollView est plus petite que celle du parent

  2. Assurez-vous que votre vue de défilement est paginée et ne coupe pas la sous-vue entrez la description de l'image ici

  3. Dans le code, déplacez le mouvement de panoramique scrollview vers la vue du conteneur parent pleine largeur:

    override func viewDidLoad() {
        super.viewDidLoad()
        statsView.addGestureRecognizer(statsScrollView.panGestureRecognizer)
    }
Angel G. Olloqui
la source
C'est une solution géniale. Très intelligent.
superpuccio
C'est en fait ce que je veux!
LYM
Très intelligent! Vous m'avez sauvé des heures. Merci!
Erik
6

La réponse acceptée est très bonne, mais elle ne fonctionnera que pour la UIScrollViewclasse et aucun de ses descendants. Par exemple, si vous avez beaucoup de vues et que vous convertissez en un UICollectionView, vous ne pourrez pas utiliser cette méthode, car la vue de collection supprimera vues qu'elle pense ne pas être visibles (donc même si elles ne sont pas coupées, elles disparaître).

Le commentaire à ce sujet mentionne scrollViewWillEndDragging:withVelocity:targetContentOffset: est, à mon avis, la bonne réponse.

Ce que vous pouvez faire est, à l'intérieur de cette méthode déléguée, vous calculez la page / l'index en cours. Ensuite, vous décidez si la vitesse et le décalage cible méritent un mouvement de "page suivante". Vous pouvez vous rapprocher du pagingEnabledcomportement.

Remarque: je suis généralement un développeur RubyMotion ces jours-ci, donc quelqu'un s'il vous plaît prouver que ce code Obj-C est correct. Désolé pour le mélange de camelCase et de snake_case, j'ai copié et collé une grande partie de ce code.

- (void) scrollViewWillEndDragging:(UIScrollView *)scrollView
         withVelocity:(CGPoint)velocity
         targetContentOffset:(inout CGPoint *)targetOffset
{
    CGFloat x = targetOffset->x;
    int index = [self convertXToIndex: x];
    CGFloat w = 300f;  // this is your custom page width
    CGFloat current_x = w * [self convertXToIndex: scrollView.contentOffset.x];

    // even if the velocity is low, if the offset is more than 50% past the halfway
    // point, proceed to the next item.
    if ( velocity.x < -0.5 || (current_x - x) > w / 2 ) {
      index -= 1
    } 
    else if ( velocity.x > 0.5 || (x - current_x) > w / 2 ) {
      index += 1;
    }

    if ( index >= 0 || index < self.items.length ) {
      CGFloat new_x = [self convertIndexToX: index];
      targetOffset->x = new_x;
    }
} 
colinta
la source
haha maintenant je me demande si je n'aurais pas dû mentionner RubyMotion - ça me fait baisser les votes! non en fait, je souhaite que les votes négatifs incluent également des commentaires, j'aimerais savoir ce que les gens trouvent mal avec mon explication.
colinta
Pouvez-vous inclure la définition de convertXToIndex:et convertIndexToX:?
mr5
Incomplet. Lorsque vous faites glisser lentement et que vous soulevez votre main avec velocityzéro, elle rebondira, ce qui est étrange. J'ai donc une meilleure réponse.
DawnSong
4
- (void) scrollViewWillEndDragging:(UIScrollView *)scrollView
         withVelocity:(CGPoint)velocity
         targetContentOffset:(inout CGPoint *)targetOffset
{
    static CGFloat previousIndex;
    CGFloat x = targetOffset->x + kPageOffset;
    int index = (x + kPageWidth/2)/kPageWidth;
    if(index<previousIndex - 1){
        index = previousIndex - 1;
    }else if(index > previousIndex + 1){
        index = previousIndex + 1;
    }

    CGFloat newTarget = index * kPageWidth;
    targetOffset->x = newTarget - kPageOffset;
    previousIndex = index;
} 

kPageWidth est la largeur que vous souhaitez que votre page soit. kPageOffset est si vous ne voulez pas que les cellules soient alignées à gauche (c'est-à-dire si vous voulez qu'elles soient alignées au centre, définissez-le sur la moitié de la largeur de votre cellule). Sinon, il devrait être zéro.

Cela permettra également de ne faire défiler qu'une page à la fois.

vish
la source
3

Jetez un œil à la -scrollView:didEndDragging:willDecelerate:méthode sur UIScrollViewDelegate. Quelque chose comme:

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    int x = scrollView.contentOffset.x;
    int xOff = x % 50;
    if(xOff < 25)
        x -= xOff;
    else
        x += 50 - xOff;

    int halfW = scrollView.contentSize.width / 2; // the width of the whole content view, not just the scroll view
    if(x > halfW)
        x = halfW;

    [scrollView setContentOffset:CGPointMake(x,scrollView.contentOffset.y)];
}

Ce n'est pas parfait - la dernière fois que j'ai essayé ce code, j'ai eu un comportement moche (sauter, si je me souviens bien) en revenant d'un rouleau élastique. Vous pourrez peut-être éviter cela en définissant simplement la bouncespropriété de la vue de défilement sur NO.

Noah Witherspoon
la source
3

Comme je ne semble pas encore autorisé à commenter, j'ajouterai mes commentaires à la réponse de Noah ici.

J'ai réussi à y parvenir par la méthode décrite par Noah Witherspoon. J'ai contourné le comportement de saut en n'appelant simplement pas la setContentOffset:méthode lorsque la vue de défilement a dépassé ses bords.

         - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
         {      
             // Don't snap when at the edges because that will override the bounce mechanic
             if (self.contentOffset.x < 0 || self.contentOffset.x + self.bounds.size.width > self.contentSize.width)
                 return;

             ...
         }

J'ai également constaté que je devais implémenter la -scrollViewWillBeginDecelerating:méthode UIScrollViewDelegatepour attraper tous les cas.

Jacob Persson
la source
Quels cas supplémentaires doivent être détectés?
chrish le
le cas où il n'y avait pas du tout de traînée. L'utilisateur lève le doigt et le défilement s'arrête immédiatement.
Jess Bowers
3

J'ai essayé la solution ci-dessus qui superposait une vue transparente avec pointInside: withEvent: overridden. Cela a plutôt bien fonctionné pour moi, mais a échoué dans certains cas - voir mon commentaire. J'ai fini par implémenter la pagination moi-même avec une combinaison de scrollViewDidScroll pour suivre l'index de la page actuelle et scrollViewWillEndDragging: withVelocity: targetContentOffset et scrollViewDidEndDragging: willDecelerate pour s'aligner sur la page appropriée. Notez que la méthode will-end n'est disponible que sur iOS5 +, mais elle est assez douce pour cibler un décalage particulier si la vitesse! = 0. Plus précisément, vous pouvez indiquer à l'appelant où vous voulez que la vue de défilement atterrisse avec une animation s'il y a de la vitesse dans un direction particulière.

Tyler
la source
0

Lors de la création de la vue de défilement, assurez-vous de définir ceci:

scrollView.showsHorizontalScrollIndicator = false;
scrollView.showsVerticalScrollIndicator = false;
scrollView.pagingEnabled = true;

Ajoutez ensuite vos sous-vues au défilement à un décalage égal à leur index * hauteur du défilement. C'est pour un scroller vertical:

UIView * sub = [UIView new];
sub.frame = CGRectMake(0, index * h, w, subViewHeight);
[scrollView addSubview:sub];

Si vous l'exécutez maintenant, les vues sont espacées et avec la pagination activée, elles défilent une à la fois.

Alors mettez ceci dans votre méthode viewDidScroll:

    //set vars
    int index = scrollView.contentOffset.y / h; //current index
    float y = scrollView.contentOffset.y; //actual offset
    float p = (y / h)-index; //percentage of page scroll complete (0.0-1.0)
    int subViewHeight = h-240; //height of the view
    int spacing = 30; //preferred spacing between views (if any)

    NSArray * array = scrollView.subviews;

    //cycle through array
    for (UIView * sub in array){

        //subview index in array
        int subIndex = (int)[array indexOfObject:sub];

        //moves the subs up to the top (on top of each other)
        float transform = (-h * subIndex);

        //moves them back down with spacing
        transform += (subViewHeight + spacing) * subIndex;

        //adjusts the offset during scroll
        transform += (h - subViewHeight - spacing) * p;

        //adjusts the offset for the index
        transform += index * (h - subViewHeight - spacing);

        //apply transform
        sub.transform = CGAffineTransformMakeTranslation(0, transform);
    }

Les images des sous-vues sont toujours espacées, nous les déplaçons simplement ensemble via une transformation lorsque l'utilisateur fait défiler.

De plus, vous avez accès à la variable p ci-dessus, que vous pouvez utiliser pour d'autres choses, comme l'alpha ou les transformations dans les sous-vues. Lorsque p == 1, cette page est entièrement affichée, ou plutôt elle tend vers 1.

Johnny Rockex
la source
0

Essayez d'utiliser la propriété contentInset de scrollView:

scrollView.pagingEnabled = YES;

[scrollView setContentSize:CGSizeMake(height, pageWidth * 3)];
double leftContentOffset = pageWidth - kSomeOffset;
scrollView.contentInset = UIEdgeInsetsMake(0, leftContentOffset, 0, 0);

Il faudra peut-être jouer avec vos valeurs pour obtenir la pagination souhaitée.

J'ai trouvé que cela fonctionnait plus proprement par rapport aux alternatives affichées. Le problème avec l'utilisation de la scrollViewWillEndDragging:méthode déléguée est que l'accélération pour les films lents n'est pas naturelle.

Vlad
la source
0

C'est la seule vraie solution au problème.

import UIKit

class TestScrollViewController: UIViewController, UIScrollViewDelegate {

    var scrollView: UIScrollView!

    var cellSize:CGFloat!
    var inset:CGFloat!
    var preX:CGFloat=0
    let pages = 8

    override func viewDidLoad() {
        super.viewDidLoad()

        cellSize = (self.view.bounds.width-180)
        inset=(self.view.bounds.width-cellSize)/2
        scrollView=UIScrollView(frame: self.view.bounds)
        self.view.addSubview(scrollView)

        for i in 0..<pages {
            let v = UIView(frame: self.view.bounds)
            v.backgroundColor=UIColor(red: CGFloat(CGFloat(i)/CGFloat(pages)), green: CGFloat(1 - CGFloat(i)/CGFloat(pages)), blue: CGFloat(CGFloat(i)/CGFloat(pages)), alpha: 1)
            v.frame.origin.x=CGFloat(i)*cellSize
            v.frame.size.width=cellSize
            scrollView.addSubview(v)
        }

        scrollView.contentSize.width=cellSize*CGFloat(pages)
        scrollView.isPagingEnabled=false
        scrollView.delegate=self
        scrollView.contentInset.left=inset
        scrollView.contentOffset.x = -inset
        scrollView.contentInset.right=inset

    }

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        preX = scrollView.contentOffset.x
    }

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

        let originalIndex = Int((preX+cellSize/2)/cellSize)

        let targetX = targetContentOffset.pointee.x
        var targetIndex = Int((targetX+cellSize/2)/cellSize)

        if targetIndex > originalIndex + 1 {
            targetIndex=originalIndex+1
        }
        if targetIndex < originalIndex - 1 {
            targetIndex=originalIndex - 1
        }

        if velocity.x == 0 {
            let currentIndex = Int((scrollView.contentOffset.x+self.view.bounds.width/2)/cellSize)
            let tx=CGFloat(currentIndex)*cellSize-(self.view.bounds.width-cellSize)/2
            scrollView.setContentOffset(CGPoint(x:tx,y:0), animated: true)
            return
        }

        let tx=CGFloat(targetIndex)*cellSize-(self.view.bounds.width-cellSize)/2
        targetContentOffset.pointee.x=scrollView.contentOffset.x

        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: velocity.x, options: [UIViewAnimationOptions.curveEaseOut, UIViewAnimationOptions.allowUserInteraction], animations: {
            scrollView.contentOffset=CGPoint(x:tx,y:0)
        }) { (b:Bool) in

        }

    }


}
TimWhiting
la source
0

Voici ma réponse. Dans mon exemple, un collectionViewqui a un en-tête de section est le scrollView que nous voulons lui donner un isPagingEnabledeffet personnalisé , et la hauteur de la cellule est une valeur constante.

var isScrollingDown = false // in my example, scrollDirection is vertical
var lastScrollOffset = CGPoint.zero

func scrollViewDidScroll(_ sv: UIScrollView) {
    isScrollingDown = sv.contentOffset.y > lastScrollOffset.y
    lastScrollOffset = sv.contentOffset
}

// 实现 isPagingEnabled 效果
func scrollViewWillEndDragging(_ sv: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    let realHeaderHeight = headerHeight + collectionViewLayout.sectionInset.top
    guard realHeaderHeight < targetContentOffset.pointee.y else {
        // make sure that user can scroll to make header visible.
        return // 否则无法手动滚到顶部
    }

    let realFooterHeight: CGFloat = 0
    let realCellHeight = cellHeight + collectionViewLayout.minimumLineSpacing
    guard targetContentOffset.pointee.y < sv.contentSize.height - realFooterHeight else {
        // make sure that user can scroll to make footer visible
        return // 若有footer,不在此处 return 会导致无法手动滚动到底部
    }

    let indexOfCell = (targetContentOffset.pointee.y - realHeaderHeight) / realCellHeight
    // velocity.y can be 0 when lifting your hand slowly
    let roundedIndex = isScrollingDown ? ceil(indexOfCell) : floor(indexOfCell) // 如果松手时滚动速度为 0,则 velocity.y == 0,且 sv.contentOffset == targetContentOffset.pointee
    let y = realHeaderHeight + realCellHeight * roundedIndex - collectionViewLayout.minimumLineSpacing
    targetContentOffset.pointee.y = y
}
DawnSong
la source
0

Version Swift raffinée de la UICollectionViewsolution:

  • Limite à une page par balayage
  • Assure un accrochage rapide à la page, même si votre défilement était lent
override func viewDidLoad() {
    super.viewDidLoad()
    collectionView.decelerationRate = .fast
}

private var dragStartPage: CGPoint = .zero

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    dragStartOffset = scrollView.contentOffset
}

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // Snap target offset to current or adjacent page
    let currentIndex = pageIndexForContentOffset(dragStartOffset)
    var targetIndex = pageIndexForContentOffset(targetContentOffset.pointee)
    if targetIndex != currentIndex {
        targetIndex = currentIndex + (targetIndex - currentIndex).signum()
    } else if abs(velocity.x) > 0.25 {
        targetIndex = currentIndex + (velocity.x > 0 ? 1 : 0)
    }
    // Constrain to valid indices
    if targetIndex < 0 { targetIndex = 0 }
    if targetIndex >= items.count { targetIndex = max(items.count-1, 0) }
    // Set new target offset
    targetContentOffset.pointee.x = contentOffsetForCardIndex(targetIndex)
}
Patrick Pijnappel
la source
0

Ancien fil, mais il convient de mentionner mon point de vue à ce sujet:

import Foundation
import UIKit

class PaginatedCardScrollView: UIScrollView {

    convenience init() {
        self.init(frame: CGRect.zero)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        _setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        _setup()
    }

    private func _setup() {
        isPagingEnabled = true
        isScrollEnabled = true
        clipsToBounds = false
        showsHorizontalScrollIndicator = false
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        // Asume the scrollview extends uses the entire width of the screen
        return point.y >= frame.origin.y && point.y <= frame.origin.y + frame.size.height
    }
}

De cette façon, vous pouvez a) utiliser toute la largeur de la vue de défilement pour effectuer un panoramique / glisser et b) pouvoir interagir avec les éléments qui sont hors des limites d'origine de la vue de défilement

basvk
la source
0

Pour le problème UICollectionView (qui pour moi était un UITableViewCell d'une collection de cartes à défilement horizontal avec des "tickers" de la carte à venir / précédente), j'ai juste dû renoncer à utiliser la pagination native d'Apple. La solution github de Damien a fonctionné à merveille pour moi. Vous pouvez modifier la taille du tickler en augmentant la largeur de l'en-tête et en la redimensionnant dynamiquement à zéro lors du premier index afin de ne pas vous retrouver avec une grande marge vide

David
la source