UIScrollView: листание по горизонтали, прокрутка по вертикали? - PullRequest
81 голосов
/ 08 апреля 2009

Как я могу заставить UIScrollView, при котором подкачка и прокрутка включены, двигаться только вертикально или горизонтально в данный момент?

Насколько я понимаю, свойство directionalLockEnabled должно достигать этого, но при диагональном перелистывании вид по-прежнему вызывает прокрутку по диагонали вместо ограничения движения одной осью.

Редактировать: чтобы быть более понятным, я бы хотел, чтобы пользователь мог прокручивать по горизонтали ИЛИ по вертикали, но не одновременно одновременно.

Ответы [ 19 ]

113 голосов
/ 10 мая 2009

Я должен сказать, что вы в очень сложной ситуации.

Обратите внимание, что вам нужно использовать UIScrollView с pagingEnabled=YES для переключения между страницами, но вам нужно pagingEnabled=NO для вертикальной прокрутки.

Есть 2 возможных стратегии. Я не знаю, какой из них будет работать / проще в реализации, поэтому попробуйте оба.

Первый: вложенные UIScrollViews. Честно говоря, мне еще предстоит увидеть человека, который заставил это работать. Однако лично я не пытался достаточно усердно, и моя практика показывает, что когда вы достаточно стараетесь, вы можете заставить UIScrollView делать все, что вы захотите .

Таким образом, стратегия заключается в том, чтобы внешний вид прокрутки обрабатывал только горизонтальную прокрутку, а внутренний вид прокрутки - только вертикальную прокрутку. Для этого вы должны знать, как работает UIScrollView внутри. Он переопределяет метод hitTest и всегда возвращает себя, так что все сенсорные события попадают в UIScrollView. Затем внутри touchesBegan, touchesMoved и т. Д. Он проверяет, заинтересован ли он в событии, и обрабатывает или передает его внутренним компонентам.

Чтобы решить, будет ли касание обрабатываться или пересылаться, UIScrollView запускает таймер при первом касании:

  • Если вы не двигали пальцем значительно в течение 150 мс, событие передается во внутренний вид.

  • Если вы значительно переместили палец в течение 150 мс, он начинает прокручиваться (и никогда не передает событие во внутренний вид).

    Обратите внимание, что при прикосновении к таблице (которая является подклассом вида прокрутки) и немедленной прокрутке строка, к которой вы прикоснулись, никогда не выделяется.

  • Если вы не значительно переместили палец в течение 150 мс, и UIScrollView начал передавать события во внутренний вид, но , тогда вы переместили палец достаточно далеко для чтобы начать прокрутку, UIScrollView вызывает touchesCancelled во внутреннем представлении и начинает прокрутку.

    Обратите внимание, что когда вы дотрагиваетесь до стола, немного подержите палец, а затем начнете прокручивать, строка, к которой вы прикоснулись, сначала подсвечивается, а затем подсвечивается.

Эта последовательность событий может быть изменена с помощью конфигурации UIScrollView:

  • Если delaysContentTouches - НЕТ, то таймер не используется - события сразу переходят во внутренний контроль (но затем отменяются, если вы достаточно далеко двигаете пальцем)
  • Если cancelsTouches НЕТ, то после отправки событий в элемент управления прокрутка никогда не произойдет.

Обратите внимание, что это UIScrollView, который получает все touchesBegin, touchesMoved, touchesEnded и touchesCanceled события от CocoaTouch (потому что его 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, чтобы никакие дополнительные таймеры неожиданности не мешали.

    (Offtopic - в отличие от вас, 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

Я не пытался запустить или даже скомпилировать это (и набрал весь класс в текстовом редакторе), но вы можете начать с вышеизложенного и, надеюсь, заставить его работать.

Единственный скрытый улов, который я вижу, заключается в том, что если вы добавляете какие-либо дочерние представления, отличные от UIScrollView, в RemorsefulScrollView, события касания, которые вы пересылаете дочернему элементу, могут возвращаться вам через цепочку респондента, если дочерний элемент не всегда обрабатывает прикосновения, подобные UIScrollView делает. Пуленепробиваемая реализация RemorsefulScrollView защитит от повторного входа touchesXxx.

Вторая стратегия: Если по какой-то причине вложенные UIScrollViews не работают или оказываются слишком сложными для получения прав, вы можете попробовать обойтись только одним UIScrollView, переключив его свойство pagingEnabled на вылететь из вашего scrollViewDidScroll метода делегата.

Чтобы предотвратить прокрутку по диагонали, сначала попробуйте запомнить contentOffset в scrollViewWillBeginDragging, а также проверить и сбросить contentOffset внутри scrollViewDidScroll, если вы обнаружите диагональное перемещение. Другая стратегия, которую нужно попробовать, - сбросить contentSize, чтобы включить прокрутку только в одном направлении, как только вы решите, в каком направлении движется палец пользователя. (UIScrollView, похоже, довольно прост, что возится с контентом contentSize и contentOffset из методов его делегатов.)

Если это не сработает или приведет к неаккуратным изображениям, вам придется переопределить touchesBegan, touchesMoved и т. Д. И не пересылать события диагонального перемещения в UIScrollView. (Однако пользовательский опыт в этом случае будет неоптимальным, потому что вам придется игнорировать диагональные движения вместо того, чтобы заставлять их двигаться в одном направлении. Если вы чувствуете себя действительно предприимчивым, вы можете написать свой собственный UITouch, похожий на RevengeTouch. Objective -C - обычный старый C, и в мире нет ничего более типичного для утки, чем C: пока никто не проверяет реальный класс объектов, что, как я полагаю, никто не делает, вы можете сделать любой класс похожим на любой другой класс. возможность синтезировать любые касания, которые вы хотите, с любыми желаемыми координатами.)

Стратегия резервного копирования: есть TTScrollView, разумное переопределение UIScrollView в Three20 library . К сожалению, это кажется очень неестественным и неифоничным для пользователя. Но если каждая попытка использования UIScrollView не удалась, вы можете вернуться к пользовательскому представлению прокрутки. Я рекомендую против этого, если это вообще возможно; использование UIScrollView гарантирует, что вы получите собственный внешний вид, независимо от того, как он будет развиваться в будущих версиях iPhone OS.

Хорошо, это маленькое эссе стало слишком длинным. Я все еще в UIScrollView играх после работы над ScrollingMadness несколько дней назад.

P.S. Если у вас получится что-то из этого, и вы захотите поделиться им, отправьте мне соответствующий код по адресу andreyvit@gmail.com, и я с радостью включу его в свой пакет ScrollingMadness трюков .

P.P.S. Добавление этого небольшого эссе в README ScrollingMadness.

53 голосов
/ 07 июля 2010

Это работает для меня каждый раз ...

scrollView.contentSize = CGSizeMake(scrollView.frame.size.width * NumberOfPages, 1);
25 голосов
/ 12 ноября 2013

Для таких ленивых, как я:

Установите 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;
}
16 голосов
/ 09 ноября 2010

Я использовал следующий метод для решения этой проблемы, надеюсь, он вам поможет.

Сначала определите следующие переменные в заголовочном файле вашего контроллера.

CGPoint startPos;
int     scrollDirection;

startPos сохранит значение contentOffset , когда ваш делегат получит сообщение scrollViewWillBeginDragging . Таким образом, в этом методе мы делаем это;

- (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;
    }
 }
13 голосов
/ 04 марта 2010

Возможно, я здесь могилу, но сегодня я наткнулся на этот пост, пытаясь решить ту же проблему. Вот мое решение, похоже, отлично работает.

Я хочу отобразить экран с содержимым, но разрешить пользователю прокручивать до одного из 4 других экранов вверх, вниз, влево и вправо. У меня есть один UIScrollView с размером 320x480 и 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);
    }
}
4 голосов
/ 05 июня 2010

Ребята, это так же просто, как сделать высоту подпредставления такой же, как высота вида прокрутки и высота размера контента. Тогда вертикальная прокрутка отключена.

3 голосов
/ 28 августа 2011

Вот моя реализация постраничной 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
3 голосов
/ 02 июля 2014

Мне также пришлось решить эту проблему, и, хотя в ответе Андрея Таранцова определенно есть много полезной информации для понимания того, как работает UIScrollViews, я чувствую, что решение немного сложнее. Мое решение, которое не включает подклассы или переадресацию касаний, выглядит следующим образом:

  1. Создание двух вложенных видов прокрутки, по одному для каждого направления.
  2. Создайте два манекена UIPanGestureRecognizers, снова по одному для каждого направления.
  3. Создание зависимостей отказов между UIScrollViews 'свойствами panGestureRecognizer и фиктивным UIPanGestureRecognizer, соответствующим направлению, перпендикулярному желаемому направлению прокрутки вашего UIScrollView, с помощью селектора UIGestureRecognizer's -requireGestureRecognizerToFail:.
  4. В обратных вызовах -gestureRecognizerShouldBegin: для вашего фиктивного UIPanGestureRecognizer рассчитайте начальное направление панорамирования, используя селектор -translationInView: селектор жеста (ваш UIPanGestureRecognizers не будет вызывать этот обратный вызов делегата, пока ваше касание не будет переведено достаточно для регистрации кастрюля). Разрешить или запретить запуск распознавателя жестов в зависимости от вычисленного направления, которое, в свою очередь, должно контролировать, разрешено ли запускать перпендикулярный распознаватель жестов панорамирования UIScrollView.
  5. В -gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer: не позволяйте своему манекену UIPanGestureRecognizers работать вместе с прокруткой (если только у вас нет дополнительного поведения, которое вы хотели бы добавить).
2 голосов
/ 12 сентября 2011

Что я сделал, так это создал постраничный UIScrollView с рамкой размером с экран просмотра и установил размер контента в соответствии с шириной, необходимой для всего моего контента, и высотой 1 (спасибо Tonetel). Затем я создал меню страниц UIScrollView с фреймами, заданными для каждой страницы, размером экрана просмотра, и установил размер контента для каждой ширины экрана и необходимую высоту для каждой из них. Затем я просто добавил каждую страницу меню в режим просмотра. Убедитесь, что подкачка включена в представлении прокрутки подкачки, но отключена в представлениях меню (по умолчанию должна быть отключена), и вы должны быть в порядке.

Представление прокрутки страниц теперь прокручивается по горизонтали, но не по вертикали из-за высоты содержимого. Каждая страница прокручивается по вертикали, но не по горизонтали из-за ограничений по размеру контента.

Вот код, если это объяснение оставляет желать лучшего:

UIScrollView *newPagingScrollView =  [[UIScrollView alloc] initWithFrame:self.view.bounds]; 
[newPagingScrollView setPagingEnabled:YES];
[newPagingScrollView setShowsVerticalScrollIndicator:NO];
[newPagingScrollView setShowsHorizontalScrollIndicator:NO];
[newPagingScrollView setDelegate:self];
[newPagingScrollView setContentSize:CGSizeMake(self.view.bounds.size.width * NumberOfDetailPages, 1)];
[self.view addSubview:newPagingScrollView];

float pageX = 0;

for (int i = 0; i < NumberOfDetailPages; i++)
{               
    CGRect pageFrame = (CGRect) 
    {
        .origin = CGPointMake(pageX, pagingScrollView.bounds.origin.y), 
        .size = pagingScrollView.bounds.size
    };
    UIScrollView *newPage = [self createNewPageFromIndex:i ToPageFrame:pageFrame]; // newPage.contentSize custom set in here        
    [pagingScrollView addSubview:newPage];

    pageX += pageFrame.size.width;  
}           
2 голосов
/ 07 августа 2010

Спасибо, Тонетел. Я немного изменил ваш подход, но это было именно то, что мне нужно, чтобы предотвратить горизонтальную прокрутку.

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