首先来看下微信上的效果:
再来看下我们的实现效果:
前言
微信的悬浮窗功能已经出来有好几个月了,最近因某些特殊原因正好想尝试实现它。接下来就有了一顿操作(学习)猛如虎,一看效果好像还行滴!在此过程参考过许多大神的资料,也学习过现有的一些demo,但是作为一个完美主义者,网上现有的demo始终达不到我心目中的“高仿”。
接下来,又开始了一播抠图、作图的操作,立求把“高仿”两字体现得淋漓尽致。没错我就是那个你们说的“不会抠图的产品经理不是一个好的程序猿”。
好了,废话了这么多,接下来开始介绍正题:
源码地址
Github地址走过路过给个吧
使用方式
1.如果你的项目没有类似如下代码:
_navigationController.delegate
和_navigationController.interactivePopGestureRecognizer.delegate
也就是没有对UINavigationController
和UINavigationController
的右滑返回手势设置代理。
那么你只需要添加一行代码就能集成...
一行代码,真的只有一行:
//添加要监控的类名
[[FloatBallManager shared] addFloatMonitorVCClasses:@[@"SecondViewController"]];
最好在- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
里添加。
2.如果不巧的是,你的项目设置了上述两个代理(当然,大部分情况下都会设置)。不方,只要添加如下配置就好了:
#pragma mark - UINavigationControllerDelegate
#pragma mark 自定义转场动画
- (id )navigationController:(UINavigationController *)navigationController
animationControllerForOperation:(UINavigationControllerOperation)operation
fromViewController:(UIViewController *)fromVC
toViewController:(UIViewController *)toVC
{
return [[FloatBallManager shared] floatBallAnimationWithOperation:operation fromViewController:fromVC toViewController:toVC];
}
#pragma mark 交互式转场
- (id )navigationController:(UINavigationController *)navigationController
interactionControllerForAnimationController:(id ) animationController
{
return [[FloatBallManager shared] floatInteractionControllerForAnimationController:animationController];
}
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
{
[[FloatBallManager shared] didShowViewController:viewController navigationController:navigationController];
}
技术实现
接下来让我带你一步步讲解实现过程:
1.首先悬浮球的添加位置得是全局置顶的,所以首选添加到UIWindow
上,我们选择[UIApplication sharedApplication].keyWindow
。
2.悬浮球需要添加一个单击手势和一个拖动手势:
//添加拖动手势
[_floatView addGestureRecognizer:[[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(dragFloatView:)]];
//添加点击手势
[_floatView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapFloatView:)]];
3.接下来就是重点:自定义转场动画的实现。
要实现自定义转场动画,得实现UINavigationControllerDelegate
的方法:
- (nullable id )navigationController:(UINavigationController *)navigationController
animationControllerForOperation:(UINavigationControllerOperation)operation
fromViewController:(UIViewController *)fromVC
toViewController:(UIViewController *)toVC NS_AVAILABLE_IOS(7_0);
该方法就是告诉navigationController
,从fromViewController
到toViewController
以哪种 operation
(pop或push)方式,通过UIViewControllerAnimatedTransitioning
协议来自定义该转场动画。
你以为用这个就能实现了吗?不,微信怎么可能用这么low的解决方案。
我们观察微信的实现效果,当手势拖动超过屏幕一半后离开,从离开的位置开始做一个缩小到悬浮球位置,并且跟悬浮球同样大小的动画。
网上的demo大多只实现了这一步。 也就是说从手指离开屏幕的那一刻,动画会直接以一个translate
的方式,将toViewController.view
从屏幕的左边移向右边。
那么,这个动画的转变该怎么实现呢?
最开始我找到了UIViewPropertyAnimator
,并且实现了对动画的转变效果。但是这个类只支持iOS10以上。我用iOS8的设备对微信进行了测试,发现iOS8上也是支持动画转变效果的。
接下来我就开始思考该如何支持到iOS7呢?在闭关研究了一天无果之后,肚子饿得不行,我就觉得得先去填饱肚子先。就在我跨出门的那一刻,脑路突然通了(这个故事告诉我们,在长期深陷于某个问题找不到解决方案的时候,可以先尝试放松下,也许有意外收获呢?)。既然手势是自身添加的(接下来会说),那我完全可以控制整个交互过程呀...
然后,我们要想对整个转场动画进行控制,那我们得实现:
- (nullable id )navigationController:(UINavigationController *)navigationController
interactionControllerForAnimationController:(id ) animationController NS_AVAILABLE_IOS(7_0);
该方法就是告诉navigationController
,你要自定义一个实现了UIViewControllerInteractiveTransitioning
协议的类来全程控制转场。
我们这里用系统给我们封装过一个类UIPercentDrivenInteractiveTransition
,这个类提供了对转场动画的常规控制。
我们主要用到下面三个方法:
//更新转场进度
- (void)updateInteractiveTransition:(CGFloat)percentComplete;
//取消转场 ,用于拖动手势未达到pop条件时,让动画还原
- (void)cancelInteractiveTransition;
//转场结束,这个很重要,不执行的话屏幕会卡在动画结束的那一刻
- (void)finishInteractiveTransition;
具体用法,请继续看下面的介绍。
4.右滑返回手势的监控。
CADisplayLink
:一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。
正常的话我们可以添加一个CADisplayLink
就能监控到当前拖动手势的位置,但是这里它已经满足不了我们的需求了(是的,普通物种已经满足不了人类了)。
这里介绍一个类UIScreenEdgePanGestureRecognizer
,此类继承于UIPanGestureRecognizer
,跟UIPanGestureRecognizer
用法大致相同,但是它多了一个UIRectEdge
属性:
typedef NS_OPTIONS(NSUInteger, UIRectEdge) {
UIRectEdgeNone = 0,
UIRectEdgeTop = 1 << 0,
UIRectEdgeLeft = 1 << 1,
UIRectEdgeBottom = 1 << 2,
UIRectEdgeRight = 1 << 3,
UIRectEdgeAll = UIRectEdgeTop | UIRectEdgeLeft | UIRectEdgeBottom | UIRectEdgeRight
} NS_ENUM_AVAILABLE_IOS(7_0);
也就是说,通过该属性改变手势的边缘触发位置,这里我们设置成gesture.edges = UIRectEdgeLeft;
。
那我们在什么时候添加UIScreenEdgePanGestureRecognizer
呢?
就是使用方法里介绍的第2种情况:
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
{
[[FloatBallManager shared] didShowViewController:viewController navigationController:navigationController];
}
- (void)didShowViewController:(UIViewController *)viewController navigationController:(UINavigationController *)navigationController
{
//如果当前显示的类为我们添加要监控的类,则将系统手势禁用,自己添加一个边缘拖动手势,模拟系统右滑返回交互
if ([self.monitorVCClasses containsObject:NSStringFromClass([viewController class])]) {
navigationController.interactivePopGestureRecognizer.enabled = NO;
// 边缘手势
UIScreenEdgePanGestureRecognizer *gesture = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handleNavigationTransition:)];
gesture.edges = UIRectEdgeLeft;
gesture.delegate = self;
[viewController.view addGestureRecognizer:gesture];
}
else { //将系统右滑返回手势还原
navigationController.interactivePopGestureRecognizer.enabled = YES;
}
}
在UINavigationController
执行完- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
代理方法后,我们对当前屏幕显示的控制器进行一些手势添加与系统手势的禁用控制。
接下来说下UIScreenEdgePanGestureRecognizer
手势回调的简单用法,请忽略中间省略的几万行代码,哈哈哈哈....
- (void)handleNavigationTransition:(UIScreenEdgePanGestureRecognizer *)gestureRecognizer
{
if (gestureRecognizer.state == UIGestureRecognizerStateBegan) {
...//此处省略几万行代码
//手势开始时调用pop方法告诉系统转场要开始了
//kPopWithPanGes是用来判断pop是由手势触发的还是点击左上角返回按钮触发
objc_setAssociatedObject([NSObject currentNavigationController], &kPopWithPanGes, [NSNumber numberWithBool:YES], OBJC_ASSOCIATION_ASSIGN);
[[NSObject currentNavigationController] popViewControllerAnimated:YES];
}
else if (gestureRecognizer.state == UIGestureRecognizerStateChanged) {
...//此处省略几万行代码
//更新转场动画进度
[animator updateInteractiveTransition:progress];
[interactive updateInteractiveTransition:progress];
}
else if (gestureRecognizer.state == UIGestureRecognizerStateEnded ||
gestureRecognizer.state == UIGestureRecognizerStateCancelled) {
...//此处省略几万行代码
//快速滑动时,通过手势加速度算出动画执行时间可移动距离,模拟系统快速拖动时可pop操作
CGPoint velocityPoint = [gestureRecognizer velocityInView:[UIApplication sharedApplication].keyWindow];
CGFloat velocityX = velocityPoint.x * AnimationDuration;
//滑动超过屏幕一半,完成转场
if (fmax(velocityX, point.x) > FloatScreenWidth / 2.0) {
if (notShowFloatContent) {
//右滑手势,滑动至右下角1/4圆内则显示悬浮球
if ([self p_checkTouchPointInRound:point]) {
[animator replaceAnimation];
}
else {
[animator continueAnimationWithFastSliding:velocityX > FloatScreenWidth / 2.0];
}
}
else { //正在显示悬浮球内容
//右滑手势拖动超过一半,手指离开屏幕,也会从当前触摸位置缩小到悬浮球
[animator replaceAnimation];
}
[interactive finishInteractiveTransition];
}
else { //未触发pop,取消转场操作,动画回归
[animator cancelInteractiveTransition];
[interactive cancelInteractiveTransition];
}
}
5.转场动画FloatTransitionAnimator
的介绍
这个类遵循了UIViewControllerAnimatedTransitioning
协议,该协议只有2个方法
//动画执行时间
- (NSTimeInterval)transitionDuration:(nullable id )transitionContext;
//动画执行过程
- (void)animateTransition:(id )transitionContext;
动画的具体实现,看源码吧~~~这里我们用的是UIBezierPath
,不建议在这里用animateWithDuration
来改变frame
与layer.cornerRadius
,动画执行过程很不自然,当然也有可能是我使用姿势不对...具体实现各位自行决定吧。
6.其它说明
a.项目中我用runtime
对UIViewController
与FloatTransitionAnimator
、UIPercentDrivenInteractiveTransition
进行了绑定,因为相互之间进行了相互强引用,所以在交互完之后都进行了手动置nil
,防止循环引用引起内存泄漏。
#pragma mark 手势清除controller绑定的转场动画与转场交互
- (void)p_clearControllerAnimatorAndInteractive:(UIViewController *)vc
{
objc_setAssociatedObject(vc, &kPopInteractiveKey, nil, OBJC_ASSOCIATION_ASSIGN);
objc_setAssociatedObject(vc, &kAnimatorKey, nil, OBJC_ASSOCIATION_ASSIGN);
}
在此提醒各们猿们
,在平常的开发过程中也要多注意这种类似的循环引用。
b.悬浮球拖动到右下角的触发条件,这里判断方法是触摸点到圆心(屏幕右下角的坐标)的距离是否小于圆半径。
//判断手势触摸点是否在圆内
- (BOOL)p_checkTouchPointInRound:(CGPoint)point
{
CGPoint center = CGPointMake(FloatScreenWidth, FloatScreenHeight);
double dx = fabs(point.x - center.x);
double dy = fabs(point.y - center.y);
double distance = hypot(dx, dy);
//触摸点到圆心的距离小于半径,则代表触摸点在圆内
return distance < RoundViewRadius;
}
c.悬浮球进入圆内的手机震动反馈提醒。
这里是用的UIImpactFeedbackGenerator
,该类只支持iOS10以上,它的震动效果更轻柔,至于iOS10以下的震动,各位自身去搜索吧(动起来,不要做伸手党)!
#pragma mark - 手机震动
- (void)p_shockPhone
{
static BOOL canShock = YES;
if (@available(iOS 10.0, *)) {
if (!canShock) {
return;
}
canShock = NO;
UIImpactFeedbackGenerator *impactFeedBack = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[impactFeedBack prepare];
[impactFeedBack impactOccurred];
//防止同时触发几个震动
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
canShock = YES;
});
}
}
总结
好了,大致的原理都已经介绍完了。
其实在开始做之前,我对自定义转场动画一点都不了解,刚看到文档介绍还有那么一丢丢抗拒,甚至也有想过放弃!但是对于技术的坚持让我一点点地去啃下了这个陌生的硬骨头。直到现在把它分享出来之后,我觉得这一切都是有意义了,甚至还有那么一丢丢成就感,可能这就是(程序猿)命吧!
通过这个demo,希望给大家提供一些技术上的帮助吧!
Github地址记得给个小星星哦
顺便给大家附上相关资料传送门吧:
转场动画的详细介绍,很详细