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

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

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

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

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

14 ответов

Решение

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

Есть и другое решение, которое, вероятно, немного лучше, чем наложение прокрутки на другое представление и переопределение 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

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

  1. Уменьшите размер scrollView Размер ScrollView меньше родительского

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

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

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

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

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

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

Я успешно добился этого с помощью метода, описанного Ноем Уизерспуном. Я работал над прыжковым поведением, просто не вызывая 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 чтобы поймать все дела.

Посмотрите на -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,

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

Уточненная версия Swift 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)
}

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

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.

Вот мой ответ. В моем примере 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
}

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

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

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

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

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

        }

    }


}

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

scrollView.pagingEnabled = YES;

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

Может потребоваться немного поиграться с вашими ценностями, чтобы достичь желаемого пейджинга.

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

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

Другие вопросы по тегам