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

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

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

其他版本: 运用Runtime优雅完结微信的手势回来生成浮窗功用

浮窗的作用,便是用来保存你浏览过的网页,也便是一个viewController,关于这种需求保存起来的目标,个人感觉运用一个专门办理的单例目标来做就比较好,便利办理,而且一切的操作都由这个办理类来完结。

结构规划

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

  • 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. 经过点击右上角按钮,再点击按钮创立(也便是经过点击创立)

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

  1. 经过手势回来时手指碰到右下角的区域创立

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

点击创立比较好完结,但是这个手势拖拽回来就有点麻烦了,看上去像是体系的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
}

作用如下:

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

浮窗的翻开和闭合

浮窗创立好了,那当然是增加到屏幕的最上方,我暂时放在[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;
}

作用如下:

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

动画代码都在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;
}

完结

到此浮窗的基本功用都做完了!当然仍是有不足的当地需求优化,别的还能够扩展更多的功用,今后我会增加更多的功用,彻底差异于微信的浮窗,例如能够保存多个控制器、敞开更多的接口、更多的自定义样式等等。

写得很匆促,今后有空继续改善吧。