如何强制在分页和滚动都开启的情况下,UIScrollView
在给定时刻只能沿垂直或水平方向移动?
我理解 directionalLockEnabled
属性可以实现这一点,但是对角线滑动仍然会导致视图在两个方向上同时滚动而不是限制在单个轴上。
编辑:更明确地说,我希望允许用户水平或垂直滚动,但不能同时进行。
如何强制在分页和滚动都开启的情况下,UIScrollView
在给定时刻只能沿垂直或水平方向移动?
我理解 directionalLockEnabled
属性可以实现这一点,但是对角线滑动仍然会导致视图在两个方向上同时滚动而不是限制在单个轴上。
编辑:更明确地说,我希望允许用户水平或垂直滚动,但不能同时进行。
我必须说,你处于非常困难的境地。
请注意,在切换页面时需要使用pagingEnabled=YES
的UIScrollView,但在垂直滚动时需要pagingEnabled=NO
。
有两种可能的策略。我不知道哪个会起作用/更容易实现,所以尝试两种方法。
第一种:嵌套的UIScrollViews。坦率地说,我还没有见过一个能让这个工作的人。然而,我个人没有尝试足够努力,我的经验表明当你足够努力时,你可以让UIScrollView做任何你想做的事情。
所以策略是让外部滚动视图仅处理水平滚动,内部滚动视图仅处理垂直滚动。为了实现这一点,您必须了解UIScrollView在内部如何工作。它覆盖了hitTest
方法并总是返回自己,以便所有触摸事件都进入UIScrollView。然后在touchesBegan
、touchesMoved
等内部组件中检查它是否感兴趣,并处理或将其传递给内部组件。
为了决定是处理触摸还是转发触摸,UIScrollView在您第一次接触它时启动一个计时器:
如果您在150ms内没有显着移动手指,则将事件传递给内部视图。
如果您在150ms内显着移动了手指,则开始滚动(并且永远不会将事件传递给内部视图)。
请注意,当您触摸表格(它是滚动视图的子类)并立即开始滚动时,您触摸的行永远不会被高亮显示。
如果您在150ms内没有显着移动手指,并且UIScrollView开始将事件传递给内部视图,但然后您已经移动了手指足以开始滚动,则在内部视图上调用touchesCancelled
并开始滚动。
请注意,当您触摸表格,将手指保持一段时间,然后开始滚动时,您触摸的行首先被突出显示,但之后取消突出显示。
这些事件序列可以通过UIScrollView的配置进行修改:
delaysContentTouches
为NO,则不使用计时器 - 事件立即进入内部控件(但如果您移动手指足够远,则事件将被取消)cancelsTouches
为NO,则一旦将事件发送到控件,就不会发生滚动。注意,正是 UIScrollView 接收了 CocoaTouch 中的所有 touchesBegin、touchesMoved、touchesEnded 和 touchesCanceled 事件(因为它的 hitTest 告诉它这样做)。如果它想这样做,它会将它们转发给内部视图,只要它想。
现在你了解了 UIScrollView 的一切,你可以修改它的行为。我敢打赌你想优先进行垂直滚动,这样一旦用户触摸视图并开始移动手指(即使轻微),视图就会开始垂直方向滚动;但是当用户足够向水平方向移动手指时,你想取消垂直滚动并开始水平滚动。
您希望子类化您的外部 UIScrollView(比如说,您将其命名为 RemorsefulScrollView),以便它立即将所有事件转发到内部视图,并且仅在检测到显着的水平移动时才滚动。
如何使 RemorsefulScrollView 表现出这种方式呢?
看起来禁用垂直滚动并将 delaysContentTouches 设置为 NO 应该使嵌套的 UIScrollViews 能够工作。不幸的是,这并不是这样;UIScrollView 似乎对快速运动进行了一些额外的过滤(不能禁用),因此即使 UIScrollView 只能水平滚动,它也将始终吞噬(并忽略)足够快的垂直运动。
这种影响非常严重,嵌套滚动视图中的垂直滚动是无法使用的。(看起来您已经拥有了这个设置,所以请尝试一下:按住一个手指 150 毫秒,然后向垂直方向移动 - 嵌套的 UIScrollView 然后按预期工作!)
这意味着您不能使用 UIScrollView 的代码进行事件处理;您必须在 RemorsefulScrollView 中覆盖所有四个触摸处理方法,并首先执行自己的处理,仅当您决定进行水平滚动时才将事件转发到 super(UIScrollView)。
但是,您必须将 touchesBegan 传递给 UIScrollView,因为您希望它记住未来水平滚动的基本坐标(如果您稍后决定进行水平滚动)。您将无法以后将 touchesBegan 发送到 UIScrollView,因为您无法存储 touches 参数:它包含会在下一次 touchesMoved 事件之前被修改的对象,而您不能再现旧状态。
因此,您必须立即将 touchesBegan 传递给 UIScrollView,但是您将隐藏任何进一步的 touchesMoved 事件,直到您决定水平滚动。没有 touchesMoved 意味着没有滚动,因此这个初始的touchesBegan 不会有任何伤害。但是确实要将 delaysContentTouches 设置为 NO,以便没有其他意外计时器干扰。
(离题——与您不同,UIScrollView可以正确存储触摸,并且可以在稍后复制并转发原始的touchesBegan事件。它利用了未发布的API,因此可以在它们被改变之前克隆触摸对象。)
考虑到您总是转发touchesBegan,您还需要转发touchesCancelled和touchesEnded。但是,您必须将touchesEnded转换为touchesCancelled,因为UIScrollView会将touchesBegan、touchesEnded序列解释为触摸点击,并将其转发到内部视图。您已经自己转发了适当的事件,因此您永远不希望UIScrollView转发任何内容。
基本上这里是您需要做的伪代码。为简单起见,在多点触摸事件发生后,我从不允许水平滚动。
// 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
我并没有尝试运行或编译这个类(在纯文本编辑器中输入了整个类),但你可以从以上内容开始,希望能让它工作。
我唯一看到的隐藏陷阱是:如果您向 RemorsefulScrollView 添加任何非 UIScrollView 的子视图,则通过响应者链转发给子视图的触摸事件可能会通过回溯返回到您身上,如果子视图未始终处理触摸事件(如 UIScrollView 所做),则可采用“touchesXxx”重进入保护,以实现堪托一击的 RemorsefulScrollView 实现。
第二种策略: 如果由于某些原因嵌套的 UIScrollViews 无法正常工作或证明难以正确处理,您可以尝试只使用一个 UIScrollView,并从您的 scrollViewDidScroll
委托方法动态切换其 pagingEnabled
属性。
为了防止对角线滚动,您首先应该尝试在 scrollViewWillBeginDragging
中记住 contentOffset,并在检测到对角线移动时,在 scrollViewDidScroll
中检查并重新设置 contentOffset。另一个要尝试的策略是将 contentSize 重置为仅在一个方向上启用滚动,一旦您决定用户的手指正在哪个方向滑动。(UIScrollView 对于从其委托方法调整 contentSize 和 contentOffset 似乎相当宽容。)
如果以上方法都无法解决问题或导致视觉效果粗糙,您必须重写 touchesBegan
、touchesMoved
等,并且不将对角线移动事件转发到 UIScrollView。 (在这种情况下,用户体验将是次优的,因为您将不得不忽略对角线移动,而不是强制将其限制在一个方向。如果您感到非常有冒险精神,可以编写自己的 UITouch 类型,例如 RevengeTouch。Objective-C 就是普通的 C,而世界上没有比 C 更具鸭子类型的东西了;只要没有人检查对象的真实类,我相信没有人会这样做,您就可以使任何类看起来像任何其他类一样。这就打开了使用任何坐标合成任何想要的触摸事件的可能性。)
备用策略:存在 TTScrollView,这是 UIScrollView 的一个理智的重新实现,在 Three20 库中。不幸的是,它给用户的感觉非常不自然,与 iPhone 不太相符。但是,如果使用 UIScrollView 的每个尝试都失败了,您可以回退到自定义滚动视图。如果有可能,我建议不要这样做;使用 UIScrollView 可确保您获得本机的外观和感觉,无论它在未来的 iPhone OS 版本中如何发展。
好吧,这篇小散文写得有点长了。几天前我还在 ScrollingMadness 上工作,现在我还在 UIScrollView 游戏中。
P.S. 如果您成功实现了任何方法并愿意共享,请将相关代码发送给我(andreyvit@gmail.com),我会很高兴地将其包含在我的 滚动疯狂技巧袋 中。
P.P.S
每次我都能成功地使用这个...
scrollView.contentSize = CGSizeMake(scrollView.frame.size.width * NumberOfPages, 1);
对于像我这样懒的人:
将scrollview.directionalLockEnabled
设置为YES
- (void) scrollViewWillBeginDragging: (UIScrollView *) scrollView
{
self.oldContentOffset = scrollView.contentOffset;
}
- (void) scrollViewDidScroll: (UIScrollView *) scrollView
{
if (scrollView.contentOffset.x != self.oldContentOffset.x)
{
scrollView.pagingEnabled = YES;
scrollView.contentOffset = CGPointMake(scrollView.contentOffset.x,
self.oldContentOffset.y);
}
else
{
scrollView.pagingEnabled = NO;
}
}
- (void) scrollViewDidEndDecelerating: (UIScrollView *) scrollView
{
self.oldContentOffset = scrollView.contentOffset;
}
我用以下方法解决了这个问题,希望对你有所帮助。
首先在你的控制器头文件中定义以下变量。
CGPoint startPos;
int scrollDirection;
startPos会在你的委托收到scrollViewWillBeginDragging消息时保持contentOffset值。因此,在这个方法中,我们这样做:
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
startPos = scrollView.contentOffset;
scrollDirection=0;
}
然后我们使用这些值来确定用户在scrollViewDidScroll消息中预期的滚动方向。
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
if (scrollDirection==0){//we need to determine direction
//use the difference between positions to determine the direction.
if (abs(startPos.x-scrollView.contentOffset.x)<abs(startPos.y-scrollView.contentOffset.y)){
NSLog(@"Vertical Scrolling");
scrollDirection=1;
} else {
NSLog(@"Horitonzal Scrolling");
scrollDirection=2;
}
}
//Update scroll position of the scrollview according to detected direction.
if (scrollDirection==1) {
[scrollView setContentOffset:CGPointMake(startPos.x,scrollView.contentOffset.y) animated:NO];
} else if (scrollDirection==2){
[scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x,startPos.y) animated:NO];
}
}
最后,当用户结束拖动时,我们必须停止所有的更新操作;
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
if (decelerate) {
scrollDirection=3;
}
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{ if (decelerate) { scrollDirection=3; } }
改为
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{ if (!decelerate) { scrollDirection=0; } }
并且加上
- (void)scrollViewDidEndDecelerating{ scrollDirection=0; }
可能会更好。 - cduck我可能会挖掘老帖子,但今天在解决相同问题时偶然发现了这篇文章。这是我的解决方案,似乎运行得很好。
我想要显示一屏内容,但允许用户向上下左右四个方向滚动到其他4个屏幕。 我有一个大小为320x480的单个UIScrollView,contentSize为960x1440。 内容偏移量从320,480开始。 在scrollViewDidEndDecelerating:中,我重新绘制5个视图(中心视图和周围的4个视图),并将内容偏移重置为320,480。
这是我的解决方案的核心部分,即scrollViewDidScroll:方法。
- (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);
}
}
为了方便日后参考,我在基于Andrey Tanasov的答案之上提出了以下解决方案,其效果良好。
首先定义一个变量来存储contentOffset,并将其设置为0,0坐标。
var positionScroll = CGPointMake(0, 0)
override func scrollViewWillBeginDragging(scrollView: UIScrollView) {
// Note that we are getting a reference to self.scrollView and not the scrollView referenced passed by the method
// For some reason, not doing this fails to get the logic to work
self.positionScroll = (self.scrollView?.contentOffset)!
}
override func scrollViewDidScroll(scrollView: UIScrollView) {
// Check that contentOffset is not nil
// We do this because the scrollViewDidScroll method is called on viewDidLoad
if self.scrollView?.contentOffset != nil {
// The user wants to scroll on the X axis
if self.scrollView?.contentOffset.x > self.positionScroll.x || self.scrollView?.contentOffset.x < self.positionScroll.x {
// Reset the Y position of the scrollView to what it was BEFORE scrolling started
self.scrollView?.contentOffset = CGPointMake((self.scrollView?.contentOffset.x)!, self.positionScroll.y);
self.scrollView?.pagingEnabled = true
}
// The user wants to scroll on the Y axis
else {
// Reset the X position of the scrollView to what it was BEFORE scrolling started
self.scrollView?.contentOffset = CGPointMake(self.positionScroll.x, (self.scrollView?.contentOffset.y)!);
self.collectionView?.pagingEnabled = false
}
}
}
很简单,只需将子视图的高度设置为与滚动视图高度和内容大小高度相同即可禁用垂直滚动。
UIScrollView
实现。请确保将pagingEnabled
设置为YES
。
PagedOrthoScrollView.h
@interface PagedOrthoScrollView : UIScrollView
@end
PagedOrthoScrollView.m
#import "PagedOrthoScrollView.h"
typedef enum {
PagedOrthoScrollViewLockDirectionNone,
PagedOrthoScrollViewLockDirectionVertical,
PagedOrthoScrollViewLockDirectionHorizontal
} PagedOrthoScrollViewLockDirection;
@interface PagedOrthoScrollView ()
@property(nonatomic, assign) PagedOrthoScrollViewLockDirection dirLock;
@property(nonatomic, assign) CGFloat valueLock;
@end
@implementation PagedOrthoScrollView
@synthesize dirLock;
@synthesize valueLock;
- (id)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self == nil) {
return self;
}
self.dirLock = PagedOrthoScrollViewLockDirectionNone;
self.valueLock = 0;
return self;
}
- (void)setBounds:(CGRect)bounds {
int mx, my;
if (self.dirLock == PagedOrthoScrollViewLockDirectionNone) {
// is on even page coordinates, set dir lock and lock value to closest page
mx = abs((int)CGRectGetMinX(bounds) % (int)CGRectGetWidth(self.bounds));
if (mx != 0) {
self.dirLock = PagedOrthoScrollViewLockDirectionHorizontal;
self.valueLock = (round(CGRectGetMinY(bounds) / CGRectGetHeight(self.bounds)) *
CGRectGetHeight(self.bounds));
} else {
self.dirLock = PagedOrthoScrollViewLockDirectionVertical;
self.valueLock = (round(CGRectGetMinX(bounds) / CGRectGetWidth(self.bounds)) *
CGRectGetWidth(self.bounds));
}
// show only possible scroll indicator
self.showsVerticalScrollIndicator = dirLock == PagedOrthoScrollViewLockDirectionVertical;
self.showsHorizontalScrollIndicator = dirLock == PagedOrthoScrollViewLockDirectionHorizontal;
}
if (self.dirLock == PagedOrthoScrollViewLockDirectionHorizontal) {
bounds.origin.y = self.valueLock;
} else {
bounds.origin.x = self.valueLock;
}
mx = abs((int)CGRectGetMinX(bounds) % (int)CGRectGetWidth(self.bounds));
my = abs((int)CGRectGetMinY(bounds) % (int)CGRectGetHeight(self.bounds));
if (mx == 0 && my == 0) {
// is on even page coordinates, reset lock
self.dirLock = PagedOrthoScrollViewLockDirectionNone;
}
[super setBounds:bounds];
}
@end
UIScrollViews
的工作原理,但我认为解决方案有点过于复杂。我的解决方案不涉及任何子类化或触摸转发,具体步骤如下:
UIPanGestureRecognizers
,同样一个用于每个方向。UIGestureRecognizer的-requireGestureRecognizerToFail:
选择器,将UIScrollViews
的panGestureRecognizer属性和与您的UIScrollView所需滚动方向垂直的虚拟UIPanGestureRecognizer
之间创建失败依赖关系。-gestureRecognizerShouldBegin:
回调中,使用手势的-translationInView:
选择器计算pan的初始方向(您的UIPanGestureRecognizers
在您的触摸已足够平移以注册为pan之前不会调用此委托回调)。根据计算出的方向允许或禁止您的手势识别器开始,这反过来应控制是否允许垂直UIScrollView pan手势识别器开始。-gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:
中,不要允许您的虚拟UIPanGestureRecognizers
与滚动同时运行(除非您有一些额外的行为需要添加)。感谢Tonetel。我稍微修改了你的方法,但正是我所需要的,以防止水平滚动。
self.scrollView.contentSize = CGSizeMake(self.scrollView.contentSize.width, 1);