阅读器多种翻页的设计与实现

前言

前文介绍的是小说阅读器的设计和实现,本文作为补充对多种翻页模式做详细剖析。

正文

常见的阅读器翻页模式包括:平移、仿真、滑页和上下:

  • 平移:左右滑动;
  • 仿真:左右滑动;(纸质书翻页效果)
  • 滑页:左右滑动;(覆盖效果)
  • 上下:上下滑动;

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中的做法。
阅读器的翻页模式多种多样,欢迎交流新的翻页模式或者其他实现方案。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容

  • *7月8日上午 N:Block :跟一个函数块差不多,会对里面所有的内容的引用计数+1,想要解决就用__block...
    炙冰阅读 2,473评论 1 14
  • 窗外凉风习习,日间的暑气在山风的打压下销声匿迹,或许是在等待着明日朝阳升起,再给他一个迎头重击;湖水与夜色讨论着美...
    林权_1b9e阅读 234评论 0 0
  • 本文原创发表在 wycode.cn[http://wycode.cn] Unity 平台的判断包含编译时判断和运行...
    王郁阅读 2,310评论 0 1
  • 第七天:空间感 创造空间才能有谈话 没有空间就没有谈话, 有放松的空间,就有放松入新的谈话 紧张的空间,只有紧张焦...
    齐文系统排列阅读 252评论 0 0
  • vhjhhuisnwbbjjsjbbdbjsjj
    仇志轩阅读 115评论 0 5