最近一直在寫小說閱讀器相關内容,看了當下熱門的幾款小說APP,發現打開/關閉書籍的時候都加了一個自定義轉場動畫,但是我們目前的項目是沒有的。作為一個對界面效果有獨特追求的開發者來說,絕對不能忍,加班也要加上去!
大概看了一下動畫效果,發現難度其實不大,簡單歸納為下面幾步:
- 設定兩個視圖,一個為書籍封面截圖,另一個為打開書籍内容截圖,儲存書籍封面視圖;
- 打開書籍時:書籍封面沿書脊位置(左邊Y軸)逆時針旋轉90度并放大到書籍視圖正常(滿屏)大小。書籍内容視圖初始位置和書籍封面重合,和書籍封面同時做放大動畫;
- 關閉書籍時:第一步儲存的書籍封面視圖初始位置與書籍内容重合,并提前沿書脊位置(左邊Y軸)逆時針旋轉90度,做順時針旋轉和縮小動畫,直到恢複到書籍封面正常位置。 書籍内容視圖直接做縮小動畫,縮小到書籍封面正常大小;
- 動畫完成時移除兩個截圖即可。
我做好的效果如下

主要代碼:
1、建立UIViewController的分類UIViewController+Transition,用于統一管理UINavigationControllerDelegate,在分類中新增屬性bookCoverView,用于儲存書籍封面視圖,當然你也可以直接在控制器中寫代碼,隻是新增了重複代碼而已。
UIViewController+Transition.h
NS_ASSUME_NONNULL_BEGIN
@interface UIViewController (Transition)
/// 書籍封面視圖
@property (nonatomic, strong, nullable) UIView *bookCoverView;
@end
NS_ASSUME_NONNULL_END
UIViewController+Transition.m
@interface UIViewController () <UINavigationControllerDelegate>
@end
@implementation UIViewController (Transition)
#pragma mark - Navigation controller delegate
- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC {
if (operation == UINavigationControllerOperationPush && [fromVC isMemberOfClass:[XPYBookStackViewController class]] && [toVC isMemberOfClass:[XPYReaderManagerController class]] && fromVC.bookCoverView) {
// 當XPYBookStackViewController控制器push到XPYReaderManagerController控制器時需要執行自定義push動畫
return [XPYOpenBookAnimation openBookAnimation];
}
if (operation == UINavigationControllerOperationPop && [fromVC isMemberOfClass:[XPYReaderManagerController class]] && [toVC isMemberOfClass:[XPYBookStackViewController class]] && fromVC.bookCoverView) {
// 當XPYReaderManagerController控制器pop到XPYBookStackViewController控制器時需要執行自定義pop動畫
return [XPYCloseBookAnimation closeBookAnimation];
}
return nil;
}
/// 使用者互動相關(這裡不需要)
- (id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController {
return nil;
}
/// 關聯屬性
#pragma mark - Getters
- (UIView *)bookCoverView {
return objc_getAssociatedObject(self, @selector(bookCoverView));
}
#pragma mark - Setters
- (void)setBookCoverView:(UIView *)bookCoverView {
objc_setAssociatedObject(self, @selector(bookCoverView), bookCoverView, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
2、建立push動畫類(XPYOpenBookAnimation)和pop動畫類(XPYCloseBookAnimation),可以公用一個類也可以分開,部落客是分開建立了兩個類。自定義過轉場動畫的開發者都知道,轉場動畫最重要的是實作UIViewControllerAnimatedTransitioning協定中的兩個方法:
(1)- (void)animateTransition:(nonnull id<UIViewControllerContextTransitioning>)transitionContext;
(2)- (NSTimeInterval)transitionDuration:(nullable id<UIViewControllerContextTransitioning>)transitionContext;
第一個方法實作具體的動畫效果,第二個方法傳回動畫執行時間
XPYOpenBookAnimation
@interface XPYOpenBookAnimation () <UIViewControllerAnimatedTransitioning>
@end
@implementation XPYOpenBookAnimation
+ (id<UIViewControllerAnimatedTransitioning>)openBookAnimation {
XPYOpenBookAnimation *animation = [[XPYOpenBookAnimation alloc] init];
return animation;
}
- (void)animateTransition:(nonnull id<UIViewControllerContextTransitioning>)transitionContext {
// 擷取目标視圖
UIViewController *fromController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *tempFromView = nil;
UIView *tempToView = nil;
if ([transitionContext respondsToSelector:@selector(viewForKey:)]) { //iOS8
tempToView = [transitionContext viewForKey:UITransitionContextToViewKey];
tempFromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
} else {
tempToView = toController.view;
tempFromView = fromController.view;
}
// 截圖(afterScreenUpdates:是否所有效果應用在視圖以後再截圖)
UIView *fromView = [fromController.bookCoverView snapshotViewAfterScreenUpdates:NO];
UIView *toView = [tempToView snapshotViewAfterScreenUpdates:YES];
//fromView和toView加入到containerView中
[transitionContext.containerView addSubview:toView];
[transitionContext.containerView addSubview:fromView];
// 儲存frame
CGRect fromFrame = fromController.bookCoverView.frame;
CGRect toFrame = toView.frame;
NSTimeInterval duration = [self transitionDuration:transitionContext];
// 修改anchorPoint為(0, 0.5)(Y軸中間位置)
fromView.layer.anchorPoint = CGPointMake(0, 0.5);
// 修改了anchorPoint之後需要重新設定frame
fromView.frame = fromFrame;
// toView初始位置與fromView一樣
toView.frame = fromFrame;
// 動畫
[UIView animateWithDuration:duration animations:^{
// 沿Y軸逆時針旋轉90度
fromView.layer.transform = CATransform3DMakeRotation(- M_PI_2, 0, 1, 0);
// frame變化
fromView.frame = toFrame;
toView.frame = toFrame;
} completion:^(BOOL finished) {
// 動畫結束移除截圖
[fromView removeFromSuperview];
[toView removeFromSuperview];
// bookCoverView設為nil
fromController.bookCoverView = nil;
// containerView添加目标視圖
[transitionContext.containerView addSubview:tempToView];
// 還原子視圖
[transitionContext.containerView.layer setSublayerTransform:CATransform3DIdentity];
// 結束轉場(這裡使用!transitionContext.transitionWasCancelled,避免手勢取消時造成卡頓現象)
[transitionContext completeTransition:![transitionContext transitionWasCancelled]];
}];
}
/// 動畫時間
- (NSTimeInterval)transitionDuration:(nullable id<UIViewControllerContextTransitioning>)transitionContext {
return 0.8;
}
XPYCloseBookAnimation
@interface XPYCloseBookAnimation () <UIViewControllerAnimatedTransitioning>
@property (nonatomic, strong) UIView *bookCoverView;
@end
@implementation XPYCloseBookAnimation
+ (id<UIViewControllerAnimatedTransitioning>)closeBookAnimation {
XPYCloseBookAnimation *animation = [[XPYCloseBookAnimation alloc] init];
return animation;
}
- (void)animateTransition:(nonnull id<UIViewControllerContextTransitioning>)transitionContext {
UIViewController *fromController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *tempFromView = nil;
UIView *tempToView = nil;
if ([transitionContext respondsToSelector:@selector(viewForKey:)]) { //iOS8
tempFromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
tempToView = [transitionContext viewForKey:UITransitionContextToViewKey];
} else {
tempFromView = fromController.view;
tempToView = toController.view;
}
UIView *toView = [fromController.bookCoverView snapshotViewAfterScreenUpdates:YES];
UIView *fromView = [tempFromView snapshotViewAfterScreenUpdates:NO];
[transitionContext.containerView addSubview:fromView];
[transitionContext.containerView addSubview:toView];
// 儲存frame
CGRect fromFrame = fromView.frame;
CGRect toFrame = fromController.bookCoverView.frame;
NSTimeInterval duration = [self transitionDuration:transitionContext];
// 修改anchorPoint為(0, 0.5)(Y軸中間位置)
toView.layer.anchorPoint = CGPointMake(0, 0.5);
// 修改了anchorPoint之後需要重新設定frame
fromView.frame = fromFrame;
// toView初始位置與fromView一樣
toView.frame = fromFrame;
// 設定toViewY軸初始位置旋轉角度
toView.layer.transform = CATransform3DMakeRotation(- M_PI_2, 0, 1, 0);
// 添加toView并隐藏fromView
[transitionContext.containerView insertSubview:tempToView atIndex:0];
tempFromView.hidden = YES;
[UIView animateWithDuration:duration animations:^{
toView.layer.transform = CATransform3DIdentity;
fromView.frame = toFrame;
toView.frame = toFrame;
} completion:^(BOOL finished) {
// 動畫結束移除截圖
[fromView removeFromSuperview];
[toView removeFromSuperview];
// bookCoverView設為nil
fromController.bookCoverView = nil;
// 還原子視圖
[transitionContext.containerView.layer setSublayerTransform:CATransform3DIdentity];
[transitionContext completeTransition:![transitionContext transitionWasCancelled]];
if ([transitionContext transitionWasCancelled]) {
tempFromView.hidden = NO;
[tempToView removeFromSuperview];
}
}];
}
- (NSTimeInterval)transitionDuration:(nullable id<UIViewControllerContextTransitioning>)transitionContext {
return 0.8;
}
3、最重要部分完成以後隻需要在自己需要執行動畫的控制器中(push為XPYBookStackViewController,pop為XPYReaderManagerController)設定UINavigationControllerDelegate,并在push時将書籍封面儲存到目标控制器中即可。
XPYBookStackViewController(push)
#import "UIViewController+Transition.h"
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
// 每次Appear重新設定NavigationController代理,實作自定義轉場動畫
self.navigationController.delegate = self;
}
/// 因為部落客使用的是UICollectionView展示書籍封面,是以在didSelectItemAtIndexPath代理方法中push
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
// 擷取資料模型
XPYBookModel *book = self.dataSource[indexPath.item];
// XPYBookStackCollectionViewCell是部落客自定義的cell
XPYBookStackCollectionViewCell *cell = (XPYBookStackCollectionViewCell *)[collectionView cellForItemAtIndexPath:indexPath];
// 對cell中的bookCoverImageView封面控件截圖
UIView *snapshotView = [cell.bookCoverImageView snapshotViewAfterScreenUpdates:NO];
// 截圖的frame設定為封面控件在keywindow上的frame
snapshotView.frame = [cell.bookCoverImageView convertRect:cell.bookCoverImageView.frame toView:XPYKeyWindow];
// 設定目前控制器的書籍封面視圖
self.bookCoverView = snapshotView;
// 建立閱讀器
XPYReaderManagerController *reader = [[XPYReaderManagerController alloc] init];
reader.book = book;
// 閱讀器書籍封面視圖使用同一個截圖但是frame不同
UIView *readerSnapshotView = [cell.bookCoverImageView snapshotViewAfterScreenUpdates:NO];
// 進入閱讀器後預設将書籍排在最前位置,是以需要擷取第一本書籍的frame
XPYBookStackCollectionViewCell *firstCell = (XPYBookStackCollectionViewCell *)[self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]];
readerSnapshotView.frame = [firstCell.bookCoverImageView convertRect:firstCell.bookCoverImageView.frame toView:XPYKeyWindow];
// 設定閱讀器的書籍封面視圖
reader.bookCoverView = readerSnapshotView;
[self.navigationController pushViewController:reader animated:YES];
}
XPYReaderManagerController(pop)
#import "UIViewController+Transition.h"
/// 隻需要在退出書籍方法中調用pop
- (void)readMenuDidExitReader {
[self.navigationController popViewControllerAnimated:YES];
}
主要的實作過程和代碼已經說完了,如果還有不明白的地方可以自己下載下傳項目研究,該小說項目内容還是比較豐富的,喜歡的朋友可以點個贊。項目位址:https://github.com/xiangxiaopenyou/XPYReader