iOS|iOS 模拟QQ音乐唱片动画实现
记录下现在的一些想法,将来遗忘的时候方便查阅。
最近在复习Core Animation、Core Graphics以及UIKit性能调优等相关方面的知识点,寻思找个demo来练练手。在手机里翻来翻去最后相中了QQ音乐这个播放页面的UI,如下图所示:
文章图片
QQ音乐播放界面
乍一看好像并不复杂,但是实际操作时会发现其中的细节需要花很多心思来处理,下面开始逐一叙述实现过程。(demo中仅实现了动画效果,播放器功能将来有机会可以另开一篇文章详细记录)
demo地址:https://github.com/JiYuwei/SpinDiscDemo
(由于Pods文件夹未纳入版本控制,如果下载的demo无法正常运行,cd到项目根目录执行pod install即可)
需要实现的需求
- 背景图(毛玻璃效果、渐变动画)
- 标题(切换曲目、作者信息)
- 播放按钮控件(按钮绘图、监听播放状态与控制动画)
- 唱片(圆角处理、旋转动画、换歌动画)
- 后台回到前台重置动画
@interface MusicViewController () @property(nonatomic,strong)UIImageView *baseImgView;
@end
//创建背景视图
-(void)createMaskView
{
_baseImgView=[[UIImageView alloc] initWithFrame:CGRectMake(0, 0, SCREENWIDTH, SCREENHEIGHT)];
_baseImgView.backgroundColor=[UIColor blackColor];
[self.view addSubview:_baseImgView];
}
关于毛玻璃效果,我们第一时间可以想到的方法就是使用UIToolBar或者UIVisualEffectView(iOS 8.0以上可用)覆盖在背景图上,使用方法如下:
UIToolbar *toolBar=[[UIToolbar alloc] initWithFrame:_baseImgView.bounds];
toolBar.barStyle=UIBarStyleBlack;
[_baseImgView addSubview:toolBar];
或者
UIVisualEffectView *blurView=[[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]];
blurView.frame=CGRectMake(0, 0, SCREENWIDTH, SCREENHEIGHT);
[_baseImgView addSubview:blurView];
运行一下,效果如图所示:
文章图片
原图
文章图片
毛玻璃 看上去很不错,不过继续进行下去很快就发现这两种方式会存在一些问题。首先我们来大致地了解下原版QQ音乐界面对于性能上的优化方式。
Xcode -> Open Developer Tool -> Instruments
文章图片
Xcode菜单 选择Core Animation
文章图片
Instruments 将手机上的QQ音乐打开,切换到播放页面,然后连接电脑,在Core Animation测试工具左上角选择所连接的手机。
文章图片
CoreAnimation 左下角找到Debug Options,这里我们主要看一下 Color OffScreen-Rendered Yellow(离屏渲染)和 Color Blended Layers(混合图层),这两项对于性能的影响比较大,如果想详细了解其他选项的含义及相关知识可以移步 UIKit性能调优实战讲解
勾选 Color OffScreen-Rendered Yellow
文章图片
Debug Options 然后可以看到手机变成了这个样子:
文章图片
QQ音乐界面 【iOS|iOS 模拟QQ音乐唱片动画实现】图中除了状态栏以外没有任何地方被标记为黄色,也就是说没有触发离屏渲染。
回头再测试下我们刚刚写的页面:
文章图片
Demo 整个屏幕都被标记成黄色,也就是说上述两种实现毛玻璃效果的方法都会导致离屏渲染,会在一定程度上影响页面流畅度。
下面我们勾选 Color Blended Layers 再来看看:
文章图片
Debug Options
文章图片
QQ音乐界面 红色表示出现了混合图层,绿色表示没有混合图层。整个界面全部被标记成红色。
这下我们可以很清楚的明白QQ音乐播放界面的设计思路了,尽量规避离屏渲染,使用混合图层模拟所有特效,这样一来,所有的圆角、模糊等效果就要从图片本身着手处理,而不能再从视图上进行处理。
- UIImage+ImageEffects
这是UIImage的一个category,基于vImage实现的模糊效果的代码,增加方法来对图像进行模糊和着色效果,模糊效果非常美观,推荐使用。
#pragma mark - Blur Image/**
*Get blured image.
*
*@return Blured image.
*/
- (UIImage *)blurImage;
/**
*Get the blured image masked by another image.
*
*@param maskImage Image used for mask.
*
*@return the Blured image.
*/
- (UIImage *)blurImageWithMask:(UIImage *)maskImage;
/**
*Get blured image and you can set the blur radius.
*
*@param radius Blur radius.
*
*@return Blured image.
*/
- (UIImage *)blurImageWithRadius:(CGFloat)radius;
/**
*Get blured image at specified frame.
*
*@param frame The specified frame that you use to blur.
*
*@return Blured image.
*/
- (UIImage *)blurImageAtFrame:(CGRect)frame;
由于我们的图片使用SDWebImage来加载,将模糊效果的处理放在加载完成后进行。
NSString *imgUrl = _dataArray[0][@"url"];
//随便找一个图片的url[_baseImgView sd_setImageWithURL:[NSURL URLWithString:imgUrl] completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
if (image) {
_baseImgView.image = [image blurImage];
}
}];
效果如图:
文章图片
模拟毛玻璃效果 由于作为背景图色调需要偏暗,接下来要调整背景图的亮度,本咸鱼尝试了很多种调整亮度的方法,使用Core Image、GPUImage,可行但是效果不太理想,卡顿感严重;后来想到改变imageView的透明度,大家应该注意到了我的Controller的跟视图以及baseImgView的backgroundColor都设置为blackColor,通过修改view的alpha值调整亮度一样可行且不影响流畅度,缺点是依然会造成离屏渲染。
最后的解决方案是在此基础上稍作更改,view的alpha不变,修改图片的透明度来达到效果,这样既不会出现离屏渲染也不会对流畅度造成影响。
新建一个 UIImage+ImageOpacity 的 category
@interface UIImage (ImageOpacity)-(UIImage *)jy_lucidImage;
-(UIImage *)jy_imageWithAlpha:(CGFloat)alpha;
@end
#import "UIImage+ImageOpacity.h"@implementation UIImage (ImageOpacity)-(UIImage *)jy_lucidImage
{
return [self jy_imageWithAlpha:0.5];
}//设置图片透明度
-(UIImage *)jy_imageWithAlpha:(CGFloat)alpha
{
UIGraphicsBeginImageContextWithOptions(self.size,NO,0.0f);
CGContextRef ctx =UIGraphicsGetCurrentContext();
CGRect area =CGRectMake(0,0, self.size.width, self.size.height);
CGContextScaleCTM(ctx,1, -1);
CGContextTranslateCTM(ctx,0, -area.size.height);
CGContextSetBlendMode(ctx,kCGBlendModeMultiply);
CGContextSetAlpha(ctx, alpha);
CGContextDrawImage(ctx, area, self.CGImage);
UIImage*newImage =UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}@end
在Controller中对baseImgView作如下设置:
[_baseImgView sd_setImageWithURL:[NSURL URLWithString:imgUrl] completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
if (image) {
_baseImgView.image = [[image blurImage] jy_lucidImage];
}
}];
效果如图:
文章图片
Demo 这样没有离屏渲染的毛玻璃背景就创建好了。
背景图渐变动画 第一时间想到的就是使用UIView封装的动画来实现,在下一张图片加载完成时使用UIView animateWithDuration:方法更改view的alpha模拟渐变效果,但是实测发现这样的效果会有一个亮度上的波动,由于我的背景色设置为黑色,切换背景时会先变黑在变到下一张图片,而不是平滑过渡。后来想到使用Core Animation中的CATranstion来实现,代码如下:
NSString *imgUrl = _dataArray[index][@"url"];
//下一张背景图url
UIImage *placeHolder = _baseImgView.image;
//使用上一张唱片背景图作为placeholder
[_baseImgView sd_setImageWithURL:[NSURL URLWithString:imgUrl] placeholderImage:placeHolder completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
if (image)
CATransition *transition = [CATransition animation];
transition.type = kCATransitionFade;
transition.duration = 1.0f;
transition.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
[_baseImgView.layer addAnimation:transition forKey:nil];
_baseImgView.image = [[image blurImage] jy_lucidImage];
}
}];
效果
背景转场动画 值得一提的是,如果使用UIToolBar或者UIVisualEffectView覆盖的方式实现毛玻璃效果时,将会导致UIImageView上的转场动画无法播放,这就是刚才所说的另一个问题。目前还不明白具体的原因,有哪位同学对此比较了解的话可以在下方留言。
标题 标题的实现比较简单,直接给出代码:
@interface TitleView : UIView@property(nonatomic,copy)NSDictionary *titleDict;
@end
#import "TitleView.h"@interface TitleView ()@property(nonatomic,strong)UILabel *titleLabel;
@property(nonatomic,strong)UILabel *artistLabel;
@end@implementation TitleView-(instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self createLabels];
}return self;
}-(void)createLabels
{
_titleLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height/2)];
_titleLabel.textAlignment = NSTextAlignmentCenter;
_titleLabel.textColor = [UIColor whiteColor];
_titleLabel.font =[UIFont systemFontOfSize:25];
[self addSubview:_titleLabel];
_artistLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, self.bounds.size.height / 2, self.bounds.size.width, self.bounds.size.height / 2)];
_artistLabel.textAlignment = NSTextAlignmentCenter;
_artistLabel.textColor = [UIColor whiteColor];
_artistLabel.font = [UIFont systemFontOfSize:16];
[self addSubview:_artistLabel];
}-(void)setTitleDict:(NSDictionary *)titleDict
{
if (_titleDict != titleDict) {
_titleDict = titleDict;
}_titleLabel.text = _titleDict[@"title"];
_artistLabel.text = _titleDict[@"artist"];
}@end
需要修改标题信息时,直接修改titleDict属性即可
NSDictionary *titleDic = _dataArray[index][@"titleDic"];
_titleView.titleDict = titleDic;
播放按钮控件 我们需要封装一个集播放、暂停、上一首、下一首等功能于一身的自定义控件,大概像这样:
文章图片
按钮控件 一个view上有3个button,分别用来控制播放/暂停、上一首、下一首。
首先来封装一个自定义button类,可以用这个类创建任意一种我们需要的button,我们叫它ConsoleBtn,头文件如下:
#import typedef NS_ENUM(NSInteger,ConsoleBtnType) {
ConsoleBtnTypePlay,
ConsoleBtnTypeLast,
ConsoleBtnTypeNext,
ConsoleBtnTypePause
};
static CGFloat bigBtnWidth = 60;
static CGFloat smallBtnWidth = 40;
@interface ConsoleBtn : UIButton@property(nonatomic)ConsoleBtnType consoleType;
-(instancetype)initWithFrame:(CGRect)frame ConsoleType:(ConsoleBtnType)type;
@end
我们需要的button有4种,因此我们定义一个枚举区分我们具体需要哪一种button,在初始化方法中,我们对button进行一个大概的设置:
-(void)customBtnStyle
{
self.layer.cornerRadius=self.frame.size.width/2;
self.layer.borderColor=BUTTON_COLOR.CGColor;
self.layer.borderWidth=2.0f;
}
值得注意的是,这里设置button.layer的cornerRadius并不会触发离屏渲染,由于我们的button不需要设置图片,button里的图案我们选择用绘图画出来,只要图形没有超过圆角的部分便不需要使用self.layer.maskToBounds = YES来裁剪,仅设置圆角是不会触发离屏渲染的,只有这两句同时放在一起时才会出现离屏渲染,所以不用担心性能问题。
接下来重写DrawRect方法,根据不同的类型画不同的图案(当然用CALayer也可以实现且性能会更好,不过由于篇幅限制这里不去深究):
#pragma mark - DrawRect
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
//获得图形上下文
//CGContextSetStrokeColor(context, _zColor);
//CGContextSetFillColor(context, _zColor);
[_btnColor set];
//设置颜色
CGContextSetLineWidth(context, 1.0);
//设置线宽switch (_consoleType) {
case ConsoleBtnTypePlay:
[self drawPlayBtnwithContext:context rect:rect];
break;
case ConsoleBtnTypeLast:
[self drawLastBtnwithContext:context rect:rect];
break;
case ConsoleBtnTypeNext:
[self drawNextBtnwithContext:context rect:rect];
break;
case ConsoleBtnTypePause:
[self drawPauseBtnwithContext:context rect:rect];
break;
default:
break;
}
}
//画一个播放按钮(三角形)
- (void)drawPlayBtnwithContext:(CGContextRef)context rect:(CGRect)rect
{
CGPoint sPoints[3];
//坐标点
sPoints[0] =CGPointMake(rect.size.width*0.3+2, rect.size.height*0.3);
//坐标1
sPoints[1] =CGPointMake(rect.size.width*0.3+2, rect.size.height*0.7);
//坐标2
sPoints[2] =CGPointMake(rect.size.width*0.7+2, rect.size.height*0.5);
//坐标3
CGContextAddLines(context, sPoints, 3);
//添加线
CGContextClosePath(context);
//封起来
CGContextDrawPath(context, kCGPathFillStroke);
//根据坐标绘制路径
}
//画一个上一首按钮(竖线加三角形)
- (void)drawLastBtnwithContext:(CGContextRef)context rect:(CGRect)rect
{
CGPoint sPoints[3];
//坐标点
sPoints[0] =CGPointMake(rect.size.width*0.65, rect.size.height*0.65);
//坐标1
sPoints[1] =CGPointMake(rect.size.width*0.65, rect.size.height*0.35);
//坐标2
sPoints[2] =CGPointMake(rect.size.width*0.35, rect.size.height*0.5);
//坐标3
CGContextAddLines(context, sPoints, 3);
//添加线
CGContextClosePath(context);
//封起来
CGContextDrawPath(context, kCGPathFillStroke);
//根据坐标绘制路径CGContextFillRect(context, CGRectMake(rect.size.width*0.35-1, rect.size.height*0.35, 2, rect.size.height*0.3));
}
//画一个下一首按钮(三角形加竖线)
- (void)drawNextBtnwithContext:(CGContextRef)context rect:(CGRect)rect
{
CGPoint sPoints[3];
//坐标点
sPoints[0] =CGPointMake(rect.size.width*0.35, rect.size.height*0.35);
//坐标1
sPoints[1] =CGPointMake(rect.size.width*0.35, rect.size.height*0.65);
//坐标2
sPoints[2] =CGPointMake(rect.size.width*0.65, rect.size.height*0.5);
//坐标3
CGContextAddLines(context, sPoints, 3);
//添加线
CGContextClosePath(context);
//封起来
CGContextDrawPath(context, kCGPathFillStroke);
//根据坐标绘制路径CGContextFillRect(context, CGRectMake(rect.size.width*0.65-1, rect.size.height*0.35, 2, rect.size.height*0.3));
}
//画一个暂停按钮(两条竖线)
- (void)drawPauseBtnwithContext:(CGContextRef)context rect:(CGRect)rect
{
CGContextFillRect(context, CGRectMake(rect.size.width*0.3+2, rect.size.height*0.3, 5, rect.size.height*0.4));
CGContextFillRect(context, CGRectMake(rect.size.width*0.7-7, rect.size.height*0.3, 5, rect.size.height*0.4));
}
ConsoleBtn封装基本完成,接下来封装一个按钮视图控件ConsoleView:
#import
@class ConsoleBtn;
@interface ConsoleView : UIView@property(nonatomic,strong)ConsoleBtn *lastBtn;
@property(nonatomic,strong)ConsoleBtn *playBtn;
@property(nonatomic,strong)ConsoleBtn *nextBtn;
@property(nonatomic) BOOL consoleBtnEnabled;
//传递button事件
-(void)addTarget:(id)target action:(SEL)action;
@end
我们在这个视图中创建三个button,分别为上一首、播放/暂停、下一首;属性consoleBtnEnabled用来控制按钮的可用状态,通过该属性可以设置控件中所有按钮为可用/禁用;为了可以直接在Controller中进行按钮事件回调,我们添加了一个addTarget: action:方法用于传递按钮事件;部分实现代码如下:
@interface ConsoleView ()@property(nonatomic,weak) id target;
@property(nonatomic,assign) SEL action;
@end
-(void)addTarget:(id)target action:(SEL)action
{
self.target=target;
self.action=action;
}
-(void)createButtons
{
CGSize cSize=self.bounds.size;
_lastBtn=[[ConsoleBtn alloc] initWithFrame:CGRectMake(10, (cSize.height-smallBtnWidth)/2, smallBtnWidth, smallBtnWidth) ConsoleType:ConsoleBtnTypeLast];
[_lastBtn addTarget:self action:@selector(btnClicked:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_lastBtn];
_playBtn=[[ConsoleBtn alloc] initWithFrame:CGRectMake((cSize.width-bigBtnWidth)/2, (cSize.height-bigBtnWidth)/2, bigBtnWidth, bigBtnWidth) ConsoleType:ConsoleBtnTypePlay];
[_playBtn addTarget:self action:@selector(btnClicked:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_playBtn];
_nextBtn=[[ConsoleBtn alloc] initWithFrame:CGRectMake(cSize.width-10-smallBtnWidth, (cSize.height-smallBtnWidth)/2, smallBtnWidth, smallBtnWidth) ConsoleType:ConsoleBtnTypeNext];
[_nextBtn addTarget:self action:@selector(btnClicked:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_nextBtn];
}
-(void)btnClicked:(UIButton *)sender
{
[self.target performSelector:self.action withObject:sender afterDelay:0];
}
现在我们可以回到Controller中去创建一个封装好的ConsoleView了:
-(void)createBtns
{
_consoleView=[[ConsoleView alloc] initWithFrame:CGRectMake((SCREENWIDTH-220)/2, SCREENHEIGHT-140, 220, 80)];
[_consoleView addTarget:self action:@selector(buttonClicked:)];
[self.view addSubview:_consoleView];
}
-(void)buttonClicked:(UIButton *)sender
{
ConsoleBtn *btn=(ConsoleBtn *)sender;
//这里可以获取点击的按钮
switch (btn.consoleType) {
case ConsoleBtnTypePlay:
case ConsoleBtnTypePause:
[self playMusic];
break;
case ConsoleBtnTypeLast:
[self loadingLastMusic];
break;
case ConsoleBtnTypeNext:
[self loadingNextMusic];
break;
default:
break;
}
}
唱片视图圆角处理 最重要的环节来了,唱片视图。首先观察一下原版的QQ音乐视图:
文章图片
QQ音乐界面 可以看到唱片视图分为2层,表层放置图片,里层为半透明效果用来模拟唱片边缘。
根据原版的视图层次结构,我们开始封装唱片视图DiscView:
@interface DiscView : UIView- (void)disc_setImageWithUrl:(NSURL *)url;
@end
@interface DiscView() @property(nonatomic,strong)UIView *baseDiscView;
@property(nonatomic,strong)UIImageView *imgDiscView;
@end
- (void)createDisc
{
_baseDiscView=[[UIView alloc] initWithFrame:self.bounds];
_baseDiscView.backgroundColor=[UIColor blackColor];
_baseDiscView.layer.cornerRadius=self.bounds.size.width/2;
_baseDiscView.alpha=0.2f;
[self addSubview:_baseDiscView];
_imgDiscView=[[UIImageView alloc] initWithFrame:CGRectMake(8, 8, self.bounds.size.width-16, self.bounds.size.height-16)];
_imgDiscView.backgroundColor=[UIColor clearColor];
[_imgDiscView zy_cornerRadiusRoundingRect];
//_imgDiscView.layer.cornerRadius = _imgDiscView.bounds.size.width/2;
//_imgDiscView.layer.masksToBounds = YES;
[self addSubview:_imgDiscView];
}
这里的问题刚才实现自定义按钮时我们也遇到了,baseDiscView的圆角我们直接设置没有问题,imgDiscView由于需要显示图片,如果采用注释的方法设置圆角会导致离屏渲染,这里的解决办法依然是从图片本身进行处理,我们使用了 UIImageView+CornerRadius 这个 category,它使用了UIBezierPath对图片进行圆角裁切处理,核心代码如下:
#pragma mark - Kernel
/**
* @brief clip the cornerRadius with image, UIImageView must be setFrame before, no off-screen-rendered
*/
- (void)zy_cornerRadiusWithImage:(UIImage *)image cornerRadius:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType {
CGSize size = self.bounds.size;
CGFloat scale = [UIScreen mainScreen].scale;
CGSize cornerRadii = CGSizeMake(cornerRadius, cornerRadius);
UIGraphicsBeginImageContextWithOptions(size, NO, scale);
CGContextRef currentContext = UIGraphicsGetCurrentContext();
if (nil == currentContext) {
return;
}
UIBezierPath *cornerPath = [UIBezierPath bezierPathWithRoundedRect:self.bounds byRoundingCorners:rectCornerType cornerRadii:cornerRadii];
[cornerPath addClip];
[self.layer renderInContext:currentContext];
[self drawBorder:cornerPath];
UIImage *processedImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
if (processedImage) {
objc_setAssociatedObject(processedImage, &kProcessedImage, @(1), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
self.image = processedImage;
}
这种圆角处理方法不会造成离屏渲染,但对view的背景色有要求,因为是直接裁切图片,如果view的背景色不为透明或与父视图背景色不同则会显示出来,建议使用时将view的背景色设为透明或与父视图相同的背景色。
由于我们的图片都是用的SDWebImage加载,使用此方法需要在加载完成后手动对图片进行处理,因此这个category使用了大量的代码将这一过程自动化,它使用了runtime替换了UIImageView的layoutSubViews方法,将图片圆角处理放在这个方法中进行,同时使用了KVO对UIImageView的image属性进行监听,以便图片发生改变时可以及时的进行处理,需要了解详细的实现原理可以移步 iOS圆角图片实现
这样我们只要在初始化UIImageView时调用zy_cornerRadiusRoundingRect方法,后面使用SDWebImage加载图片就可以自动进行圆角处理了。
回到Controller,我们创建一个DiscView:
@property(nonatomic,strong)DiscView *discView;
_discView = [[DiscView alloc] initWithFrame:CGRectMake(20, 140, SCREENWIDTH-40, SCREENWIDTH-40)];
[self.view addSubview:_discView];
NSString *imgUrl = _dataArray[0][@"url"];
[_discView disc_setImageWithUrl:[NSURL URLWithString:imgUrl]];
效果图:
文章图片
Demo 唱片动画及响应事件 这里是本文的核心部分,由于UIView的动画方法不便于封装且调用起来比较麻烦,我们使用CAAnimation来处理唱片动画。关于CAAnimation的详细介绍可以移步 CAAnimation 核心动画
唱片动画分为旋转动画和换歌动画,为了方便调用我们封装一个唱片动画管理类JYAnimationManager:
#import
#import typedef NSString * JYAnimationType NS_STRING_ENUM;
FOUNDATION_EXPORT JYAnimationType const JYAnimationTypeRotaion;
FOUNDATION_EXPORT JYAnimationType const JYAnimationTypeScaleMove;
FOUNDATION_EXPORT JYAnimationType const JYAnimationTypeFade;
@protocol JYAnimationDelegate - (void)jy_animationDidStart:(CAAnimation *)anim;
- (void)jy_animationDidStop:(CAAnimation *)anim finished:(BOOL)flag;
@end@interface JYAnimationManager : NSObject @property(nonatomic,weak) id delegate;
+ (instancetype)manager;
- (void)jy_addAnimationWithLayer:(CALayer *)layer forKey:(NSString *)key;
- (void)jy_pauseAnimationWithLayer:(CALayer *)layer;
- (void)jy_resumeAnimationWithLayer:(CALayer *)layer;
- (void)jy_removeAnimationFromLayer:(CALayer *)layer forKey:(NSString *)key;
- (void)jy_removeAllAnimationFromLayer:(CALayer *)layer;
@end
根据需求我们用字符串枚举确定要创建的动画类型。这里之所以使用字符串枚举是因为动画添加到layer时可以设置key,key为NSString *类型,将来可以使用JYAnimationType方便地创建、查找、移除对应的动画。
同样地,我们可以为动画类添加delegate用来传递CAAnimation的代理方法。
核心代码实现:
#pragma mark - AnimMethod- (void)jy_addAnimationWithLayer:(CALayer *)layer forKey:(NSString *)key
{
if (key == JYAnimationTypeRotaion) {
[self jy_addRotateAnimationWithLayer:layer];
}
else if (key == JYAnimationTypeScaleMove){
[self jy_addChangeMusicAnimationWithLayer:layer];
}
else if (key == JYAnimationTypeFade){
[self jy_addShowDiscAnimationWithLayer:layer];
}
else{
return;
}
}//暂停动画
- (void)jy_pauseAnimationWithLayer:(CALayer *)layer
{
//申明一个暂停时间为这个层动画的当前时间
CFTimeInterval currTimeoffset = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
layer.speed = 0.0;
//当前层的速度
layer.timeOffset = currTimeoffset;
//层的停止时间设为上面申明的暂停时间
}//恢复动画
- (void)jy_resumeAnimationWithLayer:(CALayer *)layer
{
CFTimeInterval pausedTime = layer.timeOffset;
// 当前层的暂停时间
/** 层动画时间的初始化值 **/
layer.speed = 1.0;
layer.timeOffset = 0.0;
layer.beginTime = 0.0;
/** end **/
CFTimeInterval timeSincePause = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
CFTimeInterval timePause = timeSincePause - pausedTime;
//计算从哪里开始恢复动画
layer.beginTime = timePause;
//让层的动画从停止的位置恢复动效
}//移除指定动画
-(void)jy_removeAnimationFromLayer:(CALayer *)layer forKey:(NSString *)key
{
[layer removeAnimationForKey:key];
}//移除所有动画
-(void)jy_removeAllAnimationFromLayer:(CALayer *)layer
{
[layer removeAllAnimations];
}
旋转动画的代码实现:
//创建旋转动画
- (void)jy_addRotateAnimationWithLayer:(CALayer *)layer
{
CABasicAnimation* rotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
//layer绕z轴旋转
rotationAnimation.toValue = https://www.it610.com/article/[NSNumber numberWithFloat: M_PI * 2.0];
//旋转1圈
rotationAnimation.duration = 30;
//动画时长
rotationAnimation.cumulative = YES;
//延展效果
rotationAnimation.repeatCount = HUGE_VALF;
//重复次数 HUGE_VALF表示无限重复
rotationAnimation.delegate = self;
[layer addAnimation:rotationAnimation forKey:JYAnimationTypeRotaion];
layer.speed = 1.0;
//重设layer的speed防止暂停后没有初始化speed造成动画不播放的问题
}
换歌动画,由于换歌动画分为3部分,缩放、平移、渐变,这里使用动画组来实现:
//切换唱片动画
- (void)jy_addChangeMusicAnimationWithLayer:(CALayer *)layer
{
CABasicAnimation *scaleAnimation = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
//缩放动画
scaleAnimation.duration = 0.2;
//持续时间
scaleAnimation.fromValue = https://www.it610.com/article/[NSNumber numberWithFloat:1.0];
// 开始时的倍率
scaleAnimation.toValue = [NSNumber numberWithFloat:0.9];
// 结束时的倍率
//以下两句同时设置表示动画完成后保持完成时的状态
scaleAnimation.removedOnCompletion = NO;
scaleAnimation.fillMode = kCAFillModeForwards;
CABasicAnimation *moveAnimation = [CABasicAnimation animationWithKeyPath:@"transform.translation"];
//平移动画
moveAnimation.duration = 0.4;
moveAnimation.beginTime = 0.4;
//延迟0.4秒执行
moveAnimation.toValue = https://www.it610.com/article/[NSValue valueWithCGPoint:CGPointMake(0, -[[UIScreen mainScreen] bounds].size.height)];
//目标位置
moveAnimation.removedOnCompletion = NO;
moveAnimation.fillMode = kCAFillModeForwards;
CABasicAnimation *fadeAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
//渐变动画
fadeAnimation.fromValue = https://www.it610.com/article/[NSNumber numberWithFloat:1.0];
//初始透明度
fadeAnimation.toValue = [NSNumber numberWithFloat:0.0];
//结束透明度
fadeAnimation.duration = 0.8;
fadeAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
//消失速度先慢后快
fadeAnimation.removedOnCompletion = NO;
fadeAnimation.fillMode = kCAFillModeForwards;
CAAnimationGroup *group = [CAAnimationGroup animation];
//创建动画组
group.duration = 0.8;
group.animations=@[scaleAnimation,moveAnimation,fadeAnimation];
//包含三种动画
group.removedOnCompletion = NO;
group.fillMode = kCAFillModeForwards;
group.delegate = self;
[layer addAnimation:group forKey:JYAnimationTypeScaleMove];
//添加动画组到layer
}
换歌过程中,第一张唱片消失的同时,第二张唱片会渐变出现,所以最后我们在封装一个渐变显示唱片的动画:
//显示唱片动画
-(void)jy_addShowDiscAnimationWithLayer:(CALayer *)layer
{
CABasicAnimation *fadeAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
fadeAnimation.fromValue = https://www.it610.com/article/[NSNumber numberWithFloat:0.0];
fadeAnimation.toValue = [NSNumber numberWithFloat:1.0];
fadeAnimation.duration = 0.8;
[layer addAnimation:fadeAnimation forKey:JYAnimationTypeFade];
}
delegate传递,我们可以在DiscView中实现manager的代理方法进行动画开始或完成时的回调:
#pragma mark - CAAnimationDelegate-(void)animationDidStart:(CAAnimation *)anim
{
if ([_delegate respondsToSelector:@selector(jy_animationDidStart:)]) {
[_delegate jy_animationDidStart:anim];
}
}-(void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
if ([_delegate respondsToSelector:@selector(jy_animationDidStop:finished:)]) {
[_delegate jy_animationDidStop:anim finished:flag];
}
}
有了JYAnimationManager,下面我们就可以为DiscView创建动画了。
首先在DiscVIew中添加属性,导入头文件这里不多说:
@property(nonatomic,strong)JYAnimationManager *jyAManager;
使用lazy load,重写getter方法:
-(JYAnimationManager *)jyAManager
{
if (!_jyAManager) {
_jyAManager=[JYAnimationManager manager];
_jyAManager.delegate = self;
}return _jyAManager;
}
之后我们可以使用self.jyAManager调用方法创建动画。
现在让我们回到Controller看下button的回调方法:
-(void)buttonClicked:(UIButton *)sender
{
ConsoleBtn *btn=(ConsoleBtn *)sender;
switch (btn.consoleType) {
case ConsoleBtnTypePlay:
case ConsoleBtnTypePause:
[self playMusic];
break;
case ConsoleBtnTypeLast:
[self loadingLastMusic];
break;
case ConsoleBtnTypeNext:
[self loadingNextMusic];
break;
default:
break;
}
}
由于动画受button来控制,我们现在要做的就是:
- 按下播放按钮,唱片开转,按钮变为暂停
- 按下暂停按钮,唱片暂停旋转,按钮变回播放
- 按下上一首/下一首按钮,播放换唱片动画,背景图随之一起变换
#import @protocol DiscViewDelegate - (void)changeDiscDidStart;
- (void)changeDiscDidFinish;
@end@interface DiscView : UIView@property (nonatomic,assign) BOOL switchRotate;
//播放暂停状态开关
@property (nonatomic,weak) id delegate;
- (void)disc_setImageWithUrl:(NSURL *)url;
@end
这个delegate将动画的回调传递到Controller中执行,为换歌动画的回调做准备,暂时先放在一边;我们具体看一下switchRotate属性,这个属性用来控制播放/暂停唱片的旋转动画,具体实现过程如下:
重写setSwitchRotate:方法:
-(void)setSwitchRotate:(BOOL)switchRotate
{
if (_switchRotate!=switchRotate) {
_switchRotate=switchRotate;
}[self checkPlayStatus];
}
在-checkPlayStatus方法中判断switchRotate属性的值,YES为播放,NO为暂停:
-(void)checkPlayStatus
{
if (_switchRotate) {
//如果layer上已有旋转动画,执行恢复动画,否则创建新的动画
if ([_imgDiscView.layer animationForKey:JYAnimationTypeRotaion]) {
[self.jyAManager jy_resumeAnimationWithLayer:_imgDiscView.layer];
}
else{
[self.jyAManager jy_addAnimationWithLayer:_imgDiscView.layer forKey:JYAnimationTypeRotaion];
}
}
else{
//暂停动画
[self.jyAManager jy_pauseAnimationWithLayer:_imgDiscView.layer];
}
}
回到Controller中实现-playMusic:
-(void)playMusic
{
_discView.switchRotate=!_discView.switchRotate;
}
做完这一步,点击中间的大按钮应该就可以控制唱片旋转/暂停了。不过我们还需要根据播放状态切换按钮的播放/暂停状态,因为现在点击播放后,按钮不会变到暂停状态上。我们可以在Controller中使用KVO监听discView的switchRotate属性来实现。
在创建唱片视图的方法中,添加观察者:
[_discView addObserver:self forKeyPath:@"switchRotate" options:NSKeyValueObservingOptionNew context:nil];
#pragma mark - KVO switchRotate
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if ([keyPath isEqualToString:@"switchRotate"]) {
BOOL switchRotate=[change[NSKeyValueChangeNewKey] boolValue];
_consoleView.playBtn.consoleType=switchRotate?ConsoleBtnTypePause:ConsoleBtnTypePlay;
}
}
在ConsoleBtn类中,重写consoleType属性的setter方法:
-(void)setConsoleType:(ConsoleBtnType)consoleType
{
if (_consoleType!=consoleType) {
_consoleType=consoleType;
}[self setNeedsDisplay];
//根据consoleType调用drawRect:进行重绘
}
最后记得移除观察者:
-(void)dealloc
{
[_discView removeObserver:self forKeyPath:@"switchRotate"];
}
这样唱片的旋转/暂停功能基本就实现了,效果图:
Demo 上一首/下一首 (-loadingLastMusic/-loadingNextMusic) 由于切换唱片动画需要用到2个DiscView,我们需要重新对Controller中的discView进行设置。
在Controller中添加一个泛型数组用于存储2个DiscView:
@property(nonatomic,strong)NSMutableArray *discViewArray;
//Lazyload
- (NSMutableArray *)discViewArray
{
if (!_discViewArray) {
_discViewArray = [NSMutableArray array];
}return _discViewArray;
}
移除之前创建的唱片视图,创建新的唱片视图并添加进数组
//创建唱片视图
-(void)createDiscView
{
DiscView *discView1 = [[DiscView alloc] initWithFrame:CGRectMake(20, 140, SCREENWIDTH-40, SCREENWIDTH-40)];
[discView1 addObserver:self forKeyPath:@"switchRotate" options:NSKeyValueObservingOptionNew context:nil];
discView1.delegate = self;
[self.view addSubview:discView1];
DiscView *discView2 = [[DiscView alloc] initWithFrame:CGRectMake(20, 140, SCREENWIDTH-40, SCREENWIDTH-40)];
discView2.delegate = self;
discView2.alpha = 0;
//第二个唱片初始化为隐藏
[self.view insertSubview:discView2 belowSubview:discView1];
//将第二个唱片放在第一个下面[self.discViewArray addObject:discView1];
[self.discViewArray addObject:discView2];
}
由于屏幕中只显示self.discViewArray[0],我们修改一下-playMusic方法,将_discView改为_discViewarray[0]:
-(void)playMusic
{
_discViewArray[0].switchRotate=!_discViewArray[0].switchRotate;
}
下面开始创建切换唱片动画,在DiscView头文件中添加两个新的方法:
- (void)takeOutDiscAnim;
- (void)takeInDiscAnim;
//添加离场动画
-(void)takeOutDiscAnim;
{
[self.jyAManager jy_addAnimationWithLayer:self.layer forKey:JYAnimationTypeScaleMove];
}
//添加入场动画
-(void)takeInDiscAnim
{
[self.jyAManager jy_addAnimationWithLayer:self.layer forKey:JYAnimationTypeFade];
}
回到Controller中,实现-loadingLastMusic/-loadingNextMusic:
static NSInteger musicIndex = 0;
-(void)loadingLastMusic
{
musicIndex = [self minusIndex:musicIndex];
[self changeMusic];
}-(void)loadingNextMusic
{
musicIndex = [self plusIndex:musicIndex];
[self changeMusic];
}
- (NSInteger)plusIndex:(NSInteger)index
{
index++;
if (index>=_dataArray.count) {
index=0;
}return index;
}- (NSInteger)minusIndex:(NSInteger)index
{
index--;
if (index<0) {
index=_dataArray.count-1;
}return index;
}
在-changeMusic中做两件事情,播放切换唱片动画、加载下一首歌曲数据:
-(void)changeMusic
{
//如果唱片正在旋转,暂停之
if (_discViewArray[0].switchRotate) {
_discViewArray[0].switchRotate = NO;
}
[_discViewArray[0] takeOutDiscAnim];
//第一张唱片离场
[_discViewArray[1] takeInDiscAnim];
//第二张唱片入场
[self loadingNextImageAtIndex:musicIndex];
//加载下一首歌曲数据
}
//加载下一首歌曲数据
-(void)loadingNextImageAtIndex:(NSInteger)index
{
//设置歌曲标题/作者信息
NSDictionary *titleDic = _dataArray[index][@"titleDic"];
_titleView.titleDict = titleDic;
NSString *imgUrl = _dataArray[index][@"url"];
//使用上一张唱片背景图作为placeholder
UIImage *placeHolder = _baseImgView.image;
[_baseImgView sd_setImageWithURL:[NSURL URLWithString:imgUrl] placeholderImage:placeHolder completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
if (image) {
_baseImgView.image = [[image blurImage] jy_lucidImage];
CATransition *transition = [CATransition animation];
transition.type = kCATransitionFade;
transition.duration = 1.0f;
transition.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
[_baseImgView.layer addAnimation:transition forKey:nil];
}
}];
[self.discViewArray[1] disc_setImageWithUrl:[NSURL URLWithString:imgUrl]];
}
实现DiscView的delegate,对切换唱片动画开始及完成时进行回调:
#pragma mark - DiscViewDelegate-(void)changeDiscDidStart
{
_consoleView.consoleBtnEnabled=NO;
//离场动画开始时,按钮禁用
//离场动画开始时,将playBtn设为播放状态
if (_consoleView.playBtn.consoleType != ConsoleBtnTypePlay) {
_consoleView.playBtn.consoleType = ConsoleBtnTypePlay;
}//为了避免动画完成时出现闪屏,将discView的alpha值修改放在动画开始时进行
_discViewArray[0].alpha = 0.0;
_discViewArray[1].alpha = 1.0;
}-(void)changeDiscDidFinish
{
_consoleView.consoleBtnEnabled=YES;
//离场动画完成时,按钮可用//离场动画完成时,自动进入播放状态,按钮状态设置为暂停
if (_consoleView.playBtn.consoleType != ConsoleBtnTypePause) {
_consoleView.playBtn.consoleType = ConsoleBtnTypePause;
}//移除前一张唱片的观察者,为新的唱片添加观察者
[_discViewArray[0] removeObserver:self forKeyPath:@"switchRotate"];
[_discViewArray[1] addObserver:self forKeyPath:@"switchRotate" options:NSKeyValueObservingOptionNew context:nil];
//将新的唱片移到视图最前面
[self.view bringSubviewToFront:_discViewArray[1]];
//交换两张唱片在数组中的位置,这一步非常关键
[_discViewArray exchangeObjectAtIndex:0 withObjectAtIndex:1];
//如果新的唱片处在暂停状态,自动开始播放
if (!_discViewArray[0].switchRotate) {
[self playMusic];
}
}
这里的按钮禁用可以做一些细节处理,回到ConsoleView中,重写consoleBtnEnabled的setter方法
-(void)setConsoleBtnEnabled:(BOOL)consoleBtnEnabled
{
if (_consoleBtnEnabled != consoleBtnEnabled) {
_consoleBtnEnabled = consoleBtnEnabled;
}_lastBtn.enabled=_consoleBtnEnabled;
_playBtn.enabled=_consoleBtnEnabled;
_nextBtn.enabled=_consoleBtnEnabled;
}
在ConsoleBtn中,重写enabled属性的setter方法:
#pragma mark - OverRide
-(void)setEnabled:(BOOL)enabled
{
[super setEnabled:enabled];
//按钮禁用时,上一首/下一首显示为灰色
if (self.consoleType == ConsoleBtnTypeLast || self.consoleType == ConsoleBtnTypeNext) {
[self enabledBtnColor:enabled];
}
}-(void)enabledBtnColor:(BOOL)enabled
{
_btnColor = enabled ? BUTTON_COLOR : DISABLE_COLOR;
self.layer.borderColor=enabled?BUTTON_COLOR.CGColor:DISABLE_COLOR.CGColor;
[self setNeedsDisplay];
}
回到DiscView,看一下如何将JYAnimationManager的delegate传递到Controller中;由于离场和入场动画时间相同且在同一时刻开始,所以delegate中只需要判断是否为其中之一即可:
#pragma mark - JYAnimationDelegate-(void)jy_animationDidStart:(CAAnimation *)anim
{
//判断是否为离场动画
if (anim == [self.layer animationForKey:JYAnimationTypeScaleMove]) {
if ([_delegate respondsToSelector:@selector(changeDiscDidStart)]) {
[_delegate changeDiscDidStart];
}}
}-(void)jy_animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
//判断离场动画是否正常执行完
if (anim == [self.layer animationForKey:JYAnimationTypeScaleMove] && flag) {
//移除视图中所有的动画
[self.jyAManager jy_removeAnimationFromLayer:_imgDiscView.layer forKey:JYAnimationTypeRotaion];
[self.jyAManager jy_removeAnimationFromLayer:self.layer forKey:JYAnimationTypeScaleMove];
if ([_delegate respondsToSelector:@selector(changeDiscDidFinish)]) {
[_delegate changeDiscDidFinish];
}
}
}
如果你坚持看到这里,恭喜,整个唱片动画应该已经实现了,效果如下:
Demo 完善与测试 由于CAAnimation动画在app进入后台后会自动移除,所以我们还需要对此进行优化。
在 DiscView的初始化方法中,添加通知:
-(instancetype)initWithFrame:(CGRect)frame
{
if (self=[super initWithFrame:frame]) {
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resetPlayStatus) name:@"CheckPlayStatus" object:nil];
[self createDisc];
}return self;
}//app进入后台后动画会被移除,重回前台后需要重新创建
-(void)resetPlayStatus
{
[self checkPlayStatus];
//根据switchRotate决定播放/暂停动画
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"CheckPlayStatus" object:nil];
}
在AppDelegate的-applicationDidBecomeActive:中,发送通知:
- (void)applicationDidBecomeActive:(UIApplication *)application {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
[[NSNotificationCenter defaultCenter] postNotificationName:@"CheckPlayStatus" object:nil];
}
效果图:
Demo 最后的最后,让我们用Instrument的Core Animation工具测试一下demo
文章图片
Color Off-screen Rendered
文章图片
Color Blended Layers 没有离屏渲染,所有效果使用混合图层模拟,基本和原版的QQ音乐保持一致。
最后看下fps:
文章图片
fps测试 毫无卡顿感,动画效果非常流畅。
看似简单的东西,做起来并不简单。我们要学的东西真的太多,太多。。。
推荐阅读
- Cisco|Cisco Packet Tracer Student(思科网络模拟器)模拟集线器和嗅探攻击
- iOS|iOS NSLocale
- iOS|iOS 视图镂空效果(UIBezierPath,CAShapeLayer)
- iOS中的NSDate
- iOS|iOS 远程推送应用配置过程
- 2020牛客寒假算法基础集训营5.C——C语言IDE超级大模拟
- 2020牛客寒假算法基础集训营4.G——音乐鉴赏概率
- c++|C++(从零开始,万字模拟实现string(超详细))
- 80 - 抓取豆瓣音乐排行榜
- iOS开发集锦之|iOS开发集锦之 2017.06.12