Tutorial :Paging UIScrollView in increments smaller than frame size



Question:

I have a scroll view that is the width of the screen but only about 70 pixels high. It contains many 50 x 50 icons (with space around them) that I want the user to be able to choose from. But I always want the scroll view to behave in a paged manner, always stopping with an icon in the exact center.

If the icons were the width of the screen this wouldn't be a problem because the UIScrollView's paging would take care of it. But because my little icons are much less than the content size, it doesn't work.

I've seen this behavior before in an app call AllRecipes. I just don't know how to do it.

Any ideas about how to get paging on a per-icon sized basis to work?


Solution:1

Try making your scrollview less than the size of the screen (width-wise), but uncheck the "Clip Subviews" checkbox in IB. Then, overlay a transparent, userInteractionEnabled = NO view on top of it (at full width), which overrides hitTest:withEvent: to return your scroll view. That should give you what you're looking for. See this answer for more details.


Solution:2

There is also another solution wich is probably a little bit better than overlaying scroll view with another view and overriding hitTest.

You can subclass UIScrollView and override its pointInside. Then scroll view can respond for touches outside its frame. Of course the rest is the same.

@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  


Solution:3

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


Solution:4

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


Solution:5

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.


Solution:6

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.


Solution:7

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.


Solution:8

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


Solution:9

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.


Solution:10

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.


Solution:11

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


Solution:12

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  }  


Solution:13

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

Note:If u also have question or solution just comment us below or mail us on toontricks1994@gmail.com
Previous
Next Post »