首先來看下微信上的效果:
再來看下我們的實現效果:
前言
微信的懸浮窗功能已經出來有好幾個月了,最近因某些特殊原因正好想嘗試實現它。接下來就有了一頓操作(學習)猛如虎,一看效果好像還行滴!在此過程參考過許多大神的資料,也學習過現有的一些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 <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
,從fromViewController
到toViewController
以哪種 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
來改變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地址記得給個小星星哦?
順便給大家附上相關資料傳送門吧:
轉場動畫的詳細介紹,很詳細