How can I force a UIScrollView
in which paging and scrolling are on to only move vertically or horizontally at a given moment?
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.
My view consists of 10 horizontal views, and some of those views consist of multiple vertical views, so you'll get something like this:
With paging enabled on both horizontal and vertical axis
The view also has the following attributes:
Now to prevent diagonal scrolling do this:
Works like a charme for me!
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 needpagingEnabled=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 insidetouchesBegan
,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:
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)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
andtouchesCanceled
events from CocoaTouch (because itshitTest
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 sendtouchesBegan
to UIScrollView later, because you cannot store thetouches
argument: it contains objects that will be mutated before the nexttouchesMoved
event, and you cannot reproduce the old state.So you have to pass
touchesBegan
to UIScrollView immediately, but you will hide any furthertouchesMoved
events from it until you decide to scroll horizontally. NotouchesMoved
means no scrolling, so this initialtouchesBegan
will do no harm. But do setdelaysContentTouches
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 forwardtouchesCancelled
andtouchesEnded
. You have to turntouchesEnded
intotouchesCancelled
, however, because UIScrollView would interprettouchesBegan
,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.
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 yourscrollViewDidScroll
delegate method.To prevent diagonal scrolling, you should first try remembering contentOffset in
scrollViewWillBeginDragging
, and checking and resetting contentOffset insidescrollViewDidScroll
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 andreyvit@gmail.com, I'd happily include it into my ScrollingMadness bag of tricks.
P.P.S. Adding this little essay to ScrollingMadness README.
Here is my implementation of a paged
UIScrollView
that only allow orthogonal scrolls. Be sure to setpagingEnabled
toYES
.PagedOrthoScrollView.h
PagedOrthoScrollView.m
What I did was create a paging UIScrollView with a frame the size of the view screen and set the content size to the width needed for all of my content and a height of 1 (thanks Tonetel). Then I created menu UIScrollView pages with frames set to each page, the size of the view screen, and set the content size of each to the width of the screen and the necessary height for each one. Then I simply added each of the menu pages to the paging view. Make sure paging is enabled on the paging scroll view but disabled on the menu views (should be disabled by default) and you should be good to go.
The paging scroll view now scrolls horizontally, but not vertically because of the content height. Each page scrolls vertically but not horizontally because of its content size constraints as well.
Here's the code if that explanation left something to be desired:
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.
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.