阅读器多种翻页的设计与实现
前言
前文详情的是小说阅读器的设计和实现,本文作为补充对多种翻页模式做详细剖析。
正文
常见的阅读器翻页模式包括:平移、仿真、滑页和上下:
平移:左右滑动;

仿真:左右滑动;(纸质书翻页效果)

滑页:左右滑动;(覆盖效果)

上下:上下滑动;

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之后,每次翻页会调用两次
viewControllerAfterViewController或者viewControllerBeforeViewController,需要特殊返回一个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中的做法。
阅读器的翻页模式多种多样,欢迎交流新的翻页模式或者者其余实现方案。
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » 阅读器多种翻页的设计与实现