【iOS】微信初版的悬浮小窗口的实现方案

5,820 阅读11分钟

很久以前写的文章,代码还能用,So搬运过来了。

Github地址:高仿微信初版的悬浮小窗口

jp_gif_file.GIF

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

浮窗的作用,就是用来保存你浏览过的网页,也就是一个viewController,对于这种需要保存起来的对象,个人感觉使用一个专门管理的单例对象来做就比较好,方便管理,并且所有的操作都由这个管理类来完成。

框架设计

框架结构 层级结构.jpg

  • JPSuspensionEntrance:专门用来做管理和操作(规定动画类型、手势的触发规则、管理浮窗等)
// JPSuspensionEntrance的关键属性
/** 主窗口,浮窗的载体(可自定义 默认为[UIApplication sharedApplication].keyWindow) */
@property (nonatomic, strong) UIWindow *window;

/** 绑定的NavigationController 成为其及interactivePopGestureRecognizer的代理 */
@property (nonatomic, strong) UINavigationController *navCtr;

/** 当前浮窗对象(为nil时移除) */
@property (nonatomic, strong) JPSuspensionView *suspensionView;

/** 右下角的判别视图(是否创建、删除浮窗) */
@property (nonatomic, strong) JPSuspensionDecideView *decideView;
  • JPSuspensionView:浮窗对象,保存目标控制器、控制器入口
/**
 * 实例化浮窗 targetVC:控制器, isSuspensionState:是否为浮窗状态(yes直接为浮窗,no为临时状态需缩小动画转成浮窗)
 */
+ (JPSuspensionView *)suspensionViewWithViewController:(UIViewController<JPSuspensionEntranceProtocol> *)targetVC isSuspensionState:(BOOL)isSuspensionState;

/**
 * 实例化浮窗,该方法isSuspensionState为no,用于创建转成浮窗前的视图
 */
+ (JPSuspensionView *)suspensionViewWithViewController:(UIViewController<JPSuspensionEntranceProtocol> *)targetVC;

/** 
 * 转成浮窗的动画
 */
- (void)shrinkSuspensionViewAnimation;

/** 
 * 更新浮窗位置 
 */
- (void)updateSuspensionFrame:(CGRect)suspensionFrame animated:(BOOL)animated;

/** 是否已经为浮窗状态 */
@property (nonatomic, assign, readonly) BOOL isSuspensionState;

/** 保存的控制器 */
@property (nonatomic, strong) UIViewController<JPSuspensionEntranceProtocol> *targetVC;

/** 点击手势->打开控制器 */
@property (nonatomic, weak) UITapGestureRecognizer *tapGR;

/** 拖动手势->拖动浮窗 */
@property (nonatomic, weak) UIPanGestureRecognizer *panGR;

/** 浮窗状态下的拖动手势回调 */
@property (nonatomic, copy) JPSuspensionViewPanBegan panBegan;
@property (nonatomic, copy) JPSuspensionViewPanChanged panChanged;
@property (nonatomic, copy) JPSuspensionViewPanEnded panEnded;

/** 浮窗状态下的位置更新回调 */
@property (nonatomic, copy) void (^suspensionFrameDidChanged)(CGRect suspensionFrame);

指定可以成为浮窗的控制器

我是通过协议来指定可以成为浮窗的控制器,这样可以很好地降低耦合性,只要控制器遵守了JPSuspensionEntranceProtocol协议就可以成为浮窗,并在控制器里面重写协议方法进行个性化定制。

  • JPSuspensionEntranceProtocol:浮窗控制器协议
@protocol JPSuspensionEntranceProtocol <NSObject>
@required
/** 是否隐藏导航栏 */
- (BOOL)jp_isHideNavigationBar;

@optional
/**
 * 需要缓存的信息(例如url)
 */
- (NSString *)jp_suspensionCacheMsg;

/**
 * 浮窗的logo图标
 */
- (UIImage *)jp_suspensionLogoImage;

/**
 * 加载浮窗的logo图标的回调
 * 当“jp_suspensionLogoImage”没有实现或者返回的是nil,就会调用该方法,需要自定义加载方案,这里只提供调用时机
 */
- (void)jp_requestSuspensionLogoImageWithLogoView:(UIImageView *)logoView;
@end

首先看看微信是怎么创建浮窗的:

  1. 通过点击右上角按钮,再点击按钮创建(也就是通过点击创建)

1.点击按钮创建.jpg

  1. 通过手势返回时手指碰到右下角的区域创建

2.手势拖拽创建.jpg

点击创建比较好实现,但是这个手势拖拽返回就有点麻烦了,看上去像是系统的pop动画,但是当你选定创建浮窗时,控制器并没有像系统那样完全移动到屏幕有方,而是先静止再收缩成浮窗

那么问题来了,由于系统返回的pop动画我暂时不知道怎么获取这个控制器的实时位置,我也尝试过在pop的过程中加入CADisplayLink来查看这个控制器的presentationLayer是否有变化,但发现也获取不了实时位置,那怎么对这个控制器进行收缩动画呢?

自定义pop返回动画

看来只有自定义这个pop动画了,既然自定义转场动画,就由JPSuspensionEntrance来成为这个navigationController的代理并重写[-navigationController:animationControllerForOperation:fromViewController:toViewController: ]协议方法,动画的代码较多,就不写在JPSuspensionEntrance里面了,创建JPSuspensionTransition对象,遵守UIViewControllerAnimatedTransitioning协议,在这里面专门写转场动画。

  • JPSuspensionTransition:转场动画对象
// 转场类型
@property (nonatomic, assign, readonly) JPSuspensionTransitionType transitionType;

// 浮窗对象
@property (nonatomic, weak) JPSuspensionView *suspensionView;

// 高仿的系统pop动画 isInteraction:是否手势操控
+ (JPSuspensionTransition *)basicPopTransitionWithIsInteraction:(BOOL)isInteraction;

// 展开浮窗的动画
+ (JPSuspensionTransition *)spreadTransitionWithSuspensionView:(JPSuspensionView *)suspensionView;

// 闭合浮窗的动画
+ (JPSuspensionTransition *)shrinkTransitionWithSuspensionView:(JPSuspensionView *)suspensionView;

// 动画完成
- (void)transitionCompletion;

高仿的系统pop动画逻辑:

- (void)basicPopAnimation {
    NSTimeInterval duration = [self transitionDuration:self.transitionContext];
    
    JPSuspensionView *suspensionView = [JPSuspensionView suspensionViewWithViewController:self.fromVC];
    [suspensionView addSubview:self.fromVC.view];
    
    CGRect toVCFrame = self.toVC.view.frame;
    // 系统pop返回开始时,toVC位于屏幕靠左大概30%屏幕宽度的位置
    toVCFrame.origin.x = -JPSEInstance.window.bounds.size.width * 0.3;
    self.toVC.view.frame = toVCFrame;
    toVCFrame.origin.x = 0;
    
    CGRect fromVCFrame = self.fromVC.view.frame;
    fromVCFrame.origin.x = 0;
    suspensionView.frame = fromVCFrame;
    fromVCFrame.origin.x = JPSEInstance.window.bounds.size.width;
    
    [self.containerView addSubview:self.toVC.view];
    [self.containerView addSubview:self.tabBar];
    [self.containerView addSubview:suspensionView];
    self.suspensionView = suspensionView;
    
    UINavigationController *navCtr = self.toVC.navigationController;
    UINavigationBar *navBar = navCtr.navigationBar;
    CGRect navBarFrame = navBar.frame;
    
    // 如果fromVC本来就隐藏了导航栏,就不需要添加系统动画效果
    [navCtr setNavigationBarHidden:self.isHideToVCNavBar animated:YES];
    if (navBar && navBar.superview) {
        self.navBar = navBar;
        self.navBarSuperView = navBar.superview;
        self.navBarIndex = [navBar.superview.subviews indexOfObject:navBar];
        // 如果fromVC本来就隐藏了导航栏,不添加系统动画,而且将它挪到suspensionView底下,然后自定义动画
        if (self.isHideFromVCNavBar) {
            [navBar.layer removeAllAnimations];
            navBarFrame.origin.x = self.toVC.view.frame.origin.x;
            navBar.frame = navBarFrame;
            navBarFrame.origin.x = 0;
            [self.containerView insertSubview:self.navBar belowSubview:suspensionView];
            // 导航栏removeAllAnimations之后就会置顶显示,为了盖住导航栏,需要将浮窗的zPosition增加
            suspensionView.layer.zPosition = 1;
        }
    }
    
    // 触发了【setNavigationBarHidden:animated:】之后tabBar也会自动添加一个系统动画,将之移除
    CGRect tabBarFrame = CGRectZero;
    if (self.tabBar) {
        [self.tabBar.layer removeAllAnimations];
        tabBarFrame = self.tabBar.frame;
        tabBarFrame.origin.x = self.toVC.view.frame.origin.x;
        self.tabBar.frame = tabBarFrame;
        tabBarFrame.origin.x = 0;
    }
    
    // 经测试,当不是手势触发时,运动轨迹是由快到慢,而手势触发的就必须得是线性!不然跟手指位置不对应~
    UIViewAnimationOptions options = self.isInteraction ? UIViewAnimationOptionCurveLinear : UIViewAnimationOptionCurveEaseOut;
    [UIView animateWithDuration:duration delay:0 options:options animations:^{
        if (self.tabBar) self.tabBar.frame = tabBarFrame;
        if (self.navBar) self.navBar.frame = navBarFrame;
        self.toVC.view.frame = toVCFrame;
        suspensionView.frame = fromVCFrame;
    } completion:^(BOOL finished) {
        [self transitionCompletion];
    }];
}

写好了动画逻辑,还要返回一个手势交互的对象来进行返回拖拽。 先【假设】手指划到屏幕超过一半松手时都能浮窗。

创建JPPopInteraction对象来进行手势交互,在[-navigationController:interactionControllerForAnimationController:]协议方法中返回该对象。

  • JPPopInteraction:pop返回的手势交互对象,触发区域在屏幕左边沿
/** 是否可以开始手势,判断pop操作是手势触发还是点击触发 */
@property (nonatomic, assign) BOOL interaction;

/** 左边沿的手势 */
@property (nonatomic, strong) UIScreenEdgePanGestureRecognizer *edgeLeftPanGR;

/** 手势回调 */
@property (nonatomic, copy) JPEdgeLeftPanBegan panBegan;
@property (nonatomic, copy) JPEdgeLeftPanChanged panChanged;
@property (nonatomic, copy) JPEdgeLeftPanWillEnded panWillEnded;
@property (nonatomic, copy) JPEdgeLeftPanEnded panEnded;

松手时要成为浮窗,这时候就是关键了,当松手时控制器还处于动画状态,这时候获取控制器view的presentationLayer对象,这个对象实时记录了动画过程中的准确位置,有了这个位置后,就可以移除控制器的pop动画状态,然后从containerView抽取出来,然后添加到目标父视图上,再对其进行收缩动画。

我在JPSuspensionView浮窗对象中封装了一个收缩为浮窗的动画方法,当松手或者通过点击时就可以调用这个方法创建浮窗了:

- (void)shrinkSuspensionViewAnimation {
    if (_isSuspensionState) return;
    _isSuspensionState = YES;
    
    self.userInteractionEnabled = NO;
    
    // 当控制器处于动画状态时,要从presentationLayer获取实时位置
    CGRect frame = self.layer.presentationLayer ? self.layer.presentationLayer.frame : self.layer.frame;
    [self.layer removeAllAnimations];
    self.layer.transform = CATransform3DIdentity;
    self.layer.zPosition = 0;
    
    // 从containerView抽取出来,然后添加到目标父视图上
    BOOL isHideNavigationBar = [self.targetVC respondsToSelector:@selector(jp_isHideNavigationBar)] && [self.targetVC jp_isHideNavigationBar];
    if (isHideNavigationBar) {
        // 如果该控制器是隐藏导航栏,就要盖在导航栏上面
        self.frame = [self.superview convertRect:frame toView:JPSEInstance.window];
        [JPSEInstance insertTransitionView:self];
    } else {
        // 否则就插入到导航栏底下
        self.frame = [self.superview convertRect:frame toView:JPSEInstance.navCtr.view];
        [JPSEInstance.navCtr.view insertSubview:self belowSubview:JPSEInstance.navCtr.navigationBar];
    }
   
    // 后面的则是动画逻辑...具体可以查看demo
}

效果如下:

拖拽创建浮窗.gif 点击创建浮窗.gif

浮窗的展开和闭合

浮窗创建好了,那当然是添加到屏幕的最上方,我暂时放在[UIApplication sharedApplication].keyWindow上面,它可以拖,又可以点,所以它自带了UITapGestureRecognizerUIPanGestureRecognizer这两种手势。

  • 拖动,就不多说了,就是拖动浮窗,松手后自动贴边。
  • 点击,就是展开浮窗显示保存的控制器界面,当点击这个浮窗时,进行展开动画,当pop返回时用来要判断是否是这个浮窗的控制器,是的话,就得使用收缩动画,否则就是系统pop动画,这两个动画的逻辑也都写在JPSuspensionTransition中,在协议方法中进行判断来返回相应动画:
- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC {
    BOOL isPush = operation == UINavigationControllerOperationPush;
    self.suspensionTransition = nil;
    
    if (isPush) {
        if (self.suspensionView && self.suspensionView.targetVC == toVC) {
            self.suspensionTransition = [JPSuspensionTransition spreadTransitionWithSuspensionView:self.suspensionView];
        }
    } else {
        if (![fromVC conformsToProtocol:@protocol(JPSuspensionEntranceProtocol)]) return nil;
        if (!self.popInteraction.interaction) {
            // 不是通过手势滑动
            if (self.popNewSuspensionView) {
                // 1.创建新的浮窗
                self.suspensionTransition = [JPSuspensionTransition shrinkTransitionWithSuspensionView:self.popNewSuspensionView];
            } else if (self.suspensionView && self.suspensionView.targetVC == fromVC) {
                // 2.闭合已经展开的浮窗
                self.suspensionTransition = [JPSuspensionTransition shrinkTransitionWithSuspensionView:self.suspensionView];
            }
            // 3.都不是以上两种情况,返回nil
        } else {
            // 判断之前是否从浮窗进入的控制器
            self.isFromSpreadSuspensionView = fromVC == self.suspensionView.targetVC;
            self.suspensionTransition = [JPSuspensionTransition basicPopTransitionWithIsInteraction:self.popInteraction.interaction];
        }
    }
    
    // 返回nil则是系统动画
    return self.suspensionTransition;
}

效果如下:

展开:闭合浮窗.gif 拖拽->闭合本来打开的浮窗.gif

动画代码都在JPSuspensionTransition里面,实现起来比较简单,有兴趣的童鞋可以看看demo。

判定创建/替换/删除的小圆窗

刚刚只是【假设】手指划到屏幕超过一半松手时就能创建浮窗,不一定都创建浮窗的,所以微信是在右下角弄了个小圆窗来让用户选择要不要浮窗,手指碰到了小圆窗才能创建浮窗。

  • JPSuspensionDecideView:判定创建/替换/删除的小圆窗
@interface JPSuspensionDecideView : UIView
+ (instancetype)suspensionDecideView;

// 是否正在显示
@property (nonatomic, assign, readonly) BOOL isShowing;

// 是否碰到了
@property (nonatomic, assign, readonly) BOOL isTouch;

// 是否判断删除操作 还是判断创建操作
@property (nonatomic, assign) BOOL isDecideDelete;

// 判断点
@property (nonatomic, assign) CGPoint touchPoint;

// 显示百分比
@property (nonatomic, assign) CGFloat showPersent;

// 直接显示(当拖动已有浮窗,显示的是删除操作,这时候就调用该方法直接显示)
- (void)show;

// 隐藏(suspensionView:需要移除的浮窗)
- (void)hideWithSuspensionView:(JPSuspensionView *)suspensionView;
@end

小圆窗有两种情况才会弹出:

  1. 创建浮窗:当自定义的左边沿手势触发时回调,这时候对小圆窗进行位移(如果该控制器是通过点击浮窗打开的控制器,这时候不需要小圆窗的出现,因为这控制器本来就是浮窗保存的控制器,这时候应该显示的是浮窗而不是小圆窗)
// 正在拖动pop动画
self.popInteraction.panChanged = ^(CGFloat persent, UIScreenEdgePanGestureRecognizer *edgeLeftPanGR) {
    if (!weakSelf) return;
    __strong typeof(weakSelf) strongSelf = weakSelf;
    CGFloat kPersent = persent * 2.0;  // 作用区域为0~0.5,所以这里乘以2
    if (kPersent > 1) kPersent = 1;
    // 如果当前控制器就是当前浮窗,不需要显示小圆窗,而是显示浮窗
    if (strongSelf.isFromSpreadSuspensionView) {
        strongSelf.suspensionView.alpha = kPersent;  
    } else {
        // 小圆窗必须得手手势交互pop的情况下才能出现
        if (strongSelf.popInteraction.interaction) {
            CGPoint point = [edgeLeftPanGR locationInView:strongSelf.window];
            strongSelf.decideView.showPersent = kPersent;  // 圆心到达右下角位置的百分比
            strongSelf.decideView.touchPoint = point;  // 手指是否碰到了小圆窗
        }
    }
};

// 手指松手的一瞬间
self.popInteraction.panWillEnded = ^(BOOL isToFinish, UIScreenEdgePanGestureRecognizer *edgeLeftPanGR) {
    if (!weakSelf) return;
    __strong typeof(weakSelf) strongSelf = weakSelf;
    // 如果当前控制器就是当前浮窗,不需要显示小圆窗
    if (strongSelf.isFromSpreadSuspensionView) {
        if (isToFinish) [strongSelf.suspensionTransition.suspensionView shrinkSuspensionViewAnimation];
    } else {
        // 判定是否碰到了小圆窗
        if (strongSelf.decideView.isTouch) {
            // 碰到了就创建浮窗
            [strongSelf.suspensionTransition.suspensionView shrinkSuspensionViewAnimation];
        }
        // 隐藏浮窗
        [strongSelf.decideView hideWithSuspensionView:nil];
    }
};
  1. 删除浮窗:当浮窗的拖动手势触发时回调,弹出小圆窗
// 手势开始
suspensionView.panBegan = ^(UIPanGestureRecognizer *panGR) {
    if (!weakSelf) return;
    __strong typeof(weakSelf) strongSelf = weakSelf;
    // 设置小圆圈为删除操作
    strongSelf.decideView.isDecideDelete = YES;  
    // 弹出小圆窗
    [strongSelf.decideView show];  
};
        
// 手势拖动
suspensionView.panChanged = ^(UIPanGestureRecognizer *panGR) {
    if (!weakSelf) return;
    __strong typeof(weakSelf) strongSelf = weakSelf;
    // 获取手指在主窗口的位置
    CGPoint point = [panGR locationInView:strongSelf.window];  
    // 判定是否碰到了小圆窗
    strongSelf.decideView.touchPoint = point;  
};
        
// 手势结束
suspensionView.panEnded = ^BOOL(JPSuspensionView *targetSuspensionView, UIPanGestureRecognizer *panGR) {
    if (!weakSelf) return NO;
    __strong typeof(weakSelf) strongSelf = weakSelf;
    // 隐藏小圆窗,如果碰到了,就带上浮窗一起隐藏然后删除浮窗
    [strongSelf.decideView hideWithSuspensionView:(strongSelf.decideView.isTouch ? targetSuspensionView : nil)];  
    return strongSelf.decideView.isTouch;  // 返回是否删除,不是删除则进行自动贴边
};

我是判定手指位置距离小圆窗的圆心是否小于半径,小于或等于就是碰到了

- (void)setTouchPoint:(CGPoint)touchPoint {
    if (!self.isShowing) return;
    
    // 计算点击的位置距离圆心的距离
    CGFloat distance = sqrt(pow(_showCenter.x - touchPoint.x, 2) + pow(_showCenter.y - touchPoint.y, 2));
    
    // 判定圆形区域之外
    if (distance > _radius) {
        self.isTouch = NO;
    } else {
        self.isTouch = YES;
    }
}

解决手势冲突

到这里基本就差不多完成了,最后还有一点,就是手势的冲突,现在主窗口上最多会有左边沿手势、系统的pop手势共存、浮窗的拖动手势,最好的做法就是只让其中一种手势触发,不然会造成界面错乱,所以我把所有手势的代理都给到了JPSuspensionEntrance进行处理(左边沿手势和系统的pop手势一开始就设置了代理,而浮窗的拖动手势我在浮窗添加到主窗口时再设置其代理):

#pragma mark - UIGestureRecognizerDelegate

- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{

    // 如果是左边沿手势和系统的pop手势
    if (gestureRecognizer == self.navCtr.interactivePopGestureRecognizer ||
        gestureRecognizer == self.popInteraction.edgeLeftPanGR) {

        // 当navigationController的子控制器只有1个的情况下没必要触发这两个手势
        if (self.navCtr.viewControllers.count <= 1) {
            return NO;
        }

        // 先判定最上层的子控制器是否遵守了<JPSuspensionEntranceProtocol>协议(是否可变浮窗)
        BOOL conformsToProtocol = [self.navCtr.viewControllers.lastObject conformsToProtocol:@protocol(JPSuspensionEntranceProtocol)];

        // 如果是系统的pop手势并且遵守了协议
        if (gestureRecognizer == self.navCtr.interactivePopGestureRecognizer && conformsToProtocol) {
            return NO;  // 禁止系统的pop手势
        }

        // 如果是左边沿手势
        if (gestureRecognizer == self.popInteraction.edgeLeftPanGR) {
            // 如果没有遵守协议,或者浮窗的拖动手势正在触发时,就不响应
            if (!conformsToProtocol || (self.suspensionView.panGR.state == UIGestureRecognizerStateBegan || self.suspensionView.panGR.state == UIGestureRecognizerStateChanged) ) {
                return NO;
            }
        }
    }

    // 如果是浮窗的拖动手势
    if (gestureRecognizer == self.suspensionView.panGR) {
        // 如果左边沿手势正在触发就不响应(浮窗的拖动手势与系统的pop手势不冲突)
        if (self.popInteraction.edgeLeftPanGR.state == UIGestureRecognizerStateBegan || self.popInteraction.edgeLeftPanGR.state == UIGestureRecognizerStateChanged) {
            return NO;
        }
    }
    
    return YES;
}

完成

到此浮窗的基本功能都做完了!当然还是有不足的地方需要优化,另外还可以扩展更多的功能,以后我会添加更多的功能,完全区别于微信的浮窗,例如可以保存多个控制器、开放更多的接口、更多的自定义样式等等。

写得很仓促,以后有空继续改进吧。