Paging UIScrollView dengan penambahan yang lebih kecil dari ukuran frame


87

Saya memiliki tampilan gulir yang merupakan lebar layar tetapi tingginya hanya sekitar 70 piksel. Ini berisi banyak ikon 50 x 50 (dengan spasi di sekitarnya) yang saya ingin pengguna dapat memilihnya. Tapi saya selalu ingin tampilan gulir berperilaku seperti halaman, selalu berhenti dengan ikon tepat di tengah.

Jika ikon adalah lebar layar, ini tidak akan menjadi masalah karena paging UIScrollView akan menanganinya. Tetapi karena ikon kecil saya jauh lebih kecil dari ukuran konten, itu tidak berfungsi.

Saya pernah melihat perilaku ini sebelumnya dalam panggilan aplikasi AllRecipes. Saya hanya tidak tahu bagaimana melakukannya.

Bagaimana cara membuat paging dengan ukuran per ikon agar berfungsi?

Jawaban:


123

Coba buat scrollview Anda kurang dari ukuran layar (lebar-bijaksana), tetapi hapus centang pada kotak "Clip Subviews" di IB. Kemudian, hamparkan tampilan transparan, userInteractionEnabled = NO di atasnya (dengan lebar penuh), yang menimpa hitTest: withEvent: untuk mengembalikan tampilan gulir Anda. Itu akan memberi Anda apa yang Anda cari. Lihat jawaban ini untuk lebih jelasnya.


1
Saya tidak begitu yakin apa yang Anda maksud, saya akan melihat jawaban yang Anda tunjuk.
Henning

8
Ini adalah solusi yang bagus. Saya tidak berpikir untuk mengesampingkan fungsi hitTest: dan itu cukup banyak memotong satu-satunya downside ke pendekatan ini. Selamat.
Ben Gotow

Solusi bagus! Benar-benar membantuku.
DevDevDev

8
Ini bekerja dengan sangat baik, tetapi saya merekomendasikan daripada hanya mengembalikan scrollView, kembalikan [scrollView hitTest: point withEvent: event] sehingga peristiwa dilewati dengan benar. Misalnya, dalam tampilan gulir yang penuh dengan UIButtons, tombol tersebut akan tetap berfungsi seperti ini.
greenisus

2
perhatikan, ini TIDAK akan berfungsi dengan tampilan tabel atau collectionView karena tidak akan membuat hal-hal di luar bingkai. Lihat jawaban saya di bawah ini
vish

63

Ada juga solusi lain yang mungkin sedikit lebih baik daripada melapisi tampilan gulir dengan tampilan lain dan menimpa hitTest.

Anda dapat membuat subkelas UIScrollView dan mengganti pointInside-nya. Kemudian tampilan gulir dapat merespons sentuhan di luar bingkainya. Tentu saja sisanya sama.

@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

1
yes! moreover, if your scrollview's bounds is same with its parent then you can just return yes from the pointInside method. thanks
cocoatoucher

5
I like this solution, though I have to change parentLocation to "[self convertPoint:point toView:self.superview]" for it to work properly.
amrox

You are right convertPoint is much better than what I wrote there.
Split

6
directly return [self.superview pointInside:[self convertPoint:point toView:self.superview] withEvent:event]; @amrox @Split you don't need responseInsets if you have a superview that acts as the cliping view.
Cœur

This solution seemed to break down when I would get to the end of my list of items if the last page of items didn't completely fill the page. It's kind of hard to explain, but if I'm showing 3.5 cells per page and I have 5 items then the second page would either show me 2 cells with empty space on the right, or it would snap to showing 3 cells with a leading partial cell. The problem was if I was in the state where it was showing me empty space and I selected a cell then the paging would correct itself during the nav transition... I'll post my solution below.
tyler

18

I see a lot of solutions, but they are very complex. A much easier way to have small pages but still keep all area scrollable, is to make the scroll smaller and move the scrollView.panGestureRecognizer to your parent view. These are the steps:

  1. Reduce your scrollView sizeScrollView size is smaller than parent

  2. Make sure your scroll view is paginated and does not clip subview enter image description here

  3. In code, move the scrollview pan gesture to the parent container view that is full width:

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

This is a greeeeeeat solution. Very smart.
matteopuc

This is actually what I want!
LYM

Very smart! You saved me hours. Thank you!
Erik

6

The accepted answer is very good, but it will only work for the UIScrollView class, and none of its descendants. For instance if you have lots of views and convert to a UICollectionView, you will not be able to use this method, because the collection view will remove views that it thinks are "not visible" (so even though they aren't clipped, they will disappear).

The comment about that mentions scrollViewWillEndDragging:withVelocity:targetContentOffset: is, in my opinion, the correct answer.

What you can do is, inside this delegate method you calculate the current page/index. Then you decide whether the velocity and target offset merit a "next page" movement. You can get pretty close to the pagingEnabled behavior.

note: I'm usually a RubyMotion dev these days, so someone please proof this Obj-C code for correctness. Sorry for the mix of camelCase and snake_case, I copy&pasted much of this 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;
    }
} 

haha now I wonder if I shouldn't have mentioned RubyMotion - it's getting me downvoted! no actually i wish downvotes also included some commentary, I would love to know what people find wrong with my explanation.
colinta

Can you include the definition for convertXToIndex: and convertIndexToX:?
mr5

Not complete. When you drag slowly and lift your hand with velocity is zero, it will bounce back, which is strange. So I have a better answer.
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 is the width you want your page to be. kPageOffset is if you don't want the cells to be left aligned (i.e. if you want them to be center aligned, set this to half the width of your cell). Otherwise, it should be zero.

This will also only allow scrolling one page at a time.


3

Take a look at the -scrollView:didEndDragging:willDecelerate: method on UIScrollViewDelegate. Something like:

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

It isn't perfect—last I tried this code I got some ugly behavior (jumping, as I recall) when returning from a rubber-banded scroll. You might be able to avoid that by simply setting the scroll view's bounces property to NO.


3

Since I don't seem to be permitted to comment yet I'll add my comments to Noah's answer here.

I've successfully achieved this by the method that Noah Witherspoon described. I worked around the jumping behavior by simply not calling the setContentOffset: method when the scrollview is past its edges.

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

             ...
         }

I also found that I needed implement the -scrollViewWillBeginDecelerating: method in UIScrollViewDelegate to catch all cases.


What additional cases need to be caught?
chrish

the case where there was no dragging at all. The user lifts the finger and scrolling stops immediately.
Jess Bowers

3

I tried out the solution above that overlayed a transparent view with pointInside:withEvent: overridden. This worked pretty well for me, but broke down for certain cases - see my comment. I ended up just implementing the paging myself with a combination of scrollViewDidScroll to track the current page index and scrollViewWillEndDragging:withVelocity:targetContentOffset and scrollViewDidEndDragging:willDecelerate to snap to the appropriate page. Note, the will-end method is only available iOS5+, but is pretty sweet for targeting a particular offset if the velocity != 0. Specifically, you can tell the caller where you want the scroll view to land with animation if there's velocity in a particular direction.


0

When creating the scrollview, make sure you set this:

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

Then add your subviews to the scroller at an offset equal to their index * height of the scroller. This is for a vertical scroller:

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

If you run it now the views are spaced out, and with paging enabled they scroll on one at a time.

So then put this in your viewDidScroll method:

    //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);
    }

The frames of the subviews are still spaced out, we're just moving them together via a transform as the user scrolls.

Also, you have access to the variable p above, which you can use for other things, like alpha or transforms within the subviews. When p == 1, that page is fully being shown, or rather it tends towards 1.


0

Try use the contentInset property of the scrollView:

scrollView.pagingEnabled = YES;

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

It may take some playing around with your values to achieve desired paging.

I have found this to work more cleanly compared to alternatives posted. Problem with using scrollViewWillEndDragging: delegate method is the acceleration for slow flicks is not natural.


0

This is the only real solution to the problem.

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

        }

    }


}

0

Here is my answer. In my example, a collectionView which has a section header is the scrollView that we want to make it has custom isPagingEnabled effect, and cell's height is a constant value.

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
}

0

Refined Swift version of the UICollectionView solution:

  • Limits to one page per swipe
  • Ensures fast snap to page, even if your scroll was slow
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)
}

0

Old thread, but worth mentioning my take on this:

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

This way you can a) use the entire width of the scrollview to pan / swipe and b) be able to interact with the elements that are out of the scrollview's original bounds


0

For the UICollectionView issue (which for me was a UITableViewCell of a collection of horizontally scrolling cards with "tickers" of the upcoming / prior card), I just had to give up on using Apple's native paging. Damien's github solution worked awesomely for me. You can tweak the tickler size by upping the header width and dynamically sizing it to zero when at the first index so you don't end up with a large blank margin

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.