UIScrollView custom paging size

35,974

Solution 1

There is a UIScrollView delegate method you can use. Set your class as the scroll view's delegate, and then implement the following:

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
    CGFloat kMaxIndex = 23;
    CGFloat targetX = scrollView.contentOffset.x + velocity.x * 60.0;
    CGFloat targetIndex = 0.0;
    if (velocity.x > 0) {
        targetIndex = ceil(targetX / (kCellWidth + kCellSpacing));
    } else if (velocity.x == 0) {
        targetIndex = round(targetX / (kCellWidth + kCellSpacing));
    } else if (velocity.x < 0) {
        targetIndex = floor(targetX / (kCellWidth + kCellSpacing));
    }
    if (targetIndex < 0)
        targetIndex = 0;
    if (targetIndex > kMaxIndex)
        targetIndex = kMaxIndex;
    targetContentOffset->x = targetIndex * (kCellWidth + kCellSpacing);
    //scrollView.decelerationRate = UIScrollViewDecelerationRateFast;//uncomment this for faster paging
}

The velocity parameter is necessary to make sure the scrolling feels natural and doesn't end abruptly when a touch ends with your finger still moving. The cell width and cell spacing are the page width and spacing between pages in your view. In this case, I'm using a UICollectionView.

Solution 2

  1. Change your scrollView size to the page size you want
  2. Set your scroll.clipsToBounds = NO
  3. Create a UIView subclass (e.g HackClipView) and override the hitTest:withEvent: method

    -(UIView *) hitTest:(CGPoint) point withEvent:(UIEvent *)event
    {     
        UIView* child = [super hitTest:point withEvent:event]; 
        if (child == self && self.subviews.count > 0)  
        {
            return self.subviews[0];
        }
        return child;
    }
    
  4. Set the HackClipView.clipsToBounds = YES

  5. Put your scrollView in this HackClipView (with the total scrolling size you want)

See this answer for more details

Update: As stated in lucius answer you can now implement the UIScollViewDelegate protocol and use the - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset method. As the targetContentOffset is a pointer. Using this method will not guarantee you the same result with scroll view pages as the user can scroll through many pages at once. But setting the descelerationRate to fast will almost give you the same result

Solution 3

You should disable paging and add a UIPanGestureRecognizer to your scroll view and handle the paging yourself.

- (void)viewDidLoad {

    [super viewDidLoad];
    CGRect viewRect = self.view.bounds; // View controller's view bounds
    theScrollView = [[UIScrollView alloc] initWithFrame:viewRect]; 

    theScrollView.scrollsToTop      = NO;
    theScrollView.pagingEnabled         = NO;
    theScrollView.delaysContentTouches  = NO;
    theScrollView.delegate = self;

    [self.view addSubview:theScrollView];

    UIPanGestureRecognizer * peter = [[[UIPanGestureRecognizer alloc] initWithTarget:self  
                                                                              action:@selector(handlePan:)]
                                       autorelease]; 
    [theScrollView addGestureRecognizer:peter]; 

}

-(void)handlePan:(UIPanGestureRecognizer*)recognizer{

    switch (recognizer.state) {
    case UIGestureRecognizerStateBegan:{
        // panStart and startPoint are instance vars for the viewContainer 
        panStart = theScrollView.contentOffset;
        startPoint = [recognizer locationInView:theScrollView]; 
        
        
        break;
    }
    case UIGestureRecognizerStateChanged:{
                    
        CGPoint newPoint = [recognizer locationInView:theScrollView];
        CGFloat delta = startPoint.x - newPoint.x;
        if ( abs(delta) > 2)
            theScrollView.contentOffset = CGPointMake( theScrollView.contentOffset.x + delta, 0); 
        
        CGFloat moveDelta = panStart.x - theScrollView.contentOffset.x;                               
        

        // current witdh should hold the currently displayed page/view in theScrollView
        if ( abs(moveDelta) > (currentWidth * 0.40)){
            panStart = theScrollView.contentOffset;
            startPoint = newPoint;
            
            //NSLog(@"delta is bigger"); 
            if ( moveDelta < 0 )
                [self incrementPageNumber]; // you should implement this method and present the next view
            else 
                [self decrementPageNumber]; // you should implement this method and present the previous view
   
            recognizer.enabled = NO; // disable further event until view change finish
                 
        }
        
        break; 
    }
        
    case UIGestureRecognizerStateEnded:
    case UIGestureRecognizerStateCancelled:

        recognizer.enabled = YES; 
        [self showDocumentPage:currentPage]; 
        
        break;
        
        
    default:
        break;
    }
}

Solution 4

Swift 4.1 solution that simplifies reusing:

/// Protocol that simplifies custom page size configuration for UIScrollView.
/// Sadly, can not be done better due to protocol extensions limitations - https://stackoverflow.com/questions/39487168/non-objc-method-does-not-satisfy-optional-requirement-of-objc-protocol
/// - note: Set `.decelerationRate` to `UIScrollViewDecelerationRateFast` for a fancy scrolling animation.
protocol ScrollViewCustomHorizontalPageSize: UIScrollViewDelegate {
    /// Custom page size
    var pageSize: CGFloat { get }

    /// Helper method to get current page fraction
    func getCurrentPage(scrollView: UIScrollView) -> CGFloat

    /// Helper method to get targetContentOffset. Usage:
    ///
    ///     func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    ///         targetContentOffset.pointee.x = getTargetContentOffset(scrollView: scrollView, velocity: velocity)
    ///     }
    func getTargetContentOffset(scrollView: UIScrollView, velocity: CGPoint) -> CGFloat

    /// Must be implemented. See `getTargetContentOffset` for more info.
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
}

extension ScrollViewCustomHorizontalPageSize {
    func getCurrentPage(scrollView: UIScrollView) -> CGFloat {
        return (scrollView.contentOffset.x + scrollView.contentInset.left) / pageSize
    }

    func getTargetContentOffset(scrollView: UIScrollView, velocity: CGPoint) -> CGFloat {
        let targetX: CGFloat = scrollView.contentOffset.x + velocity.x * 60.0

        var targetIndex = (targetX + scrollView.contentInset.left) / pageSize
        let maxOffsetX = scrollView.contentSize.width - scrollView.bounds.width + scrollView.contentInset.right
        let maxIndex = (maxOffsetX + scrollView.contentInset.left) / pageSize
        if velocity.x > 0 {
            targetIndex = ceil(targetIndex)
        } else if velocity.x < 0 {
            targetIndex = floor(targetIndex)
        } else {
            let (maxFloorIndex, lastInterval) = modf(maxIndex)
            if targetIndex > maxFloorIndex {
                if targetIndex >= lastInterval / 2 + maxFloorIndex {
                    targetIndex = maxIndex
                } else {
                    targetIndex = maxFloorIndex
                }
            } else {
                targetIndex = round(targetIndex)
            }
        }

        if targetIndex < 0 {
            targetIndex = 0
        }

        var offsetX = targetIndex * pageSize - scrollView.contentInset.left
        offsetX = min(offsetX, maxOffsetX)

        return offsetX
    }
}

Just conform to ScrollViewCustomPageSize protocol in your UIScrollView/UITableView/UICollectionView delegate and you are done, e.g.:

extension MyCollectionViewController: ScrollViewCustomPageSize {
    var pageSize: CGFloat {
        return 200
    }

    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        targetContentOffset.pointee.x = getTargetContentOffset(scrollView: scrollView, velocity: velocity)
    }
}

For a fancy scrolling I also recommend to set collectionView.decelerationRate = UIScrollViewDecelerationRateFast

Solution 5

I had the same problem so I have made a custom UIScrollView. It's available on Github now because when I searched I didn't find any solutions like this. Enjoy! https://github.com/MartinMetselaar/MMCPSScrollView

MMCPSScrollView* scrollView = [[MMCPSScrollView alloc] initWithFrame:self.view.bounds];
[scrollView setType:MMCPSScrollVertical];
[scrollView setPageHeight:250];
[scrollView setPageSize:2];
[self.view addSubview:scrollView];

If you have any further questions about this component, just ask.

Share:
35,974

Related videos on Youtube

user784625
Author by

user784625

Updated on July 09, 2022

Comments

  • user784625
    user784625 almost 2 years

    paging in UIScrollView is a great feature, what I need here is to set the paging to a smaller distance, for example I want my UIScrollView to page less size that the UIScrollView frame width. Thanks

  • user784625
    user784625 almost 13 years
    it does not help me, I want something animated
  • user784625
    user784625 almost 13 years
    it does not help me, I'm already have 2 UIScrollViews neasted
  • Joshua
    Joshua almost 11 years
    This is a bad answer. If you make the scrollview smaller then the touchable area is also shrunk to the frame you set. The question specifically asks how to handle "a page less than the UIScrollView frame width".
  • Francescu
    Francescu almost 11 years
    @Joshua, no, by overriding the hitTest method on the superview you can redirect the touch on the scrollView child.
  • thgc
    thgc over 10 years
    I upvoted because this is a different and functioning solution and provides more customizability to the scrolling behavior.
  • Frank Schmitt
    Frank Schmitt almost 10 years
    @Francescu @Joshua or override pointInside:withEvent: in a scroll view subclass.
  • nikans
    nikans almost 10 years
    Terribly bad idea in most cases
  • Kevin Hirsch
    Kevin Hirsch almost 10 years
    If you want to avoid the scrollview jumping to its initial position when making very small swipes, you may use this : if (velocity.x > 0) { targetIndex = ceil(targetX / (self.cardViewWidth + kCardSpacing)); } else { targetIndex = floor(targetX / (self.cardViewWidth + kCardSpacing)); }
  • pash3r
    pash3r over 9 years
    hi, @lucius! Your code seems to work but I have 1 problem! My pageSize = 340. TargetContentOffset.x calculates correctly, but my scrollView stops at the wrong position so I see margin between views and view (or cell, doesn't matter) (but it has to stop in right position). Maybe u can tell something about it? :) Thanks in advance!
  • pash3r
    pash3r over 9 years
    @lucious, I've figured it out: I had to set scrollView.pagingEnabled to NO :)
  • KoCMoHaBTa
    KoCMoHaBTa over 9 years
    Why targetX expression is multiplied by 60 at the end? This is the only part that i cannot understand. From where does this magic number comes?
  • Martin
    Martin over 8 years
    Won't work with UITableView & UICollectionView, cause the cells out of the original bounds (normally "hidden" but visible thanks to clipToBounds=NO) will be removed from superview.
  • Морт
    Морт over 7 years
    @KoCMoHaBTa "velocity is in points/millisecond" as per the documentation, so this takes into account the force of the drag, by adding the distance that the scroll view would travel in the next 60ms. More powerful drags will scroll further.
  • MLQ
    MLQ over 7 years
    @lucius But how do we know that the deceleration animation will run for exactly 60ms?
  • Robert
    Robert over 6 years
    From trial and error I found that a constant of 900 instead of 60 avoids a glitch when you scroll a small distance very fast.
  • Max Chuquimia
    Max Chuquimia over 6 years
    Don't forget to have a play with scrollView.decelerationRate to get the feel you are looking for - I had to set it to UIScrollViewDecelerationRateFast before it felt natural
  • Fantini
    Fantini about 6 years
    I think that you should elaborate a bit more. Why does your code works?
  • codrut
    codrut almost 6 years
    Tried with a UICollectionView and the cells from outside the visible area are hidden/reused, except the next one in the direction of scrolling. Did not succeed to trick it to cache more cells. Also, drag touch works only if it starts inside the small scrollView
  • mkz
    mkz about 5 years
    is there a way to lock scroll to only one page per action? using following approach I sometimes can scroll though two pages, but want only one in each direction. sometimes now: 0 - 1 - 3 - 4, want : 0 - 1 - 2 - 3 - 4
  • Anton Plebanovich
    Anton Plebanovich about 5 years
    @mkz hi, you can just clamp targetIndex before calculating offsetX in getTargetContentOffset method. I didn't test but something like that should work: targetIndex = max(targetIndex, floor(getCurrentPage(scrollView: scrollView)); targetIndex = min(targetIndex, ceil(getCurrentPage(scrollView: scrollView))
  • krishan kumar
    krishan kumar about 5 years
    It will be more helpful, if you give swift implementation also. Because i am not able to assign value to "targetContentOffset"
  • Albert Renshaw
    Albert Renshaw almost 5 years
    It seems the suggestion by @KevinHirsch is not just a preference but an actual fix to a bug in the behavior of the script by Lucious, I'm therefor editing it in to the answer. While I'm at it I added optional (commented out) high value snippets from the comments.
  • Albert Renshaw
    Albert Renshaw almost 5 years
    Additionally, Kevin's solution has a small issue in which if you page to the right, then over half way through the paging animation, if you 'tap' the scrollview w/o swiping at all, it will launch you back to the previous page. I've fixed this by adding a conditional to use round if no velocity.x was found (i.e. tap not swipe)
  • Alen Liang
    Alen Liang over 4 years
    @MaxChuquimia Your comment saved my day!
  • bruno
    bruno over 3 years
    The methods are triggered but there's no limit in the width. Do you have an example?
  • Anton Plebanovich
    Anton Plebanovich over 3 years
    @bruno you may check github.com/APUtils/APExtensions/tree/… there is a custom page size example
  • stefanosn
    stefanosn about 2 years
    @AlbertRenshaw What if the cell has dynamic height? How do i get the height of each UITableViewcell from my UItableview in scrollViewWillEndDragging so i can use a dynamic kCellHeight? I need this because not all cells have the same height in my tableview...any help appreciated.