自定義 ViewController 容器轉場
我們在本文只探討了在 navigation controller 中的兩個 view controller 之間的轉場動畫,但是這些做法在 tab bar controller 或者任何你自己定義的 view controller 容器中也是通用的…
儘管從技術角度來講,使用 iOS 7 的 API,你可以對自定義容器中的 view controllers 做自定義轉場,但是這不是能直接使用的,實現這種效果非常不容易。
請注意我正在討論的自定義檢視控制器容器 (custom container view controllers) 都是 UIViewController
的直接子類,而不是 UITabBarController
UINavigationController
的子類。
對於你自定義的繼承於 UIViewController
的容器子類,並沒有現成可用的 API 允許一個任意的動畫控制器 (animation controller) 將一個子檢視控制器自動轉場到另外一個,不管是可互動式的轉場還是不可互動式的轉場。 我甚至都覺著蘋果根本就不想支援這種方式。蘋果支援下面的這幾種轉場方式:
- Navigation controller 推入和推出頁面
- Tab bar controller 選擇的改變
- Modal 頁面的展示和消失
在本文中,我將向你展示如何自定義檢視控制器容器,並且使其支援第三方的動畫控制器。
預熱準備
看到這裡,你可能對上文我們說到的一些問題犯嘀咕,讓我來告訴你答案吧:
為什麼我們不直接繼承 UINavigationController
或 UITabBarController
,並且使用它們提供的功能的?
有些時候這是你不想要的。可能你想要一個非常特殊的外觀或者行為,和這些類能夠提供給你的差別非常大,因此你必須使用一些黑客式的手段去達到你想要的結果,同時還要擔心繫統框架的版本更新後這些黑客式的手段是否還仍然有效。或者,你就是想完全控制你的檢視控制器容器,避免不得不支援一些特定的功能。
好吧, 那麼為什麼不使用 transitionFromViewController:toViewController:duration:options:animations:completion:
這又是一個好問題,你可能想用這種方式去實現,但是或許你對程式碼的整潔性比較在意,想把這種轉場相關的程式碼封裝在內部。那麼為什麼不使用一個既存的、被良好驗證的設計模式呢?這種設計模式可以非常方便的支援第三方的轉場動畫。
介紹相關的API
在我們開始寫程式碼之前,讓我們先花一分鐘的時間來簡單看一下我們需要的元件吧。
iOS 7 自定義檢視控制器轉場的 API 基本上都是以協議的方式提供的,這也使其可以非常靈活的使用,因為你可以很簡單地將它們插入到你的類中。最主要的五個元件如下:
- 動畫控制器 (Animation Controllers) 遵從
UIViewControllerAnimatedTransitioning
協議,並且負責實際執行動畫。 - 互動控制器 (Interaction Controllers) 通過遵從
UIViewControllerInteractiveTransitioning
協議來控制可互動式的轉場。 - 轉場代理 (Transitioning Delegates) 根據不同的轉場型別方便的提供需要的動畫控制器和互動控制器。
- 轉場上下文 (Transitioning Contexts) 定義了轉場時需要的元資料,比如在轉場過程中所參與的檢視控制器和檢視的相關屬性。 轉場上下文物件遵從
UIViewControllerContextTransitioning
協議,並且這是由系統負責生成和提供的。 - 轉場協調器(Transition Coordinators) 可以在執行轉場動畫時,並行的執行其他動畫。 轉場協調器遵從
UIViewControllerTransitionCoordinator
協議。
正如你從其他的閱讀材料中得知的那樣,轉場有不可互動式和可互動式兩種方式。在本文中,我們將集中精力於不可互動的轉場。這種轉場是最簡單的轉場,也是我們學習的一個好的開始。這意味著我們需要處理上面提到的動畫控制器 (animation controllers),轉場代理 (transitioning delegates) 和轉場上下文 (transitioning contexts)。
閒話少說,讓我們開始動手吧…
示例工程
通過三個階段,我們將要實現一個簡單自定義的檢視控制器容器,它可以對子檢視控制器提供自定義的轉場動畫的支援。
你可以在這裡找到這三個階段的 Xcode 工程的原始碼。
階段 1: 基礎
我們應用中的核心類是 ContainerViewController
,它持有一個UIViewController
例項的陣列,每個例項是一個普通的 ChildViewController
。容器檢視控制器設定了一個帶有可點選圖示,並代表每個子檢視控制器的私有的子檢視:
我們通過點選圖示在不同的子檢視控制器之間切換。在這一階段,子檢視控制器之間切換時是沒有轉場動畫的。
你可以在這裡檢視階段-1的原始碼。
階段 2: 轉場動畫
當我們新增轉場動畫時,我們想要使用一個遵從 UIViewControllerAnimatedTransitioning
協議的動畫控制器(animation controllers)。這個協議聲明瞭 3 個方法,前面的 2 個方法是必須實現的:
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext;
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext;
- (void)animationEnded:(BOOL)transitionCompleted;
通過這些方法,我們可以獲得我們所需的所有東西。當我們的檢視控制器容器準備執行動畫時,我們可以從動畫控制器中獲取動畫的持續時間,並讓其去執行真正的動畫。當動畫執行完畢後,如果動畫控制器實現了可選的 animationEnded:
方法,我們可以呼叫動畫控制器中的 animationEnded:
方法。
但是,首先我們必須把一件事情搞清楚。正如你在上面的方法簽名中看到的那樣,上面兩個必須實現的方法需要一個轉場上下文引數,這是一個遵從 UIViewControllerContextTransitioning
協議的物件。通常情況下,當我們使用系統內建的類時,系統框架為我們建立了轉場上下文物件,並把它傳遞給動畫控制器。但是在我們這種情況下,我們需要自定義轉場動畫,所以我們需要承擔系統框架的責任,自己去建立這個轉場上下文物件。
這就是大量使用協議的方便之處。我們可以不用必須複寫一個私有類,而複寫私有類這種方法是明顯不可行的。我們可以定義自己的類,並使其遵從文件中相應的協議就可以了。
儘管在 UIViewControllerContextTransitioning
協議中聲明瞭很多方法,而且它們都是必須要實現 (required) 的,但是我們現在可以暫時忽略它們中的一些方法,因為我們現在僅僅支援不可互動式的轉場。
同 UIKit 類似,我們定義了一個私有類 NSObject <UIViewControllerContextTransitioning>
。在我們的特定例子中,這個私有類是 PrivateTransitionContext
,它的初始化方法如下實現:
- (instancetype)initWithFromViewController:(UIViewController *)fromViewController toViewController:(UIViewController *)toViewController goingRight:(BOOL)goingRight {
NSAssert ([fromViewController isViewLoaded] && fromViewController.view.superview, @"The fromViewController view must reside in the container view upon initializing the transition context.");
if ((self = [super init])) {
self.presentationStyle = UIModalPresentationCustom;
self.containerView = fromViewController.view.superview;
self.viewControllers = @{
UITransitionContextFromViewControllerKey:fromViewController,
UITransitionContextToViewControllerKey:toViewController,
};
CGFloat travelDistance = (goingRight ? -self.containerView.bounds.size.width : self.containerView.bounds.size.width);
self.disappearingFromRect = self.appearingToRect = self.containerView.bounds;
self.disappearingToRect = CGRectOffset (self.containerView.bounds, travelDistance, 0);
self.appearingFromRect = CGRectOffset (self.containerView.bounds, -travelDistance, 0);
}
return self;
}
我們把檢視的出現和消失時的狀態記錄了下來,比如初始狀態和最終狀態的 frame。
請注意一點,我們的初始化方法需要我們提供我們是在向右切換還是向左切換。在我們的 ContainerViewController
中,按鈕是一個接一個水平排列的,轉場上下文通過設定每個的 frame 來記錄它們之間的位置關係。動畫控制器或者說 animator,在生成動畫時可以使用這些 frame。
我們也可以通過另外的方式去獲取這些資訊,但是那樣的話,就會使 animator 和 ContainerViewController
及其檢視控制器耦合在一起了,這是不好的,我們並不想這樣。animator 應該只關心它自己以及傳遞給它的上下文,因為這樣,在理想情況下,animator 可以在不同的上下文中得到複用。
在下一步實現我們自己的動畫控制器時,我們應該時刻記住這一點,現在讓我們來實現轉場上下文吧。
你可能記得我們在 issue #5 中的View Controller 轉場已經做過相同的事情了,為什麼我們不使用它呢?事實上,由於使用了非常靈活的協議,我們可以直接把那個工程中的動畫控制器,也就是 Animator
類直接拿過來使用,不需要任何修改。
使用 Animator
類的例項來做轉場動畫的核心程式碼如下所示:
[fromViewController willMoveToParentViewController:nil];
[self addChildViewController:toViewController];
Animator *animator = [[Animator alloc] init];
NSUInteger fromIndex = [self.viewControllers indexOfObject:fromViewController];
NSUInteger toIndex = [self.viewControllers indexOfObject:toViewController];
PrivateTransitionContext *transitionContext = [[PrivateTransitionContext alloc] initWithFromViewController:fromViewController toViewController:toViewController goingRight:toIndex > fromIndex];
transitionContext.animated = YES;
transitionContext.interactive = NO;
transitionContext.completionBlock = ^(BOOL didComplete) {
[fromViewController.view removeFromSuperview];
[fromViewController removeFromParentViewController];
[toViewController didMoveToParentViewController:self];
};
[animator animateTransition:transitionContext];
這其中的大部分是在對檢視控制器容器的操作,計算出我們是在向左切換還是向右切換。做動畫的部分基本上只有 3 行程式碼:1) 建立 animator,2) 建立轉場上下文,和 3) 觸發動畫執行。
有了上面的程式碼,轉場效果看起來如下圖所示:
非常酷,我們甚至沒有寫一行動畫相關的程式碼。
你可以在 階段-2 標籤下看到這部分程式碼的變化。在與 階段-1 的對比這裡你可以看到 階段-2 和 階段-1 相對比的完整的程式碼改變。
階段 3: 封裝
我想我們最後要做的一件事情是封裝 ContainerViewController
,使其能夠:
- 提供預設的轉場動畫。
- 提供替換預設動畫控制器的代理。
這意味著我們需要把對 Animator
類的依賴移除,同時需要建立一個代理協議。
我們如下定義這個協議:
@protocol ContainerViewControllerDelegate <NSObject>
@optional
- (void)containerViewController:(ContainerViewController *)containerViewController didSelectViewController:(UIViewController *)viewController;
- (id <UIViewControllerAnimatedTransitioning>)containerViewController:(ContainerViewController *)containerViewController animationControllerForTransitionFromViewController:(UIViewController *)fromViewController toViewController:(UIViewController *)toViewController;
@end
containerViewController:didSelectViewController:
方法使 ContainerViewController
可以很更容易的集成於功能齊全的應用中。
containerViewController:animationControllerForTransitionFromViewController:toViewController:
方法挺有趣的,當然,你可以把它和下面的 UIKit 中的檢視控制器容器的代理協議做對比:
tabBarController:animationControllerForTransitionFromViewController:toViewController:
(UITabBarControllerDelegate
)navigationController:animationControllerForOperation:fromViewController:toViewController:
(UINavigationControllerDelegate
)
所有的這些方法都返回一個 id<UIViewControllerAnimatedTransitioning>
物件。
與之前一直使用一個 Animator
物件不同, 我們現在可以從我們的代理那裡獲取一個動畫控制器:
id<UIViewControllerAnimatedTransitioning>animator = nil;
if ([self.delegate respondsToSelector:@selector (containerViewController:animationControllerForTransitionFromViewController:toViewController:)]) {
animator = [self.delegate containerViewController:self animationControllerForTransitionFromViewController:fromViewController toViewController:toViewController];
}
animator = (animator ?: [[PrivateAnimatedTransition alloc] init]);
如果我們有代理並且它返回了一個 animator,那麼我們就使用這個 animator。否則,我們使用內部私有類 PrivateAnimatedTransition
建立一個預設的 animator。接下來我們將實現 PrivateAnimatedTransition
類。
儘管預設的動畫和 Animator
有一些不同,但是程式碼看起來驚人的相似。下面是完整的程式碼實現:
@implementation PrivateAnimatedTransition
static CGFloat const kChildViewPadding = 16;
static CGFloat const kDamping = 0.75f;
static CGFloat const kInitialSpringVelocity = 0.5f;
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
return 1;
}
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
UIViewController* toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
// When sliding the views horizontally, in and out, figure out whether we are going left or right.
BOOL goingRight = ([transitionContext initialFrameForViewController:toViewController].origin.x < [transitionContext finalFrameForViewController:toViewController].origin.x);
CGFloat travelDistance = [transitionContext containerView].bounds.size.width + kChildViewPadding;
CGAffineTransform travel = CGAffineTransformMakeTranslation (goingRight ? travelDistance : -travelDistance, 0);
[[transitionContext containerView] addSubview:toViewController.view];
toViewController.view.alpha = 0;
toViewController.view.transform = CGAffineTransformInvert (travel);
[UIView animateWithDuration:[self transitionDuration:transitionContext] delay:0 usingSpringWithDamping:kDamping initialSpringVelocity:kInitialSpringVelocity options:0x00 animations:^{
fromViewController.view.transform = travel;
fromViewController.view.alpha = 0;
toViewController.view.transform = CGAffineTransformIdentity;
toViewController.view.alpha = 1;
} completion:^(BOOL finished) {
fromViewController.view.transform = CGAffineTransformIdentity;
[transitionContext completeTransition:![transitionContext transitionWasCancelled]];
}];
}
@end
需要注意的一點是,上面的程式碼沒有通過設定檢視的 frame 來反應它們之間的位置關係,但是程式碼仍然可以正常工作,只不過轉場總是在同一個方向上。因此,這個類也可以被其他的程式碼庫使用。
轉場動畫現在看起來如下所示:
在 階段-3 的程式碼中,app delegate 中設定代理的部分被註釋掉了,這樣就可以看到預設的動畫效果了。你可以將其設定回再使用 Animator
類。你可能想檢視同 階段-2 相比所有的修改。
我們現在有一個自包含的提供了預設轉場動畫的 ContainerViewController
類,這個預設的轉場動畫可以被開發者自己定義的iOS 7 自定義動畫控制器 (UIViewControllerAnimatedTransitioning
) 的物件代替,甚至都可以不用關心我們的原始碼就可以方便的替換。
結論
在本文中我們通過使用 iOS 7 提供的自定義檢視控制器轉場的新特性,使我們自定義的檢視控制器容器成為了 UIKit 的一等公民。
這意味著你可以把自定義的非互動式的轉場動畫應用到自定義的檢視控制器容器中。你可以看到我們把 7 個話題之前使用的轉場類直接拿過來使用,而且沒有做任何修改。
如果你想讓自己的容器檢視控制器作為一個類庫或者框架,或者僅僅想使你的程式碼得到更好的複用,這將是非常完美的。
我們現在僅僅支援非互動式的轉場,下一步就是對互動式的轉場也提供支援。
我把它留給你當作一個練習。這有一些複雜,因為我們基本上是要模仿系統的行為,而這真的全是猜測性的工作。