views:

6268

answers:

11

How can I force a UIScrollView in which paging and scrolling are on to move vertically or horizontally only?

My understanding is that the directionalLockEnabled property should achieve this, but a diagonal swipe still causes the view to scroll diagonally instead of restricting motion to a single axis.

Edit: to be clearer, I'd like to allow the user to scroll horizontally OR vertically, but not both simultaneously.

A: 

If you are using interface builder to design your interface - this could be done quite easily.

In interface builder simply click on the UIScrollView and select the option (in the inspector) that limits it to only one way scrolling.

zPesk
There are no options in IB to do this. The two options you may be referring to have to do with display of the scroll bar, not the actual scrolling. Furthermore, the Direction Lock only locks the direction once the scrolling has begun.
Sophtware
+1  A: 

From the docs:

"the default value is NO, which means that scrolling is permitted in both horizontal and vertical directions. If the value is YES and the user begins dragging in one general direction (horizontally or vertically), the scroll view disables scrolling in the other direction."

I think the important part is "if... the user begins dragging in one general direction". So if they begin dragging diagonally this doesn't kick in. Not that these docs are always reliable when it comes to interpretation - but that does seem to fit with what you are seeing.

This actually seems sensible. I have to ask - why do you want to restrict to only horizontal or only vertical at any time? Perhaps UIScrollView is not the tool for you?

Phil Nash
A: 

Perhaps UIScrollView is not the tool for you?

You might be right -- it's certainly proving tough to adapt for this app.

I have to ask - why do you want to restrict to only horizontal or only vertical at any time?

I'm displaying data over several pages, each of which needs to be able to scroll vertically.

I initially thought about embedding 4 UIScrollViews within the main UIScrollView, but intercepting touch events becomes fiddly, especially because the nested scrollview on each page will contain draggable items.

I've currently got a single large scrollview with directionalLockEnabled, and hoped that scrolling would be fixed to the x or y axis only, but swiping diagonally pages across as well as scrolling down, which is somewhat disorientating.

If there's a better way to accomplish a similar effect with or without UIScrollView, I'd be thrilled to hear of a solution.

NickD
Nick, it's best to edit your question to add clarifications like this, not to post them as an answer — your clarification is *not* an answer to your question. Let's keep this community site clean!
Andrey Tarantsov
+20  A: 

You're in a very tough situation, I must say.

Note that you need to use a UIScrollView with pagingEnabled=YES to switch between pages, but you need pagingEnabled=NO to scroll vertically.

There are 2 possible strategies. I don't know which one will work / is easier to implement, so try both.

First: nested UIScrollViews. Frankly, I'm yet to see a person who has got this to work. However I have not tried hard enough personally, and my practise shows that when you do try hard enough, you can make UIScrollView do anything you want.

So the strategy is to let the outer scroll view only handle horizontal scrolling, and inner scroll views to only handle vertical scrolling. To accomplish that, you must know how UIScrollView works internally. It overrides hitTest method and always returns itself, so that all touch events go into UIScrollView. Then inside touchesBegan, touchesMoved etc it checks if it's interested in the event, and either handles or passes it on to the inner components.

To decide if the touch is to be handled or to be forwarded, UIScrollView starts a timer when you first touch it:

  • If you haven't moved your finger significantly within 150ms, it passes the event on to the inner view.

  • If you have moved your finger significantly within 150ms, it starts scrolling (and never passes the event to the inner view).

    Note how when you touch a table (which is a subclass of scroll view) and start scrolling immediately, the row that you touched is never highlighted.

  • If you have not moved your finger significantly within 150ms and UIScrollView started passing the events to the inner view, but then you have moved the finger far enough for the scrolling to begin, UIScrollView calls touchesCancelled on the inner view and starts scrolling.

    Note how when you touch a table, hold your finger a bit and then start scrolling, the row that you touched is highlighted first, but de-highlighted afterwards.

These sequence of events can be altered by configuration of UIScrollView:

  • If delaysContentTouches is NO, then no timer is used — the events immediately go to the inner control (but then are canceled if you move your finger far enough)
  • If cancelsTouches is NO, then once the events are sent to a control, scrolling will never happen.

Note that it is UIScrollView that receives all touchesBegin, touchesMoved, touchesEnded and touchesCanceled events from CocoaTouch (because its hitTest tells it to do so). It then forwards them to the inner view if it wants to, as long as it wants to.

Now that you know everything about UIScrollView, you can alter its behavior. I can bet you want to give preference to vertical scrolling, so that once the user touches the view and starts moving his finger (even slightly), the view starts scrolling in vertical direction; but when the user moves his finger in horizontal direction far enough, you want to cancel vertical scrolling and start horizontal scrolling.

You want to subclass your outer UIScrollView (say, you name your class RemorsefulScrollView), so that instead of the default behaviour it immediately forwards all events to the inner view, and only when significant horizontal movement is detected it scrolls.

How to do make RemorsefulScrollView behave that way?

  • It looks like disabling vertical scrolling and setting delaysContentTouches to NO should make nested UIScrollViews to work. Unfortunately, it does not; UIScrollView appears to do some additional filtering for fast motions (which cannot be disabled), so that even if UIScrollView can only be scrolled horizontally, it will always eat up (and ignore) fast enough vertical motions.

    The effect is so severe that vertical scrolling inside a nested scroll view is unusable. (It appears that you have got exactly this setup, so try it: hold a finger for 150ms, and then move it in vertical direction — nested UIScrollView works as expected then!)

  • This means you cannot use UIScrollView's code for event handling; you have to override all four touch handling methods in RemorsefulScrollView and do your own processing first, only forwarding the event to super (UIScrollView) if you have decided to go with horizontal scrolling.

  • However you have to pass touchesBegan to UIScrollView, because you want it to remember a base coordinate for future horizontal scrolling (if you later decide it is a horizontal scrolling). You won't be able to send touchesBegan to UIScrollView later, because you cannot store the touches argument: it contains objects that will be mutated before the next touchesMoved event, and you cannot reproduce the old state.

    So you have to pass touchesBegan to UIScrollView immediately, but you will hide any further touchesMoved events from it until you decide to scroll horizontally. No touchesMoved means no scrolling, so this initial touchesBegan will do no harm. But do set delaysContentTouches to NO, so that no additional surprise timers interfere.

    (Offtopic — unlike you, UIScrollView can store touches properly and can reproduce and forward the original touchesBegan event later. It has an unfair advantage of using unpublished APIs, so can clone touch objects before they are mutated.)

  • Given that you always forward touchesBegan, you also have to forward touchesCancelled and touchesEnded. You have to turn touchesEnded into touchesCancelled, however, because UIScrollView would interpret touchesBegan, touchesEnded sequence as a touch-click, and would forward it to the inner view. You are already forwarding the proper events yourself, so you never want UIScrollView to forward anything.

Basically here's pseudocode for what you need to do. For simplicity, I never allow horizontal scrolling after multitouch event has occurred.

// RemorsefulScrollView.h

@interface RemorsefulScrollView : UIScrollView {
  CGPoint _originalPoint;
  BOOL _isHorizontalScroll, _isMultitouch;
  UIView *_currentChild;
}
@end

// RemorsefulScrollView.m

// the numbers from an example in Apple docs, may need to tune them
#define kThresholdX 12.0f
#define kThresholdY 4.0f

@implementation RemorsefulScrollView

- (id)initWithFrame:(CGRect)frame {
  if (self = [super initWithFrame:frame]) {
    self.delaysContentTouches = NO;
  }
  return self;
}

- (id)initWithCoder:(NSCoder *)coder {
  if (self = [super initWithCoder:coder]) {
    self.delaysContentTouches = NO;
  }
  return self;
}

- (UIView *)honestHitTest:(CGPoint)point withEvent:(UIEvent *)event {
  UIView *result = nil;
  for (UIView *child in self.subviews)
    if ([child pointInside:point withEvent:event])
      if ((result = [child hitTest:point withEvent:event]) != nil)
        break;
  return result;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    [super touchesBegan:touches withEvent:event]; // always forward touchesBegan -- there's no way to forward it later
    if (_isHorizontalScroll)
      return; // UIScrollView is in charge now
    if ([touches count] == [[event touchesForView:self] count]) { // initial touch
      _originalPoint = [[touches anyObject] locationInView:self];
    _currentChild = [self honestHitTest:_originalPoint withEvent:event];
    _isMultitouch = NO;
    }
  _isMultitouch ||= ([[event touchesForView:self] count] > 1);
  [_currentChild touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
  if (!_isHorizontalScroll && !_isMultitouch) {
    CGPoint point = [[touches anyObject] locationInView:self];
    if (fabsf(_originalPoint.x - point.x) > kThresholdX && fabsf(_originalPoint.y - point.y) < kThresholdY) {
      _isHorizontalScroll = YES;
      [_currentChild touchesCancelled:[event touchesForView:self] withEvent:event]
    }
  }
  if (_isHorizontalScroll)
    [super touchesMoved:touches withEvent:event]; // UIScrollView only kicks in on horizontal scroll
  else
    [_currentChild touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
  if (_isHorizontalScroll)
    [super touchesEnded:touches withEvent:event];
    else {
    [super touchesCancelled:touches withEvent:event];
    [_currentChild touchesEnded:touches withEvent:event];
    }
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
  [super touchesCancelled:touches withEvent:event];
  if (!_isHorizontalScroll)
    [_currentChild touchesCancelled:touches withEvent:event];
}

@end

I have not tried to run or even to compile this (and typed the whole class in a plain text editor), but you can start with the above and hopefully get it working.

The only hidden catch I see is that if you add any non-UIScrollView child views to RemorsefulScrollView, the touch events you forward to a child may arrive back to you via responder chain, if the child does not always handle touches like UIScrollView does. A bullet-proof RemorsefulScrollView implementation would protect against touchesXxx reentry.

Second strategy: If due to some reason nested UIScrollViews do not work out or prove too hard to get right, you can try to get along with just one UIScrollView, switching its pagingEnabled property on the fly from your scrollViewDidScroll delegate method.

To prevent diagonal scrolling, you should first try remembering contentOffset in scrollViewWillBeginDragging, and checking and resetting contentOffset inside scrollViewDidScroll if you detect a diagonal movement. Another strategy to try is to reset contentSize to only enable scrolling in one direction, once you decide which direction the user's finger is going. (UIScrollView seems pretty forgiving about fiddling with contentSize and contentOffset from its delegate methods.)

If that does not work either or results in sloppy visuals, you have to override touchesBegan, touchesMoved etc and not forward diagonal movement events to UIScrollView. (The user experience will be suboptimal in this case however, because you will have to ignore diagonal movements instead of forcing them into a single direction. If you're feeling really adventurous, you can write your own UITouch lookalike, something like RevengeTouch. Objective-C is plain old C, and there's nothing more ducktypeful in the world than C; as long as noone checks the real class of the objects, which I believe noone does, you can make any class look like any other class. This opens up a possibility to synthesize any touches you want, with any coordinates you want.)

Backup strategy: there's TTScrollView, a sane reimplementation of UIScrollView in Three20 library. Unfortunately it feels very unnatural and non-iphonish to the user. But if every attempt of using UIScrollView fails, you can fall back to a custom-coded scroll view. I do recommend against it if at all possible; using UIScrollView ensures you are getting the native look-and-feel, no matter how it evolves in future iPhone OS versions.

Okay, this little essay got a little bit too long. I'm just still into UIScrollView games after the work on ScrollingMadness several days ago.

P.S. If you get any of these working and feel like sharing, please e-mail me the relevant code at [email protected], I'd happily include it into my ScrollingMadness bag of tricks.

P.P.S. Adding this little essay to ScrollingMadness README.

Andrey Tarantsov
This is a lovely explanation and worthy essay in its own right; I wish I could vote more than once.
quixoto
A: 

If you haven't tried doing this in the 3.0 beta, I recommend you give it a shot. I am currently using a series of table views inside a scroll view, and it works just as expected. (Well, the scrolling does, anyway. Found this question when looking for a fix to a totally different issue...)

Tim Keating
A: 

Need to reset _isHorizontalScroll to NO in touchesEnded and touchesCancelled.

praveen M
+1  A: 

I might be gravedigging here, but I stumbled onto this post today as I was trying to solve the same problem. Here's my solution, seems to be working great.

I want to display a screenful of content, but allow the user to scroll to one of 4 other screenfuls, up down left and right. I have a single UIScrollView with size 320x480 and a contentSize of 960x1440. The content offset starts at 320,480. In scrollViewDidEndDecelerating:, I redraw the 5 views (center views and the 4 around it), and reset the content offset to 320,480.

Here's the meat of my solution, the scrollViewDidScroll: method.

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{   
    if (!scrollingVertically && ! scrollingHorizontally)
    {
        int xDiff = abs(scrollView.contentOffset.x - 320);
        int yDiff = abs(scrollView.contentOffset.y - 480);

        if (xDiff > yDiff)
        {
            scrollingHorizontally = YES;
        }
        else if (xDiff < yDiff)
        {
            scrollingVertically = YES;
        }
    }

    if (scrollingHorizontally)
    {
        scrollView.contentOffset = CGPointMake(scrollView.contentOffset.x, 480);
    }
    else if (scrollingVertically)
    {
        scrollView.contentOffset = CGPointMake(320, scrollView.contentOffset.y);
    }
}
jenningj
Great piece of code. Worked for me! (Might want to add the fact that scrollingVertically and scrollingHorizontally need to be defined as member variables in your class.)
Greg Maletic
+2  A: 

Guys it is as simple as making the subview height the same as the scroll view height and the content size height. Then the vertical scrolling is disabled.

julianlubenov
A: 

This works for me everytime...

scrollView.contentSize = CGSizeMake(scrollView.frame.size.width * NumberOfPages, 1);

Tonetel
A: 

Thanks Tonetel. I slightly modified your approach, but it was exactly what I needed to prevent horizontal scrolling.

self.scrollView.contentSize = CGSizeMake(self.scrollView.contentSize.width, 1);

Jeremy
A: 
don't understand how this could be working for you... could you upload a sample-project?
Fossli