UIScrollView:水平分页,垂直滚动? [英] UIScrollView: paging horizontally, scrolling vertically?

查看:99
本文介绍了UIScrollView:水平分页,垂直滚动?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

如何强制启用分页和滚动功能的UIScrollView在给定时刻仅垂直或水平移动?

我的理解是directionalLockEnabled属性应该可以实现此目的,但是对角滑动仍会导致视图向对角滚动,而不是将运动限制在单个轴上.

更清楚地说,我想允许用户水平或垂直滚动​​,但不能同时滚动.

解决方案

我必须说,您处境艰难.

请注意,您需要使用带有pagingEnabled=YES的UIScrollView在页面之间进行切换,但是需要pagingEnabled=NO才能垂直滚动.

有2种可能的策略.我不知道哪个会工作/更容易实现,因此请尝试两者.

第一:嵌套的UIScrollViews.坦白说,我还没有看到一个能做到这一点的人.但是,我个人没有足够努力,而我的实践表明,当您尽力而为时,您可以使UIScrollView做任何您想要的东西.

因此,策略是让外部滚动视图仅处理水平滚动,让内部滚动视图仅处理垂直滚动.为此,您必须知道UIScrollView如何在内部工作.它重写hitTest方法并始终返回自身,以便所有触摸事件都进入UIScrollView.然后在touchesBegantouchesMoved等内部,检查是否对该事件感兴趣,然后处理该事件或将其传递给内部组件.

要确定是否要处理或转发触摸,UIScrollView会在您首次触摸时启动一个计时器:

  • 如果您在150ms内没有明显移动手指,它将把事件传递到内部视图.

  • 如果您在150ms内明显移动了手指,它将开始滚动(并且永远不会将事件传递到内部视图).

    请注意,当您触摸表格(滚动视图的子类)并立即开始滚动时,所触摸的行永远不会突出显示.

  • 如果您没有在150ms内显着移动手指,并且UIScrollView开始将事件传递到内部视图,但是然后您已经将手指移动了足够远为了开始滚动,UIScrollView在内部视图上调用touchesCancelled并开始滚动.

    请注意,当您触摸表格时,先稍稍按住手指然后开始滚动,所触摸的行会先突出显示,然后再取消突出显示.

可以通过配置UIScrollView来更改这些事件顺序:

  • 如果delaysContentTouches为NO,则不使用计时器-事件立即进入内部控件(但是如果您将手指移到足够远的位置,事件将被取消)
  • 如果cancelsTouches为否",则一旦事件发送到控件,滚动就永远不会发生.

请注意,是UIScrollView从CocoaTouch接收 all touchesBegintouchesMovedtouchesEndedtouchesCanceled事件(因为它的hitTest告诉它这样做).然后,只要需要,它就会将它们转发到内部视图.

现在您已了解有关UIScrollView的所有知识,可以更改其行为.我敢打赌,您希望优先使用垂直滚动,这样,一旦用户触摸视图并开始移动手指(甚至略微移动),视图就会开始沿垂直方向滚动;但是当用户在水平方向上移动手指足够远时,您想要取消垂直滚动并开始水平滚动.

您想对外部UIScrollView进行子类化(例如,将您的类命名为RemorsefulScrollView),以便代替默认行为,它立即将所有事件转发到内部视图,并且仅当检测到明显的水平移动时才滚动.

>

如何使RemorsefulScrollView表现出这种方式?

  • 似乎禁用了垂直滚动并将delaysContentTouches设置为NO应该可以使嵌套的UIScrollViews正常工作.不幸的是,事实并非如此. UIScrollView似乎对快速动作(无法禁用)进行了一些附加过滤,因此,即使UIScrollView只能水平滚动,它也总是会吃掉(并忽略)足够快的垂直动作.

    效果非常严重,无法在嵌套滚动视图中进行垂直滚动. (看来您已经完全设置好了,因此请尝试:手指保持150ms,然后沿垂直方向移动-嵌套的UIScrollView会按预期工作!)

  • 这意味着您不能使用UIScrollView的代码进行事件处理;您必须重写RemorsefulScrollView中的所有四种触摸处理方法并首先进行自己的处理,如果您决定进行水平滚动,则仅将事件转发到super(UIScrollView).

  • 但是您必须将touchesBegan传递给UIScrollView,因为您希望它记住将来的水平滚动的基本坐标(如果以后再确定它是水平滚动).您以后将无法将touchesBegan发送到UIScrollView,因为您无法存储touches参数:它包含将在下一个touchesMoved事件之前发生突变的对象,并且无法重现旧状态.

    因此,您必须立即将touchesBegan传递给UIScrollView,但是您将对其隐藏所有其他touchesMoved事件,直到您决定水平滚动. No touchesMoved表示不滚动,因此此初始touchesBegan不会造成损害.但是,请务必将delaysContentTouches设置为否",以免其他意外计时器受到干扰.

    (离题-与您不同的是,UIScrollView 可以正确存储触摸,并且以后可以重现和转发原始的touchesBegan事件.使用未发布的API有不公平的优势,因此可以在之前克隆触摸对象它们是突变的.)

  • 鉴于您始终转发touchesBegan,还必须转发touchesCancelledtouchesEnded.但是,您必须将touchesEnded转换为touchesCancelled,因为UIScrollView会将touchesBegantouchesEnded序列解释为触摸式单击,并将其转发到内部视图.您已经在自己转发适当的事件,因此您永远不希望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

我还没有尝试运行甚至编译它(并在纯文本编辑器中键入了整个类),但是您可以从上面开始,并希望它能正常工作.

我看到的唯一隐藏的问题是,如果您将任何非UIScrollView子视图添加到RemorsefulScrollView,则转发给子级的触摸事件可能会通过响应者链返回给您,如果子级并不总是像UIScrollView那样处理触摸做.防弹RemorsefulScrollView实现可防止touchesXxx重新进入.

第二种策略:如果由于某种原因嵌套的UIScrollView无法工作或证明难以正确放置,您可以尝试仅与一个UIScrollView相处,将其pagingEnabled属性切换为您的scrollViewDidScroll委托方法中获得成功.

为防止对角线滚动,应首先尝试记住scrollViewWillBeginDragging中的contentOffset,并在检测到对角线移动时检查并重置scrollViewDidScroll中的contentOffset.尝试的另一种策略是,一旦确定了用户的手指往哪个方向移动,就将contentSize重置为仅允许在一个方向上滚动. (UIScrollView在其委托方法中摆弄contentSize和contentOffset似乎很宽容.)

如果这不起作用或导致马虎的视觉效果,则必须覆盖touchesBegantouchesMoved等,并且不将对角线移动事件转发到UIScrollView. (但是,在这种情况下,用户体验将不是最佳的,因为您将不得不忽略对角线移动,而不是将其强制向单个方向移动.如果您真的很冒险,则可以编写自己的UITouch,类似于RevengeTouch.) -C是普通的老C,在世界上没有比C更令人沮丧的类型了;只要没有人检查对象的真实类(我相信没人做过),您就可以使任何类看起来像其他任何类.可以将所需的任何触摸与所需的坐标进行综合.)

备份策略::TTScrollView是 Three20中UIScrollView的明智重新实现.库.不幸的是,对于用户而言,这感觉非常不自然且不友好.但是,如果每次使用UIScrollView的尝试均失败,则可以退回到自定义编码的滚动视图.我确实建议不要这样做.使用UIScrollView可以确保您获得本机的外观,无论它在将来的iPhone OS版本中如何发展.

好吧,这篇小文章太长了一点.几天前在ScrollingMadness上工作之后,我仍然还在UIScrollView游戏中.

P.S.如果您可以使用这些工具中的任何一种并希望共享,请给我发送电子邮件至andreyvit@gmail.com,并将相关代码发送给我,我很乐意将其包含在我的 解决方案

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 andreyvit@gmail.com, I'd happily include it into my ScrollingMadness bag of tricks.

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

这篇关于UIScrollView:水平分页,垂直滚动?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆