使用Runtime优雅实现微信的手势返回生成浮窗功能

2020-03-06  本文已影响0人  健了个平_24

Demo地址

最终效果图

微信的手势返回生成浮窗的效果,我感觉是微信自定义的手势返回动画,毕竟跟系统自带的有些许差别,我之前也使用了高仿系统返回的自定义动画来实现,实现起来比较麻烦,这里介绍另一种更简洁更方便的方案 ---- Runtime。

手势返回生成浮窗最主要是要获取手势返回的进度,通过这个进度控制右下角那个半圆的显示,接着判断松手时的那个点有没有触碰到这个半圆,如果没有就正常返回或取消,如果触碰到了就将控制器的View去执行一个浮窗生成的动画,那就OJBK了。

Runtime

系统返回的pop动画是一个转场动画,但UINavigationController没有公开这个动画相关的API,现在想要获取手势返回的进度,通过Runtime来看看UINavigationController的私有方法有没有:

// 查看类的方法列表
var count: UInt32 = 0
let methodList = class_copyMethodList(UINavigationController.self, &count)
for i in 0 ..< count {
    let method = methodList![Int(i)]
    let name = sel_getName(method_getName(method))
    print(String(cString: name))
}
free(methodList)

打印了一大堆方法,手势转场,方法名应该是带有Interactive这个词的,通过筛选有以下这3个方法挺符合的:

_updateInteractiveTransition:
_finishInteractiveTransition:transitionContext:
_cancelInteractiveTransition:transitionContext:

很明显这3个就是手势控制返回动画的私有API。
OK,知道了这些方法的存在,下一步再使用Runtime交换一下实现:

extension UINavigationController {
    private static func jp_swizzlingForClass(originalSelector: Selector, swizzledSelector: Selector) {
        let originalMethod = class_getInstanceMethod(self, originalSelector)
        let swizzledMethod = class_getInstanceMethod(self, swizzledSelector)
        guard originalMethod != nil, swizzledMethod != nil else {
            return
        }
        method_exchangeImplementations(originalMethod!, swizzledMethod!)
    }

    // 要在AppDelegate里面执行一下这个方法
    static func jp_takeOnceTimeFunc() {
        jp_takeOnceTime
    }
    private static let jp_takeOnceTime: Void = {
        jp_swizzlingForClass(originalSelector: Selector(("_updateInteractiveTransition:")), swizzledSelector: #selector(jp_updateInteractiveTransition(percent:)))
        jp_swizzlingForClass(originalSelector: Selector(("_finishInteractiveTransition:transitionContext:")), swizzledSelector: #selector(jp_finishInteractiveTransition(percent:transitionContext:)))
        jp_swizzlingForClass(originalSelector: Selector(("_cancelInteractiveTransition:transitionContext:")), swizzledSelector: #selector(jp_cancelInteractiveTransition(percent:transitionContext:)))
    }()

    // 手势控制的过程,percent:动画进度
    @objc fileprivate func jp_updateInteractiveTransition(percent: CGFloat) {
        // 先执行一下原本的方法
        jp_updateInteractiveTransition(percent: percent) 
       
    }
    
    // 手势停止,确定完成动画,动画继续直到结束后的状态
    @objc fileprivate func jp_finishInteractiveTransition(percent: CGFloat, transitionContext: UIViewControllerContextTransitioning) {
        // 先执行一下原本的方法
        jp_finishInteractiveTransition(percent: percent, transitionContext: transitionContext)
       
    }
    
    // 手势停止,确定取消动画,动画往返回到开始前的状态
    @objc fileprivate func jp_cancelInteractiveTransition(percent: CGFloat, transitionContext: UIViewControllerContextTransitioning) {
        // 先执行一下原本的方法
        jp_cancelInteractiveTransition(percent: percent, transitionContext: transitionContext)
        
    }
}

接下来得创建一个单例,用来管理右下角的判定半圆和需要生成浮窗的控制器。

我这里写了JPFwAnimator这么一个单例,先简单说明一下:

首先判定半圆decideView得在updateInteractiveTransition之前就添加到navigationController.view上,并且确定是通过手势触发的pop动画才添加,可以交换一下popViewController方法在其里面进行判断,并更新一下其他方法:

@objc fileprivate func jp_popViewController(animated: Bool) -> UIViewController? {
    JPFwAnimator.shrinkFwVC = self.topViewController // 保存一下要生成浮窗的VC

    // 如果pop手势状态是begin,说明是手势返回
    if interactivePopGestureRecognizer?.state == .began {
        // 把判定半圆加上去
        view.addSubview(JPFwAnimator.decideView)
    } else {
        // 否则,就是通过点击返回的,这里就可以直接执行浮窗动画了
    }
    // 调用原本的方法,开始pop动画
    return jp_popViewController(animated: animated)
}

@objc fileprivate func jp_updateInteractiveTransition(percent: CGFloat) {
    jp_updateInteractiveTransition(percent: percent)

    let animator = JPFwAnimator
    guard animator.shrinkFwVC != nil else {
        return
    }

    animator.decideView.showPersent = percent * 2 // * 2 是为了滑到一半就显示完整
    animator.decideView.touchPoint = interactivePopGestureRecognizer!.location(in: view) // 获取手指的点,在内部判定是否在半圆的范围内
}

@objc fileprivate func jp_finishInteractiveTransition(percent: CGFloat, transitionContext: UIViewControllerContextTransitioning) {
    jp_finishInteractiveTransition(percent: percent, transitionContext: transitionContext)
       
    let animator = JPFwAnimator
    guard animator.shrinkFwVC != nil, animator.isPush == false else {
        return
    }

    // 如果是碰到了
    if decideView.isTouching {
        // 执行浮窗动画
    }

    // 隐藏判定半圆并移除
    decideView.decideDoneAnimation()
}
    
@objc fileprivate func jp_cancelInteractiveTransition(percent: CGFloat, transitionContext: UIViewControllerContextTransitioning) {
    jp_cancelInteractiveTransition(percent: percent, transitionContext: transitionContext)

    // 隐藏判定半圆并移除
    decideView.decideDoneAnimation()
}
判定半圆的触碰效果

这里有一个注意的点,有时候即便是碰到了判定半圆,系统还是会执行cancelInteractiveTransition,这是因为手势被取消了,例如在全屏系列的iPhone上滑到了下巴的时候就会取消这个手势,可是我看微信,只要是碰到了就肯定会生成浮窗,所以微信很大可能是自定义的,不过这里也是可以通过Runtime来修改。
过场动画是需要使用UIPercentDrivenInteractiveTransition这个类来控制的,上面那3个方法就是由这个类来调用的,又或者通过打断点来查看:

打印bt查看函数调用栈
那就好办了,交换UIPercentDrivenInteractiveTransition的取消方法的实现即可:
extension UIPercentDrivenInteractiveTransition {
    // 要在AppDelegate里面执行一下这个方法
    static func jp_takeOnceTimeFunc() {
        jp_takeOnceTime
    }
    private static let jp_takeOnceTime: Void = {
        jp_swizzlingForClass(originalSelector: #selector(cancel), swizzledSelector: #selector(jp_cancel))
    }()
    
    // 有时候已经滑到判定区域里面,但还是会取消pop,这是系统自身的判断(例如手指滑到了iPhoneX的下巴),这里hook来自己判断
    @objc fileprivate func jp_cancel() {
        guard JPFwAnimator.shrinkFwVC != nil else {
            jp_cancel()
            return
        }
        if JPFwAnimator.decideView.isTouching == true {
            // 只要碰到了,强行finish,接着就会调用finishInteractiveTransition方法
            finish()
        } else {
            jp_cancel()
        }
    }
}

浮窗动画

现在知道动画的进度和结束了,就剩这个浮窗动画了。
这个动画不难,用maskView进行收缩,再把center设置为目标的点过去就好了。
关键是系统的这个动画无法停止,也就是说不能停住这个控制器去执行自己的动画。
只能自己写一个做浮窗动画的View,放上一张对控制器的view调用snapshotView获取的截图,然后就是设置浮窗动画的初始位置,现在有了动画的进度就可以知道了,percent可以当做这个控制器的view的x在屏幕的比例,接着就是放在navigationController.view上面,记得在动画开始前对控制器的view进行隐藏,执行动画。

// 大概就酱紫,具体可查看Demo

// 动画初始位置
let frame = CGRect(x: percent * shrinkFwVC.view.frame.width, y: shrinkFwVC.view.frame.origin.y, width: shrinkFwVC.view.frame.width, height: shrinkFwVC.view.frame.height)
 // 根据poping控制器的view的位置,创建浮窗对象
let floatingWindow = JPFloatingWindow(frame: frame, floatingVC: shrinkFwVC)
// 添加浮窗到当前容器视图内,盖住poping控制器的view
navCtr.view.insertSubview(floatingWindow, belowSubview: navCtr.navigationBar)
// 隐藏poping控制器的view
fwView.isHidden = true
        
// 搞个随机点
let randomPoint = CGPoint(x: CGFloat(arc4random_uniform(UInt32(jp_portraitScreenWidth_))), y: CGFloat(arc4random_uniform(UInt32(jp_portraitScreenHeight_))))
// 开始浮窗动画
floatingWindow.shrinkFloatingWindowAnimation(floatingPoint: randomPoint) { (kFloatingWindow) in
    kFloatingWindow.removeFromSuperview()
    transitionContext?.completeTransition(true)
    // JPFwManager是管理浮窗的单例
    JPFwManager.floatingWindows.insert(kFloatingWindow, at: 0)
    JPFwManager.floatingWindowsHasDidChanged?(true, 0)
}

打开动画跟浮窗动画差不多,就是反过来的过程。

最后

做到这里就跟微信的几乎差不多了,不过微信上在pop的过程中导航栏有一些地方会有所不同:


正常情况 可以生成浮窗的情况

可以看得出,微信应该是自定义的动画,而且还是自定义的导航栏背景 ---- 动画开始前先把导航栏背景放在底层控制器的view上。
这是对控制器的其他处理,我在Demo里面公开了相应的API,也做了相应的处理,具体可以去Demo看看:


导航栏效果
最终效果图
最后剩下的就是一些业务逻辑的处理(例如多个浮窗的管理、哪些控制器可以浮窗哪些不可以等等),并且得设置相关协议,以后Demo会完善这些功能并整合到一个新的库。

好了,要去搬砖了,先酱紫,Thx~

Demo地址
顺带以前写的高仿版:高仿微信初版的网页悬浮小窗口的小框架

上一篇下一篇

猜你喜欢

热点阅读