iOS开发系列--一行代码添加高仿微信悬浮球效果

首先来看下微信上的效果:

微信

再来看下我们的实现效果:


悬浮球

前言

微信的悬浮窗功能已经出来有好几个月了,最近因某些特殊原因正好想尝试实现它。接下来就有了一顿操作(学习)猛如虎,一看效果好像还行滴!在此过程参考过许多大神的资料,也学习过现有的一些demo,但是作为一个完美主义者,网上现有的demo始终达不到我心目中的“高仿”。
接下来,又开始了一播抠图、作图的操作,立求把“高仿”两字体现得淋漓尽致。没错我就是那个你们说的“不会抠图的产品经理不是一个好的程序猿”。
好了,废话了这么多,接下来开始介绍正题:

源码地址

Github地址走过路过给个🌟吧

使用方式

1.如果你的项目没有类似如下代码:
_navigationController.delegate_navigationController.interactivePopGestureRecognizer.delegate
也就是没有对UINavigationControllerUINavigationController的右滑返回手势设置代理。
那么你只需要添加一行代码就能集成...
一行代码,真的只有一行:

//添加要监控的类名
[[FloatBallManager shared] addFloatMonitorVCClasses:@[@"SecondViewController"]];

最好在- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions里添加。

2.如果不巧的是,你的项目设置了上述两个代理(当然,大部分情况下都会设置)。不方,只要添加如下配置就好了:

#pragma mark - UINavigationControllerDelegate
#pragma mark 自定义转场动画
- (id <UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                   animationControllerForOperation:(UINavigationControllerOperation)operation
                                                         fromViewController:(UIViewController *)fromVC
                                                           toViewController:(UIViewController *)toVC
{
    return [[FloatBallManager shared] floatBallAnimationWithOperation:operation fromViewController:fromVC toViewController:toVC];
}

#pragma mark 交互式转场
- (id <UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController
                          interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>) 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 <UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                   animationControllerForOperation:(UINavigationControllerOperation)operation
                                                fromViewController:(UIViewController *)fromVC
                                                  toViewController:(UIViewController *)toVC  NS_AVAILABLE_IOS(7_0);

该方法就是告诉navigationController,从fromViewControllertoViewController以哪种 operation(pop或push)方式,通过UIViewControllerAnimatedTransitioning协议来自定义该转场动画。

你以为用这个就能实现了吗?不,微信怎么可能用这么low的解决方案。
我们观察微信的实现效果,当手势拖动超过屏幕一半后离开,从离开的位置开始做一个缩小到悬浮球位置,并且跟悬浮球同样大小的动画。
网上的demo大多只实现了这一步。 也就是说从手指离开屏幕的那一刻,动画会直接以一个translate的方式,将toViewController.view从屏幕的左边移向右边。
那么,这个动画的转变该怎么实现呢?
最开始我找到了UIViewPropertyAnimator,并且实现了对动画的转变效果。但是这个类只支持iOS10以上。我用iOS8的设备对微信进行了测试,发现iOS8上也是支持动画转变效果的。
接下来我就开始思考该如何支持到iOS7呢?在闭关研究了一天无果之后,肚子饿得不行,我就觉得得先去填饱肚子先。就在我跨出门的那一刻,脑路突然通了(这个故事告诉我们,在长期深陷于某个问题找不到解决方案的时候,可以先尝试放松下,也许有意外收获呢?)。既然手势是自身添加的(接下来会说),那我完全可以控制整个交互过程呀...
然后,我们要想对整个转场动画进行控制,那我们得实现:

- (nullable id <UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController
                          interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>) 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 <UIViewControllerContextTransitioning>)transitionContext;
//动画执行过程
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext;

动画的具体实现,看源码吧~~~这里我们用的是UIBezierPath,不建议在这里用animateWithDuration来改变framelayer.cornerRadius,动画执行过程很不自然,当然也有可能是我使用姿势不对...具体实现各位自行决定吧。

6.其它说明
a.项目中我用runtimeUIViewControllerFloatTransitionAnimatorUIPercentDrivenInteractiveTransition进行了绑定,因为相互之间进行了相互强引用,所以在交互完之后都进行了手动置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地址记得给个小星星哦🌟

顺便给大家附上相关资料传送门吧:
转场动画的详细介绍,很详细

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