最近一直在写小说阅读器相关内容,看了当下热门的几款小说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