iOS开发——仿微信图片浏览交互的实现(向下拖拽图片退出图片浏览器)

DEMO的github地址:https://github.com/YYProgrammer/YYPhotoBrowserLikeWX

效果如下图

效果图.gif
  • 实现图片组的浏览,包含捏合缩放、双击缩放、单击退出、向下拖拽退出等。
  • 重点是“向下拖拽退出”的实现。

架构设计

下文称下图中左边的界面为界面A,右边为界面B


A-B.jpeg

界面A只是一个用来测试的界面,界面B才是图片浏览器,架构设计主要针对界面B。主要需要考虑以下几个点:

  • 界面B的结构:N张图片需要左右滑动、图片本身需要缩放、N种手势交互、后期额外控件的添加等。
  • A到B、B到A的转场动画。
  • 重点:向下拖拽的交互怎么实现
界面B的结构
  • N张图片需要左右滑动:必然需要UIScrollView或其子类(UICollectionView),来放所有图片。
  • 图片本身需要缩放:所以图片本身需要一个UIScrollView包装起来用于做缩放。
  • N种手势交互:UIScrollView本身带有很多手势,再往上添加手势不妥,所以应当创建个UIView,UIView里有UIScrollView,手势加在UIView上,例如单击、双击等。
  • 后期额外控件的添加:


    额外控件.jpeg

    例如上图红圈的控件,明显不能添加到UIScrollView中否则就跟着滑走了。

最终设计如下图:


结构示意.jpeg
  • 蓝色是个scrollview,里面放图片,这样可以缩放图片。(demo中我把它封装成了YYPhotoBrowserSubScrollView)
  • 绿色是个scrollview,里面放蓝色scrollview,这样可以实现左右滑动翻页。(demo中我把它封装成了YYPhotoBrowserMainScrollView)
  • 红色是个控制器的view,这样做可以随意添加额外控件,而且控制器的话,也方便做界面A-界面B的转场动画(demo中对应YYPhotoBrowserViewController)。
  • 消息传递我采用的代理模式。
转场动画

这里B是modal出的控制器,我把转场效果交给了转场代理transitioningDelegate,转场动画具体做法可以参考文章:http://www.jianshu.com/p/a65d3463f4bc
值得一提的是,拖拽时背景颜色透明,出现A的界面。换句话说,B被present之后,A并没有消失。这需要设置一个属性

B.modalPresentationStyle = UIModalPresentationOverCurrentContext;

重点:向下拖拽的交互的实现

控件简介

结合上图,拖拽事件发生在蓝色这一层。
蓝色是个UIView(YYPhotoBrowserSubScrollView),添加了子控件UIScrollview(demo中对象命名为mainScrollView),mainScrollView的宽高占满了YYPhotoBrowserSubScrollView。mainScrollView中有UIImageView。
双击手势添加给YYPhotoBrowserSubScrollView,双击后改变mainScrollView的zoomScale(缩放比例系数)来实现缩放,单击手势也添加给YYPhotoBrowserSubScrollView,单击后通知代理-绿色控件,绿色再通知红色控制器,控制器退回。

需求介绍

用户向上拖拽时,图片向上移动(即正常的scrollview的滚动效果,结合demo中第一张长图片查看)。
向下拖拽到最顶部并持续向下拖拽时,图片遂手势移动,并变小,背景逐渐透明。松手瞬间,如果手势是向下移动的,B页退出,如果手势是向上移动的,图片回到原来的位置

解决方案分析

那么问题来了,拖拽的交互理所当然动用手势-UIPanGestureRecognizer,添加给谁呢?

首先我们来看一下一个手势发生时,发生了哪些事情:
1、生成一个UIEvent事件;
2、通过事件响应链查找最合适的事件执行者;
3、调用事件执行者绑定的手势事件。
所以,手势所绑定的事件在执行前,会先通过逐级查找的方式,找到最适合响应手势的控件,再执行其方法,流程如下图(白色原点处发生点击)


事件响应链.jpeg

图中控件与上面的结构图的控件一致,蓝色线是向下询问过程,橙色是返回结果的过程。
询问过程:
1、application:window你好,我收到一个事件(UIEvent),是在点你身上的,你看一看具体是你哪个孩子(subview)来响应,问好了告诉我。
2、window:我确认了一下,我是可见的(hidden != NO && alpha >= 0.01),我是可点击的(userInteractionEnabled != NO),而且点击的点确实在我身上([self pointInside:point withEvent:event] == YES),那么,我来遍历一下我的孩子们(subview),看看谁最合适,如果没有的话,那就是我了
3、4、5、同上。
返回过程:
1、UIImageView:我不能被点击。
2、UIScrollview:我的孩子都不能响应,那就是我了。
3、YYPhotoBrowserMainScrollView:UIScrollview可以。
4、5、同上。
最后蓝色那个UIScrollview就成了事件的响应者。
其实这个询问和返回的过程,就是UIView里的方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event

按系统流程重写的话,内部过程如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // 1.判断当前控件能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    
    // 2. 判断点在不在当前控件
    if ([self pointInside:point withEvent:event] == NO) return nil;
    
    // 3.从后往前遍历自己的子控件(后添加的视图在上面,在上面优先响应)
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--)
    {
        UIView *childView = self.subviews[i];
        //把当前控件上的坐标系转换成子控件上的坐标系
        CGPoint childP = [self convertPoint:point toView:childView];
        //让subview继续找它的subview
        UIView *fitView = [childView hitTest:childP withEvent:event];
        if (fitView)//寻找到最合适的view
        {
            return fitView;
        }
    }
    
    // 循环结束,表示没有比自己更合适的view
    return self;
}

然后,再调用响应者绑定的对应方法去执行,并且在调用时会将本次触摸相关的等信息装进UIGestureRecognizer里作为参数。

现在,我们来看一下这几个不可行的方案:

  • 添加一个pan手势给UIImageView,在手势事件中判断手势方向,如果使向上拖拽,把事件传递给scrollview,如果是向下,做“向下拖拽退出”交互效果。
    • 不可行原因:
      要判断手势方向,只能在手势事件中进行(hitTest中无法通过携带的UIEvent参数判断方向),通过方向来改变响应者,而要改变手势响应者,只能在hitTest方法中,但hitTest是在手势事件执行前,所以已经确定了UIImageView为响应者,再想改变响应者,UIImageView就会中断对事件的响应,本次触摸事件就宣告结束,需要再次抬起手指,重新下拉,生成新的事件,这个时候才能让scrollview来响应。
  • 重写scroolview的pan手势的绑定事件。如果是向上拖动,就按系统的交互写,如果是向下,就做“向下拖拽退出”交互效果。
    无论是新建一个pan手势覆盖掉scroolview自带的,还是找到它自带的pan手势打印出它绑定的方法(没记错的话叫handlePan:)然后重写,还是自己用UIView的子类实现一个自定义Scrollview,都算作重写,因为都要自己实现系统的交互效果。
    • 不可行原因:
      理论上不是不可行,是非常难。因为系统交互上,不只是单纯的手指移动,内容跟着动,还有:例如,快速滑动页面后松手,页面会持续滑动并以一个加速度做系数来减速直到停止,这个加速度怎么算?又如:内容滚到顶部后持续下拉再松手,会像弹簧一样弹回去,回去的速度是变化的,这里的加速度又是多少?
可行方案

思考题做到这里,其实答案已经很接近了。
所有拖动的交互都离不开pan手势UIPanGestureRecognizer,所以UIScrollview既然能在手指移动时做事儿,那它也离不开UIPanGestureRecognizer。
翻看UIScrollview的.h文件不难发现,它其实已经暴露了它的pan手势(不暴露就runtime遍历属性,总能找到想要的)。

h文件.jpeg

上文说到,调用手势绑定事件时,会把触摸相关的信息放进手势对象里,所以手指在scrollview里移动时,就能从它的panGestureRecognizer拿到我们想要的信息:手指移动路径,就能根据这些数据,做想要的效果。
所以方案如下:当scrollview在顶部并向下拖拽时,隐藏scrollview中本来的UIImageview,造一个一模一样的用来移动的imageview。获取到scrollview的panGestureRecognizer的手指位置信息,移动并缩放图片,通知代理设置控制器背景透明度。手指松开时,根据手指瞬间方向判断是否需要退出页面,并执行相应操作。

难点解决
  • 手势绑定事件的方法在父类中,怎么实时监控手势发生并移动了呢?怎么监听手势结束呢?
    当然是用到UIScrollview的代理方法。
/** scrollview正在滚动 */
- (void)scrollViewDidScroll:(UIScrollView *)scrollView;   
/** scrollview即将结束拖拽 */
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset;

这里需要注意的是,如果图片没有屏幕大,那么scrollview的contentSize是小于frame的,这个时候并不能拖拽,代理方法自然也不执行。需要设置scrollview的属性:

/** 总是有弹簧效果 */
_mainScrollView.alwaysBounceVertical = YES;
_mainScrollView.alwaysBounceHorizontal = YES;//这是为了左右滑时能够及时回调scrollViewDidScroll代理

注意左右的弹簧属性也要设置,否则向下拖动交互进行中,如果用户开始左右拖动,而mainScrollView不能左右拖动,那代理方法不会执行,会造成图片卡住不动的感觉。

  • 造跟着手指动的imageView时,初始frame的计算
    其实不算很难,但是要场景要考虑全面,因为在拖动时,图片可能已经是放大状态。
- (void)saveFrameBeginPan
{
    imageWidthBeforeDrag = self.mainImageView.yy_width;//开始时的高
    imageHeightBeforeDrag = self.mainImageView.yy_height;//开始时的宽
    //计算图片Y需要考虑到图片此时的高,如果足够高时,交互发生时y一定是0
    CGFloat imageBeginY = (imageHeightBeforeDrag < kMainScreenHeight) ? (kMainScreenHeight - imageHeightBeforeDrag) * 0.5 : 0.0;
    imageYBeforeDrag = imageBeginY; //+ imageHeightBeforeDrag * 0.5;
    //centerX需要考虑到offset
    scrollOffsetX = self.mainScrollView.contentOffset.x;
    CGFloat imageX = -scrollOffsetX;
    imageCenterXBeforeDrag = imageX + imageWidthBeforeDrag * 0.5;
}

为什么是y值加centerX的值?
因为这样图片缩小的效果是往图片最中间缩。

其它小细节
  • 双手捏合来缩放图片时,也会调用代理scrollViewDidScroll
    解决:根据手势的手指数>1判断是否做交互,另外还可以设置一个变量来记录是不是正在缩放。
  • 向下拖拽时向左右滑动会出现上一张/下一张图片的边缘
    解决:拖拽时通知代理隐藏其它图片,结束时显示出来。
  • 手势拖动时,其实scrollview正在被拖动,里面的图片也就正在移动,图片弹回去时,位置就变化了,会有一个瞬移的感觉
    解决:拖动开始记录下offset,结束拖动时赋值回去。
  • 松开时怎么判断是否返回
    解决:在拖动时记录瞬间的方向,即如果当前手势点的y大于之前的,解释向下移动,松开就要退出页面。否则不退出页面。甚至还可以加一个,如果只像下拉了一丢丢,就不退出页面。
  • 当scrollview的offset.y小于0时才执行交互代码,那如果向下拉了,不松手又向上拉,此时offset.y就大于0了,那不就不执行代码了,图片不就卡住不动了吗?
    解决:设置变量来记录是否正在下拉,拉动开始是为yes,结束时为no,当offset.y<0或者变量为yes,就执行下拉。
  • 小细节太多了。。。。

实战效果

项目效果.gif

其它

推荐阅读更多精彩内容