很久以前写的文章,代码还能用,So搬运过来了。
Github地址:高仿微信初版的悬浮小窗口
其他版本: 使用Runtime优雅实现微信的手势返回生成浮窗功能
浮窗的作用,就是用来保存你浏览过的网页,也就是一个viewController,对于这种需要保存起来的对象,个人感觉使用一个专门管理的单例对象来做就比较好,方便管理,并且所有的操作都由这个管理类来完成。
框架设计
- 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
首先看看微信是怎么创建浮窗的:
- 通过点击右上角按钮,再点击按钮创建(也就是通过点击创建)
- 通过手势返回时手指碰到右下角的区域创建
点击创建比较好实现,但是这个手势拖拽返回就有点麻烦了,看上去像是系统的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
}
效果如下:
浮窗的展开和闭合
浮窗创建好了,那当然是添加到屏幕的最上方,我暂时放在[UIApplication sharedApplication].keyWindow
上面,它可以拖,又可以点,所以它自带了UITapGestureRecognizer
和UIPanGestureRecognizer
这两种手势。
- 拖动,就不多说了,就是拖动浮窗,松手后自动贴边。
- 点击,就是展开浮窗显示保存的控制器界面,当点击这个浮窗时,进行展开动画,当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;
}
效果如下:
动画代码都在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
小圆窗有两种情况才会弹出:
- 创建浮窗:当自定义的左边沿手势触发时回调,这时候对小圆窗进行位移(如果该控制器是通过点击浮窗打开的控制器,这时候不需要小圆窗的出现,因为这控制器本来就是浮窗保存的控制器,这时候应该显示的是浮窗而不是小圆窗)
// 正在拖动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];
}
};
- 删除浮窗:当浮窗的拖动手势触发时回调,弹出小圆窗
// 手势开始
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;
}
完成
到此浮窗的基本功能都做完了!当然还是有不足的地方需要优化,另外还可以扩展更多的功能,以后我会添加更多的功能,完全区别于微信的浮窗,例如可以保存多个控制器、开放更多的接口、更多的自定义样式等等。
写得很仓促,以后有空继续改进吧。