閱讀器多種翻頁的設計與實現

  • 2020 年 2 月 18 日
  • 筆記

前言

前文介紹的是小說閱讀器的設計和實現,本文作為補充對多種翻頁模式做詳細剖析。

正文

常見的閱讀器翻頁模式包括:平移、仿真、滑頁和上下:

  • 平移:左右滑動;
  • 仿真:左右滑動;(紙質書翻頁效果)
  • 滑頁:左右滑動;(覆蓋效果)
  • 上下:上下滑動;

1、平移

UIKit提供UIPageViewController可以很方便實現平移的頁面切換效果,使用流程: 1、創建UIPageViewController;

    self.pageVC = [[UIPageViewController alloc]                     initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll                     navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal                     options:                     @{                       UIPageViewControllerOptionSpineLocationKey:@(UIPageViewControllerSpineLocationMin)                       }];      self.pageVC.delegate = self;      self.pageVC.dataSource = self;      [self addChildViewController:self.pageVC];      [self.view addSubview:self.pageVC.view];

2、初始化首個界面;

- (void)customInitFirstPage {      UIViewController *vc = [self getRandomVCWithIndex:5];        [self.pageVC setViewControllers:@[vc]                            direction:UIPageViewControllerNavigationDirectionReverse                             animated:NO                           completion:^(BOOL finished) {                           }];  }

3、滑動時返回相鄰的界面;

#pragma mark - UIPageViewControllerDelegate    - (nullable UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController {      UIViewController *ret;      UIViewController *vc = viewController;      if (vc) {          NSInteger index = vc.view.tag;          if (index > 0) {              ret = [self getRandomVCWithIndex:index - 1];          }      }      return ret;  }    - (nullable UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController {      UIViewController *ret;      UIViewController *vc = viewController;      if (vc) {          NSInteger index = vc.view.tag;          if (index < 10) {              ret = [self getRandomVCWithIndex:index + 1];          }      }      return ret;  }

2、仿真

相對安卓,iOS實現這個翻頁效果非常方便——UIPageViewController同樣支持這個翻頁效果。 使用流程和平移類似,但多了一些注意事項:

  • initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll變為UIPageViewControllerTransitionStyleScroll
  • 支持翻頁的時候,對背面做一個自定義展示,需要打開self.pageVC.doubleSided = YES;
  • 初始化界面的時候和平移一樣,但是在使用過程中再調用-setViewControllers時,如果animated的參數為YES,則需要手動傳入兩個vc,如下:
- (void)manualChangePage {      UIViewController *vc = [self getRandomVCWithIndex:5];      NSArray *arr;      if (self.pageVC.doubleSided) {          BackViewController *backVC = [[BackViewController alloc] init];          [backVC updateWithViewController:vc];          backVC.view.tag = vc.view.tag;          arr = @[vc, backVC];      }      else {          arr = @[vc];      }      [self.pageVC setViewControllers:arr                            direction:UIPageViewControllerNavigationDirectionReverse                             animated:YES                           completion:^(BOOL finished) {                           }];  }
  • 設置doubleSided為YES之後,每次翻頁會調用兩次viewControllerAfterViewControllerviewControllerBeforeViewController,需要特殊返回一個BackViewController作為背面的VC:
- (nullable UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController {      UIViewController *ret;      UIViewController *vc = viewController; // 注意這裡不是pageViewController.viewControllers      if (vc) {          NSInteger index = vc.view.tag;          if (index > 0) {              if ([vc isKindOfClass:BackViewController.class]) {                  ret = [self getRandomVCWithIndex:index - 1];              }              else {                  BackViewController *backVC = [[BackViewController alloc] init];                  [backVC updateWithViewController:vc];                  backVC.view.tag = vc.view.tag;                  ret = backVC;              }          }      }      return ret;  }
  • 背面的VC可以添加自定義的view,但通常採用的做法是作為當前界面的鏡像(用截圖的方式):
- (UIImage *)captureView:(UIView *)view {      if ([self checkNullRect:view]) {          return nil;      }      CGRect rect = view.bounds;      UIGraphicsBeginImageContextWithOptions(rect.size, YES, 0.0f);      CGContextRef context = UIGraphicsGetCurrentContext();        CGAffineTransform transform = CGAffineTransformMake(-1.0, 0.0, 0.0, 1.0, rect.size.width, 0.0);      CGContextConcatCTM(context,transform);      [view.layer renderInContext:context];      UIImage *image = UIGraphicsGetImageFromCurrentImageContext();      UIGraphicsEndImageContext();      return image;  }

增加的-checkNullRect:方法是避免iOS9可能出現的frame為CGRectNull的crash。

- (BOOL)checkNullRect:(UIView *)view {      BOOL ret = CGRectIsNull(view.frame);      for (UIView *subView in view.subviews) {          ret = ret || [self checkNullRect:subView];      }      return ret;  }

3、滑頁

滑頁沒有系統庫支持,需要手動實現。 對前面兩種翻頁模式進行分析,我們可以發現一些共性,比如說以頁(VC)為單位、實時獲取界面VC和頁面之間有先後順序等。 分解UI層的實現,整個動畫可以用以下流程來表示: 1、頁面初始化,直接顯示頁面,監聽用戶pan手勢; 2、用戶pan手勢開始,根據方向確定左滑還是右滑,獲取新的VC; 3、處理用戶左右滑動,視圖跟隨用戶滑動; 4、用戶pan手勢結束,根據動畫完成程度確定是補齊動畫還是回退; 5、處理完動畫相關,將狀態重置為1,接受用戶的pan手勢;

如果還要支持tap手勢,則自動完成一次動畫效果,再將狀態重置為status_show(只有在此狀態才響應tap的手勢)。

核心邏輯:

  • pan手勢開始時,記錄點的位置:
    CGPoint point = [rec translationInView:self.view];      static CGPoint startPoint;      //手勢開始      if (rec.state == UIGestureRecognizerStateBegan) {          startPoint = point;      }
  • pan手勢觸發過程中,先確定方向,再獲取對應的VC;然後根據左右滑動,分別改變位置(showVC對應不不動的VC,moveVC跟着pan手勢移動):
//手勢進行      if (rec.state == UIGestureRecognizerStateChanged) {          if (self.currentStatus == SSReaderPageEffectViewStatusDefault) { // 用戶開始移動,此時判斷是左移還是右移              if (point.x >= startPoint.x) { // 右移                  self.currentStatus = SSReaderPageEffectViewStatusMovingToLastPage;              }              else {                  self.currentStatus = SSReaderPageEffectViewStatusMovingToNextPage;              }              if (self.delegate) {                  if (self.currentStatus == SSReaderPageEffectViewStatusMovingToLastPage) {                      UIViewController *lastVC = [self.delegate slideViewControllerGetLastVC:self];                      if (!lastVC) {                          [rec cancelCurrentGestureReccongizing];                          self.currentStatus = SSReaderPageEffectViewStatusDefault;                          SSLOG_INFO(@"info, reach last end");                      }                      else {                          [self addChildViewController:lastVC];                          [self.view insertSubview:lastVC.view aboveSubview:self.showVC.view];                          self.moveVC = lastVC;                          [self addMaskToVC:self.moveVC];                      }                  }                  else if (self.currentStatus == SSReaderPageEffectViewStatusMovingToNextPage) {                      UIViewController *nextVC = [self.delegate slideViewControllerGetNextVC:self];                      if (!nextVC) {                          [rec cancelCurrentGestureReccongizing];                          self.currentStatus = SSReaderPageEffectViewStatusDefault;                          SSLOG_INFO(@"info, reach next end");                      }                      else {                          [self addChildViewController:nextVC];                          [self.view insertSubview:nextVC.view belowSubview:self.showVC.view];                          self.moveVC = self.showVC;                          self.showVC = nextVC;                          [self addMaskToVC:self.moveVC];                      }                  }                    if (self.currentStatus == SSReaderPageEffectViewStatusMovingToLastPage) {                      [self.delegate slideViewController:self willTransitionToViewControllers:self.moveVC];                  }                  else if (self.currentStatus == SSReaderPageEffectViewStatusMovingToNextPage) {                      [self.delegate slideViewController:self willTransitionToViewControllers:self.showVC];                  }              }          }          if (self.currentStatus == SSReaderPageEffectViewStatusMovingToNextPage) {              self.moveVC.view.right = self.view.width * (1 - rate);          }          else if (self.currentStatus == SSReaderPageEffectViewStatusMovingToLastPage) {              self.moveVC.view.right = self.view.width * rate;          }      }
  • pan手勢結束時,根據動畫完成程度決定是否完成該動作(用animateWithDuration:的動畫block來完成);

注意事項: 滑頁效果通常都需要添加一個陰影效果,可以對showVC進行處理:

- (void)addMaskToVC:(UIViewController *)vc {      vc.view.layer.shadowColor = [UIColor colorWithRed:0/255.0 green:0/255.0 blue:0/255.0 alpha:0.8].CGColor;      vc.view.layer.shadowOffset = CGSizeMake(5, 5);      vc.view.layer.shadowOpacity = 0.8;      vc.view.layer.shadowRadius = 6;  }

在手勢結束的時候,除了根據動畫完成程度來判斷是否完成該動作外,速度通常也會作為參考值:

        CGPoint speed = [rec velocityInView:rec.view];          rate = (rate >= kCompleteRate || fabs(speed.x) > 200) ? 1 : 0; // 經驗數值,多次嘗試得出

另外一個問題是手勢在進行到一半時如果APP切入後台,動畫出現暫停的情況。這是因為pan手勢在切後台時會自動cancel,所以需要在手勢處理增加對cancel狀態的處理。

4、上下滑動

上下滑動同樣沒有系統庫支持,需要手動實現。 效果分解: 1、當用戶滑動的過程,視圖要跟隨手指的移動; 2、當用戶往上滑然後鬆開時,視圖要帶有加速度的往上滑動;(附加特性:在滑動過程中用戶可以通過重複這個行為加速滑動) 3、在視圖滑動的過程中,用戶可以通過簡單的tap操作停止交互;

用戶的交互有3種touchBegin/touchMove/touchEnd,上述的三個效果實現如下: 1、監聽touchMove,計算手指的移動距離,換算成view的移動; 2、touchEnd之後,根據pan手勢的移動速度和原來的滑動速度,計算得到滑動的新初始速度; 3、touchBegin開始,講當前速度重置為0;

上述的過程2的處理非常複雜,需要考慮原來的滑動速度,才能實現效果分解中的附加特性。 通常iOS實現滑動會有兩大選擇:UIScrollView和UITableView;(UICollectionView和UITableView類似) UIScrollView存在一個較大的局限:上面的視圖資源無法回收利用,當添加的view過多的時候會佔用內存; UITableView用cell重複利用規避上面的局限,但是存在新的問題:當數據源(排版數據)變化時,需要頻繁調用reloadData,造成性能瓶頸;同時reload會造成contentSize和contentOffset的改變,導致界面可能會出現閃爍,需要各類邏輯的特殊處理。

綜上的分析,這裡提供一個基於UIScrollView的方案,避免去手動計算速度,也可以及時回收內存,並且contentSize一直保持不變。 以下圖為例,我們使得UIScrollView的contentSize為(view.width, 3*view.height),偏移contentOffsetY為view.height(初始狀態相當於將窗口放置在中間):

B是我們創建的第一個vc,大小和UIScrollView的size一樣大;當我們向下滑動時,我們創建vcA放在B的上面; 當我們上滑到vcA完全展示的時候,vcB已經滑動到屏幕外面(紅色為窗口大小);此時我們回收vcB,然後將UIScrollView的Y偏移重新改為view.height,回到了初始化狀態。 同理,我們可以處理向上滑動的情況。至此,我們可以不依賴UITableView完成無限視圖的滾動,同時避免各類touch事件處理和加速度計算。

簡單的實現效果

上圖的實現過程非常簡短:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {      if (self.scrollView.contentOffset.y >= (self.scrollView.contentSize.height - self.height)) {          UIView *firstView = [self.viewArr firstObject];          [self.viewArr removeObjectAtIndex:0];          firstView.top = self.scrollView.contentSize.height;          [self.viewArr addObject:firstView];          for (UIView *view in self.viewArr) {              view.top -= self.height;          }          [self.scrollView setContentOffset:CGPointMake(0, self.scrollView.contentOffset.y - self.height)];      }  }

基於出延伸出來我們的整體流程圖:

遇到的問題(Q&A): Q:如何實現UIScrollView改變offset,但是繼承原來的速度? A: [self.scrollView setContentOffset:CGPointMake(0, self.view.height) animated:NO]; [self.scrollView setContentOffset:CGPointMake(0, self.view.height); 上面兩個API均可以改變offset,但是-setContentOffset:animated:會使得當前的速度重置為0,使得跨頁時滑動不流暢;使用-setContentOffset:可以解決這個問題,僅僅改變offset,並且繼承原來的速度接着運動;

Q: -scrollViewDidScroll:方法怎麼會出現遞歸循環調用? A: 在通過-setContentOffset:改變offset之後,仍會觸發-scrollViewDidScroll:的回調,如果在此回調又觸發了offset的改變,則進入了遞歸調用的坑,從下圖的堆棧可以看到:

解決辦法是在設置偏移時,先把delegate取消,修改完成後再賦值回去:

- (void)safeSetContentOffsetY:(CGFloat)y {      self.scrollView.delegate = nil;      [self.scrollView setContentOffset:CGPointMake(self.scrollView.contentOffset.x, y) animated:NO];      self.scrollView.delegate = self;  }

Q: 滑動到最後一頁的時候,沒有再往下的VC(返回的nextVC為nil),如果用戶沒有中斷手勢繼續滑動,如何避免觸發再次獲取nextVC? A: 當滑動到最後一頁的時候,此時沒有nextVC,無法接着往下滑,但是因為手勢還在,會頻繁觸發getNextVC的方法。對此可以新增手勢取消的方法:

- (void)cancelCurrentGestureReccongizing {      // disabled gesture recognizers will not receive touches. when changed to NO the gesture recognizer will be cancelled if it's currently recognizing a gesture      self.enabled = NO;      self.enabled = YES;  }

Q:滑頁效果,在進行到一半時切入後台,如何避免動畫出現異常現象? A: 這是因為pan手勢在切後台時會自動cancel,所以需要在手勢處理增加對cancel狀態的處理;

Q:如果初始化的時候,傳進的VC.view不滿一屏,該如何處理? A: 手動填充到滿屏幕。

- (void)fullFillContent {          CGFloat downFillY;          if (self.viewControllers && self.viewControllers.count > 0) {              UIViewController *vc = [self.viewControllers lastObject];              downFillY = vc.view.bottom;          }          else {              downFillY = self.scrollView.contentOffset.y;          }          while (downFillY < windowMaxY) {              if (!self.delegate) {                  NSLog(@"error, empty delegate");                  break;              }              UIViewController *vc = [self.delegate scrollViewControllerGetNextVC:self];              if (!vc) {                  NSLog(@"info, reach next end");                  break;              }                [self.vcArr addObject:vc];              [self addChildViewController:vc];              [self.scrollView addSubview:vc.view];              vc.view.top = downFillY;              downFillY = vc.view.bottom;              NSLog(@"info, add next vc, frame:%@", NSStringFromCGRect(vc.view.frame));          }      }

總結

demo地址是在GitHub,包括四種翻頁效果,其中的滑頁和上下滑動都以參考UIPageViewController的接口做了調整,基本可以直接複製代碼進行接入。 上下滑動的代碼不多,但是經過多次嘗試再有的定論,中間也換過多次方案,最終優化得到的結論就是demo中的做法。 閱讀器的翻頁模式多種多樣,歡迎交流新的翻頁模式或者其他實現方案。