Пейджинг UIScrollView с шагом меньше размера кадра

У меня есть вид прокрутки, ширина которого составляет всего около 70 пикселей. Он содержит много значков 50 х 50 (с пробелами вокруг них), которые я хочу, чтобы пользователь мог выбирать. Но я всегда хочу, чтобы представление прокрутки работало в постраничной манере, всегда останавливаясь с иконкой в точном центре.

Если бы значки были шириной экрана, это не было бы проблемой, потому что подкачка UIScrollView позаботится об этом. Но поскольку мои маленькие значки намного меньше размера контента, это не работает.

Я видел это раньше в приложении AllRecipes. Я просто не знаю, как это сделать.

Как заставить работать пейджинг на основе размера значка?

Ответ 1

Попробуйте сделать свой scrollview меньше, чем размер экрана (по ширине), но снимите флажок "Подвижные клипы" в IB. Затем наложите прозрачное представление userInteractionEnabled = NO поверх него (на полную ширину), которое переопределяет hitTest: withEvent: для возврата в режим прокрутки. Это должно дать вам то, что вы ищете. Подробнее см. этот ответ.

Ответ 2

Существует также другое решение, которое, вероятно, немного лучше, чем наложение вида прокрутки с другим видом и переопределение hitTest.

Вы можете подклассифицировать UIScrollView и переопределить его pointInside. Тогда просмотр прокрутки может реагировать на касания вне рамки. Конечно, остальное одно и то же.

@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

Ответ 3

Я вижу много решений, но они очень сложные. Гораздо более простой способ иметь небольшие страницы, но при этом сохранять прокручиваемость всей области, состоит в том, чтобы уменьшить прокрутку и переместить scrollView.panGestureRecognizer в родительское представление. Вот эти шаги:

  1. Уменьшите размер scrollView ScrollView size is smaller than parent

  2. Убедитесь, что ваш вид прокрутки разбит на страницы и не обрезает подпредставление enter image description here

  3. В коде переместите жест панорамирования прокрутки к представлению родительского контейнера с полной шириной:

    override func viewDidLoad() {
        super.viewDidLoad()
        statsView.addGestureRecognizer(statsScrollView.panGestureRecognizer)
    }

Ответ 4

Принятый ответ очень хорош, но он будет работать только для класса UIScrollView, и ни один из его потомков. Например, если у вас много просмотров и конвертируется в UICollectionView, вы не сможете использовать этот метод, потому что представление коллекции удалит представления, которые, по его мнению, "не видны" (так что даже если они не обрезаны, они исчезнут).

Комментарий, который упоминает scrollViewWillEndDragging:withVelocity:targetContentOffset:, является, на мой взгляд, правильным ответом.

Что вы можете сделать, внутри этого метода делегата вы вычисляете текущую страницу/индекс. Затем вы решаете, заслуживает ли смещение скорости и цели смещение "следующей страницы". Вы можете приблизиться к поведению pagingEnabled.

Примечание: в наши дни я обычно являюсь разработчиком RubyMotion, поэтому кому-то, пожалуйста, подтвердите этот код Obj-C для правильности. Извините за сочетание camelCase и snake_case, я скопировал и наклеил большую часть этого кода.

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

Ответ 5

- (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 - это ширина, которой вы хотите, чтобы ваша страница была. kPageOffset - это если вы не хотите, чтобы ячейки были выровнены влево (т.е. если вы хотите, чтобы они были выровнены по центру, установите это на половину ширины вашей ячейки). В противном случае он должен быть равен нулю.

Это также позволит прокручивать только одну страницу за раз.

Ответ 6

Взгляните на метод -scrollView:didEndDragging:willDecelerate: на UIScrollViewDelegate. Что-то вроде:

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

Это не идеально - последний раз я пробовал этот код, я получил какое-то уродливое поведение (прыгая, как я помню), когда возвращался из свитка с резиновой лентой. Возможно, вы сможете избежать этого, просто установив для свойства прокрутки bounces значение NO.

Ответ 7

Поскольку мне кажется, что мне не разрешено комментировать, я добавлю свои комментарии к Ною, ответьте здесь.

Я успешно достиг этого методом, описанным Ноем Уизерспуном. Я работал вокруг прыгающего поведения, просто не вызывая метод setContentOffset:, когда прокрутка просматривает его края.

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

             ...
         }

Я также обнаружил, что мне нужно реализовать метод -scrollViewWillBeginDecelerating: в UIScrollViewDelegate, чтобы поймать все случаи.

Ответ 8

Я опробовал решение выше, которое наложило прозрачный вид с помощью pointInside: withEvent: overridden. Это работало очень хорошо для меня, но в некоторых случаях нарушалось - см. Мой комментарий. Я закончил тем, что просто выполнил пейджинг с помощью комбинации scrollViewDidScroll, чтобы отслеживать текущий индекс страницы и scrollViewWillEndDragging: withVelocity: targetContentOffset и scrollViewDidEndDragging: willDecelerate для привязки к соответствующей странице. Обратите внимание, что метод конца будет доступен только iOS5 +, но он очень мил для таргетинга на конкретное смещение, если скорость!= 0. В частности, вы можете указать вызывающему абоненту, где вы хотите, чтобы прокрутка отображалась на земле с анимацией, если скорость в особое направление.

Ответ 9

При создании прокрутки убедитесь, что вы установили это:

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

Затем добавьте свои подпрограммы в скроллер со смещением, равным их индексу * высоты скроллера. Это для вертикального скроллера:

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

Если вы запустите его сейчас, то просмотры будут разнесены, и с включенным пейджингом они будут прокручиваться по одному за раз.

Итак, поместите это в свой метод 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);
    }

Рамки подзонов все еще разнесены, мы просто перемещаем их вместе с помощью преобразования, когда пользователь прокручивает.

Кроме того, у вас есть доступ к переменной p выше, которую вы можете использовать для других вещей, таких как альфа или преобразование в подзонах. Когда p == 1, эта страница полностью отображается, или, скорее, она стремится к 1.

Ответ 10

Попробуйте использовать свойство contentInset scrollView:

scrollView.pagingEnabled = YES;

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

Для достижения желаемого поискового вызова может потребоваться некоторая игра с вашими значениями.

Я нашел, что это работает более чисто по сравнению с опубликованными альтернативами. Проблема с использованием метода делегата scrollViewWillEndDragging: - ускорение для медленных щелчков не является естественным.

Ответ 11

Это единственное реальное решение проблемы.

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

        }

    }


}

Ответ 12

Вот мой ответ. В моем примере collectionView, у которого есть заголовок раздела, есть scrollView, который мы хотим сделать, имеет пользовательский эффект isPagingEnabled, а высота ячейки - постоянное значение.

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
}

Ответ 13

Уточненная версия UICollectionView решения UICollectionView:

  • Ограничения до одной страницы за пролистывание
  • Обеспечивает быструю привязку к странице, даже если ваша прокрутка была медленной
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)
}

Ответ 14

Старый поток, но стоит упомянуть мой взгляд на это:

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

Таким образом, вы можете а) использовать всю ширину прокрутки для панорамирования/пролистывания и б) иметь возможность взаимодействовать с элементами, находящимися за пределами первоначальной границы прокрутки

Ответ 15

Для проблемы UICollectionView (которая для меня была UITableViewCell из коллекции горизонтально прокручиваемых карт со "тикерами" будущей/предыдущей карты), мне просто пришлось отказаться от использования нативной подкачки Apple. Решение Damien GitHub работает для меня очень хорошо. Вы можете настроить размер тиклера, увеличив ширину заголовка и динамически увеличивая его до нуля при первом индексе, чтобы не иметь большого пустого поля