14

My question has to do with a custom form of paging which I am trying to do with a scroller, and this is easier to visualise if you first consider the type of scroll view implemented in a slot machine.

So say my UIScrollView has a width of 100 pixels. Assume it contains 3 inner views, each with a width of 30 pixels, such that they are separated by a width of 3 pixels. The type of paging which I would like to achieve, is such that each page is one of my views (30 pixels), and not the whole width of the scroll view.

I know that usually, if the view takes up the whole width of the scroll view, and paging is enabled then everything works. However, in my custom paging, I also want surrounding views in the scroll view to be visible as well.

How would I do this?

Matjan
  • 3,591
  • 1
  • 33
  • 31
Olshansky
  • 5,904
  • 8
  • 32
  • 47
  • Can you use UIPickerView?. It does this paging stuff. –  Aug 04 '11 at 17:42
  • 1
    Check out Beginning iPhone Development by Mark & laMarche - it has a section on creating a slot machine from a UIPickerView - it's not a UIScrollView but maybe this will do what you want? – amergin Aug 04 '11 at 18:26
  • I looked into UIPickerViews, and I spent quite a bit of time trying to make it horizontal and doing other customizations to fit my needs. However, the limitation of not being able to change the height (no matter how hard I tried) does not allow me to do some of the things I'm trying. What I practically need to do is get a customizable UIScrollView. At the moment I'm trying to cheat my way through by having several UIScrollViews beside each other, and set off their contents appropriately. – Olshansky Aug 04 '11 at 20:36

7 Answers7

26

I just did this for another project. What you need to do is to place the UIScrollView into a custom implementation of UIView. I created a class for this called ExtendedHitAreaViewController. The ExtendedHitAreaView overrides the hitTest function to return its first child object, which will be your scroll view.

Your scroll view should be the page size you want, i.e., 30px with clipsToBounds = NO. The extended hit area view should be the full size of the area you want to be visible, with clipsToBounds = YES.

Add the scroll view as a subview to the extended hit area view, then add the extended hit area view to your viewcontroller's view.

@implementation ExtendedHitAreaViewContainer

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if ([self pointInside:point withEvent:event]) {
        if ([[self subviews] count] > 0) {
            //force return of first child, if exists
            return [[self subviews] objectAtIndex:0];
        } else {
            return self;
        }
    }
    return nil;
}
@end
picciano
  • 22,341
  • 9
  • 69
  • 82
  • Amazing!!! This did exactly what I was trying to achieve. Before I saw this answer, I had actually almost completed my implementation which involved having 3 scrollviews (because I only wanted to have 3 items visible at a time), and I would set their content offset appropriately when any one of the 3 scrollvies was scrolled. It kind of worked but was still relatively buggy. I'm also going to go out on a limp here and ask if you have any idea how I could achieve a cover flow view effect with my scroller. This isn't something I necessarily need to do but thought would be kind of cool. Thanks!! – Olshansky Aug 07 '11 at 16:19
  • If I want to add buttons to the scroll view, meaning I'll add UIButtons to the extended area container in the same locations as the pictures, how would I return the correct button to the receiver when it is pressed? – Olshansky Aug 08 '11 at 18:50
  • 1
    with the UIButtons, you might try returning [[[self subviews] objectAtIndex:0] hitTest:point withEvent:event] instead. Haven't tried that, but that may be an even better solution. Delegate authority to the scrollview. – picciano Aug 09 '11 at 16:08
  • If `scrollView` has a header or footer, your solution does NOT work. – DawnSong Sep 15 '17 at 13:05
  • 1
    The answer is over 6 years old, not surprised some things have changed. You're welcome to update it. – picciano Sep 15 '17 at 14:40
14

Since iOS 5 there is this delegate method: - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset.

So you can do something like this:

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint
*)targetContentOffset {
    if (scrollView == self.scrollView) {
        CGFloat x = targetContentOffset->x;
        x = roundf(x / 30.0f) * 30.0f;
        targetContentOffset->x = x;
    } 
}

For higher velocities you might want to adjust your targetContentOffset a bit different if you want a more snappy feeling.

Johan Kool
  • 15,637
  • 8
  • 64
  • 81
  • How does this work if we want the views that are outside the bounds of the scrollview to be interact-able? The scrollview would never even get those touches, right? – yuf Oct 12 '12 at 17:36
  • Make sure that `pagingEnabled` is disabled, or else this will not work. – Iulian Onofrei Sep 11 '19 at 07:27
4

I had the same problem and this worked great for me, tested on iOS 8.

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
                 withVelocity:(CGPoint)velocity
          targetContentOffset:(inout CGPoint *)targetContentOffset
{
    NSInteger index = lrint(targetContentOffset->x/_pageWidth);
    NSInteger currentPage = lrint(scrollView.contentOffset.x/_pageWidth);

    if(index == currentPage) {
        if(velocity.x > 0)
            index++;
        else if(velocity.x < 0)
            index--;
    }

    targetContentOffset->x = index * _pageWidth;
}

I had to check the velocity and always go to next/previous page if velocity was not zero, otherwise it would give non-animated jumps when doing very short and fast swipes.

Update: This seems to work even better:

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
                 withVelocity:(CGPoint)velocity
          targetContentOffset:(inout CGPoint *)targetContentOffset
{
    CGFloat index = targetContentOffset->x/channelScrollWidth;

    if(velocity.x > 0)
        index = ceil(index);
    else if(velocity.x < 0)
        index = floor(index);
    else
        index = round(index);

    targetContentOffset->x = index * channelScrollWidth;
}

Those are for a horizontal scrollview, use y instead of x for a vertical one.

3

I've been struggling to overcome this issue, and I found an almost perfect solution which is to ideal with and paging width you want.

I'd set scrollView.isPaging to false (meanwhile, it's false by default) from UIScrollView and set its delegate to UIScrollViewDelegate.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    // Stop scrollView sliding:
    targetContentOffset.pointee = scrollView.contentOffset

    if scrollView == scrollView {
        let maxIndex = slides.count - 1
        let targetX: CGFloat = scrollView.contentOffset.x + velocity.x * 60.0
        var targetIndex = Int(round(Double(targetX / (pageWidth + spacingWidth))))
        var additionalWidth: CGFloat = 0
        var isOverScrolled = false

        if targetIndex <= 0 {
            targetIndex = 0
        } else {
            // in case you want to make page to center of View
            // by substract width with this additionalWidth
            additionalWidth = 20
        }

        if targetIndex > maxIndex {
            targetIndex = maxIndex
            isOverScrolled = true
        }

        let velocityX = velocity.x
        var newOffset = CGPoint(x: (CGFloat(targetIndex) * (self.pageWidth + self.spacingWidth)) - additionalWidth, y: 0)
        if velocityX == 0 {
            // when velocityX is 0, the jumping animation will occured
            // if we don't set targetContentOffset.pointee to new offset
            if !isOverScrolled &&  targetIndex == maxIndex {
                newOffset.x = scrollView.contentSize.width - scrollView.frame.width
            }
            targetContentOffset.pointee = newOffset
        }

        // Damping equal 1 => no oscillations => decay animation:
        UIView.animate(
            withDuration: 0.3, delay: 0,
            usingSpringWithDamping: 1,
            initialSpringVelocity: velocityX,
            options: .allowUserInteraction,
            animations: {
                scrollView.contentOffset = newOffset
                scrollView.layoutIfNeeded()
        }, completion: nil)
    }
}

slides contains all page views that you have inserted to UIScrollView.

1

I have many different views inside scroll view with buttons and gesture recognisers.

@picciano's answer didn't work (scroll worked good but buttons and recognisers didn't get touches) for me so I found this solution:

class ExtendedHitAreaView : UIScrollView {
    // Your insets
    var hitAreaEdgeInset = UIEdgeInsets(top: 0, left: -20, bottom: 0, right: -20) 

    override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool {
        let hitBounds = UIEdgeInsetsInsetRect(bounds, hitAreaEdgeInset)
        return CGRectContainsPoint(hitBounds, point)
    }
}
eilas
  • 373
  • 6
  • 17
  • Works with iOS 11. Had the same problem, a gesture recognizer was blocked by @picciano solution. This solution instead blocks nothing _and_ catches the ScrollView scrolling gesture. – Jan Apr 22 '18 at 15:43
0

After a couple of days of researching and troubleshooting i came up with something that works for me!

First you need to subclass the view that the scrollview is in and override this method with the following:

    -(UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {
          UIView* child = nil;
          if ((child = [super hitTest:point withEvent:event]) == self)
                 return (UIView *)_calloutCell;
           return child;
    }

Then all the magic happens in the scrollview delegate methods

    -(void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
            //_lastOffset is declared in the header file
            //@property (nonatomic) CGPoint lastOffset;
            _lastOffset = scrollView.contentOffset;
     }

    - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {

            CGPoint currentOffset = scrollView.contentOffset;
            CGPoint newOffset = CGPointZero;

            if (_lastOffset.x < currentOffset.x) {
                // right to left
                newOffset.x = _lastOffset.x + 298;
            }
            else {
                 // left to right
                 newOffset.x = _lastOffset.x - 298;
            }

            [UIView beginAnimations:nil context:NULL];
            [UIView setAnimationDuration:0.5];
            [UIView setAnimationDelay:0.0f];

            targetContentOffset->x = newOffset.x;
            [UIView commitAnimations];

      }

You also need to set the scrollview's deceleration rate. I did it in ViewDidLoad

    [self.scrollView setDecelerationRate:UIScrollViewDecelerationRateFast];
  • 1
    what effect does `-[UIView beginAnimations:]` etc have in this context? aren't you just changing the value of a struct, changes that Core Animation will never actually see? – Stefan Fisk Jun 10 '15 at 08:27
-1

alcides' solution works perfectly. i just enable / disable the scrolling of the scrollview, whenever i enter scrollviewDidEndDragging and scrollViewWillEndDragging. if the user scrolls several times before the paging animation is finished, the cells are slightly out of alignment.

so i have:

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {

    scrollView.scrollEnabled = NO

    CGPoint currentOffset = scrollView.contentOffset;
    CGPoint newOffset = CGPointZero;

    if (_lastOffset.x < currentOffset.x) {
        // right to left
        newOffset.x = _lastOffset.x + 298;
    }
    else {
        // left to right
        newOffset.x = _lastOffset.x - 298;
    }

    [UIView animateWithDuration:0.4 animations:^{
        targetContentOffset.x = newOffset.x
    }];
}

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView {
    scrollView.scrollEnabled = YES
}
helkarli
  • 131
  • 1
  • 8