UIScrollView:水平分页,垂直滚动?

91

如何强制在分页和滚动都开启的情况下,UIScrollView 在给定时刻只能沿垂直或水平方向移动?

我理解 directionalLockEnabled 属性可以实现这一点,但是对角线滑动仍然会导致视图在两个方向上同时滚动而不是限制在单个轴上。

编辑:更明确地说,我希望允许用户水平或垂直滚动,但不能同时进行。


你想将视图限制为仅水平或垂直滚动,还是希望用户可以水平或垂直滚动,但不能同时进行? - Will Hartung
你能否帮我一个忙,把标题改得更有趣些,更加不平凡?比如说:“UIScrollView:水平分页,竖直滚动?” - Andrey Tarantsov
很不幸,我以前在一台现在无法访问的电脑上以未注册用户的身份发布了这个问题(后来回家时使用相同的用户名注册),这意味着我无法编辑它。这也解释了为什么我在下面进行跟进而不是编辑原始问题。很抱歉这次违反了 Stack 的礼仪! - Nick
21个回答

117

我必须说,你处于非常困难的境地。

请注意,在切换页面时需要使用pagingEnabled=YES的UIScrollView,但在垂直滚动时需要pagingEnabled=NO

有两种可能的策略。我不知道哪个会起作用/更容易实现,所以尝试两种方法。

第一种:嵌套的UIScrollViews。坦率地说,我还没有见过一个能让这个工作的人。然而,我个人没有尝试足够努力,我的经验表明当你足够努力时,你可以让UIScrollView做任何你想做的事情

所以策略是让外部滚动视图仅处理水平滚动,内部滚动视图仅处理垂直滚动。为了实现这一点,您必须了解UIScrollView在内部如何工作。它覆盖了hitTest方法并总是返回自己,以便所有触摸事件都进入UIScrollView。然后在touchesBegantouchesMoved等内部组件中检查它是否感兴趣,并处理或将其传递给内部组件。

为了决定是处理触摸还是转发触摸,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 似乎相当宽容。)

    如果以上方法都无法解决问题或导致视觉效果粗糙,您必须重写 touchesBegantouchesMoved 等,并且不将对角线移动事件转发到 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


    7
    请注意,SDK现在可以直接使用此嵌套滚动视图功能,无需任何额外的操作。 - andygeers
    1
    点赞了andygeers的评论,但后来发现它并不准确;你仍然可以通过从起始点斜向拖动来“破坏”常规的UIScrollView,然后你就“拉松了滚动条”,它将忽略方向锁定,直到你释放并最终停在某个位置。 - Kalle
    感谢您提供了这么好的幕后解释!我选择了Mattias Wadman下面的解决方案,我认为它看起来不那么具有侵入性。 - Ortwin Gentz
    现在UIScrollView公开了其平移手势识别器,更新这个答案会很棒。(但也许这超出了最初的范围。) - jtbandes
    这篇文章有更新的可能吗?非常棒的文章,感谢您的分享。 - Pavan

    56

    每次我都能成功地使用这个...

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

    20
    如果有其他人看到这个问题:我认为这个答案是在问题澄清之前发布的。它只允许您水平滚动,但没有解决垂直或水平滚动而不是对角线滚动的问题。 - andygeers
    对我来说运行得非常好。我有一个非常长的水平scrollView,带有分页功能,并且每个页面上都有一个UIWebView,它处理我需要的垂直滚动。 - Tom Redman
    很好!但是有人能解释一下这是怎么工作的吗(将contentSize高度设置为1)! - Praveen
    它确实有效,但为什么以及如何有效呢?有人能够理解吗? - Marko Francekovic

    29

    对于像我这样懒的人:

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

    15

    我用以下方法解决了这个问题,希望对你有所帮助。

    首先在你的控制器头文件中定义以下变量。

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

    嗯...实际上,在更深入的调查后,它运行得相当不错,但问题在于在减速阶段失去了方向控制 - 因此,如果您开始斜着移动手指,那么它将只会水平或垂直移动,直到您松开手指,此时它将以对角线方向减速一段时间。 - andygeers
    @andygeers,如果将 - (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
    对我没用。在飞行中设置contentOffset似乎会破坏分页行为。我采用了Mattias Wadman的解决方案。 - Ortwin Gentz

    13

    我可能会挖掘老帖子,但今天在解决相同问题时偶然发现了这篇文章。这是我的解决方案,似乎运行得很好。

    我想要显示一屏内容,但允许用户向上下左右四个方向滚动到其他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);
        }
    }
    

    你可能会发现,当用户放开滚动视图时,这段代码不会很好地“减速”,它会突然停止。如果你不需要减速,那么这可能对你来说没问题。 - andygeers

    4

    为了方便日后参考,我在基于Andrey Tanasov的答案之上提出了以下解决方案,其效果良好。

    首先定义一个变量来存储contentOffset,并将其设置为0,0坐标。

    var positionScroll = CGPointMake(0, 0)
    

    现在,在scrollView的代理中实现以下内容:
    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
            }
    
        }
    
    }
    

    希望这能有所帮助。

    4

    很简单,只需将子视图的高度设置为与滚动视图高度和内容大小高度相同即可禁用垂直滚动。


    2
    对于其他遇到这个问题的人:我认为在澄清问题之前发布了这个答案。这将允许您仅水平滚动,但它并未解决能够垂直或水平滚动而无法对角线滚动的问题。 - andygeers

    3
    这是一个仅允许直角滚动的分页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
    

    1
    非常好,也适用于iOS 7。谢谢! - Ortwin Gentz

    3
    我也曾经遇到过这个问题,虽然Andrey Tarantsov的回答提供了很多有用的信息来理解UIScrollViews的工作原理,但我认为解决方案有点过于复杂。我的解决方案不涉及任何子类化或触摸转发,具体步骤如下:
    1. 创建两个嵌套的滚动视图,一个用于每个方向。
    2. 创建两个虚拟的UIPanGestureRecognizers,同样一个用于每个方向。
    3. 通过使用UIGestureRecognizer的-requireGestureRecognizerToFail:选择器,将UIScrollViews的panGestureRecognizer属性和与您的UIScrollView所需滚动方向垂直的虚拟UIPanGestureRecognizer之间创建失败依赖关系。
    4. 在您虚拟UIPanGestureRecognizers的-gestureRecognizerShouldBegin:回调中,使用手势的-translationInView:选择器计算pan的初始方向(您的UIPanGestureRecognizers在您的触摸已足够平移以注册为pan之前不会调用此委托回调)。根据计算出的方向允许或禁止您的手势识别器开始,这反过来应控制是否允许垂直UIScrollView pan手势识别器开始。
    5. -gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:中,不要允许您的虚拟UIPanGestureRecognizers与滚动同时运行(除非您有一些额外的行为需要添加)。

    2

    感谢Tonetel。我稍微修改了你的方法,但正是我所需要的,以防止水平滚动。

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

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接