使用Runtime优雅实现微信的手势返回生成浮窗功能
微信的手势返回生成浮窗的效果,我感觉是微信自定义的手势返回动画,毕竟跟系统自带的有些许差别,我之前也使用了高仿系统返回的自定义动画来实现,实现起来比较麻烦,这里介绍另一种更简洁更方便的方案 ---- 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
这么一个单例,先简单说明一下:
-
JPFwAnimator.decideView
:右下角的判定半圆,内部封装了相关实现,只需要传入动画进度(percent)来控制显示进度(showPersent),和手指在屏幕上的点(touchPoint)来判定在手指离开屏幕的时候是否生成浮窗(isTouching) -
JPFwAnimator.shrinkFwVC
:popViewController
返回的控制器,就是要生成浮窗的那个控制器
首先判定半圆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个方法就是由这个类来调用的,又或者通过打断点来查看:
那就好办了,交换
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地址
顺带以前写的高仿版:高仿微信初版的网页悬浮小窗口的小框架