BMMusicPlayer播放器。仿网易云转动音效

logo

BMMusicPlayer简介

基于FreeStreamer播放器二次封装。仿网易云封面图旋转,转圈音乐动效。全局基于ASDK。

里面有几个算是技术点吧,分别为:

  • 仿’爱奇艺’Tabbar点击动画切换效果
  • list页面cell采用ASDK实现layer化
  • 播放器单利实现,除”关闭”,”进度条滑块”采用ASDK实现layer化。
  • 播放器背景使用封面图背景高斯模糊效果,获取图片主色调作为shandow。音效动画同样获取图片主色调。

现在来分别的来详细说说

仿”爱奇艺”Tabbar的点击切换效果。

之前一直觉得爱奇艺的动画做的好屌~很多动画单纯用代码实现,仅一个路径就要画死人。并且很多app的小控件做的都很厉害。你可以看看很多主流的播放器的播放暂停按钮,那个动画简直让人不知道如何用纯代码实现。在羡慕的同时就发现一个很有意思的库:lottie。这个是airbnb开源的一个动画库,当然现在已经很普及了。它是通过设计师设计或者网上下载一些素材通过Adobe After Effects配合对应的外挂扩展插件到处一个json。然后再项目带入对应的库,调用json来实现动画效果。

整体体验下来效果还是不错的。而且过程也很简单。但是有一个痛点是灵活性不足—不方便于修改。一般来说都是设计师做好了整个成品(注意:里面可能还设计尺寸匹配),然后生成json,各端导入。而json文件为了大小,可读性基本为0。那么就带来了另外一个问题。几乎不可以二次修改。

有一个官方的素材库:https://www.lottiefiles.com/ 里面的动画真的厉害的不行。但是基于第二点问题。里面真正开源,也就是带有AEP资源的其实很少。而一般来说这种动画都需要做一点程度的定制化,也就导致真正可用的其实不多。

不过~如果您公司有一个牛逼的设计师。用这个库还是超级爽歪歪的。

ASDK

ASDK也就是FaceBook开源的非常出名的AsyncDisplayKit,但已经改名为Texture

最早知道这个库是在研究YYKit时,作者提到很多核心渲染逻辑是借鉴的它。具体有多调,网上随便搜索一下就知道了。之前一直没有机会接触到,刚好有一个新项目,就用80%的ASDK写了。

基本上重新封装了整个UIKit,大到控制器,小到Label。主要说一说使用感受。

异步控制:之前一直觉得既然是异步渲染,那么在写业务代码的时候是有多繁琐啊。使用下来发现其实还好。正常的View里面的线程处理问题不会特别复杂,我也没遇到什么大的问题。主要遇到的问题都在动画上。

ViewLayer选择:这个库很吊的是,很多空间都可以直接使用Layer来绘制。比如播放器的List页面,里面的封面图,new整个视图,锁整个视图,标题,时间都是在一个层级。但是带来一个弊端是:调式有点傻~ 需要一个一个把isLayerBacked关掉。

布局:首先ASDK是不支持AutoLayout的,引入了新的布局方式Flex。要么你不想有成本就傻傻的用frame布局。但是这明显不是一个正确的选择。说说Flex的学习成本吧,大概就是一天。然后你会发现这种布局方式太爽,太灵活,太兼容性强了。而且代码量也不是特别多。只是刚开始的时候有点不习惯。它的思路永远是从最小控件然后一点一点成Spec到最大。

缺点:学习成本,而且目前的2.7版本也不是一个很稳定版本(截稿位置,最近的commit是1天以前)。第三方的问题。正常的开发其实对第三方的依赖性很强的,而它不是特别的友好。动画问题,不知道这个是不是我个人的原因,总觉得怪怪的。而且因为异步的原因还有闪烁的问题,一直也没有很好的解决办法。

最后,给一点我的个人建议。排除这个库的体积来说,我觉得这个库还是值得引入的。但是已最小模块来使用。也就是说简单的没有交互的简单页面或者控件,小到VIew来用。你可以这样理解 就把他当做是一个可变图片处理器。可以动态绘制出你需要的图。放在对应的View上。或者。。。YYkit

播放器动画

播放器动画其实拆2个,一个是最简单的基础动画,旋转。一个是多条圈的波纹动画。波纹动画上还有一个小圆点,从几个地方旋转出来。

核心思想其实就是把小圆点放在 圈让,然后让圈也旋转。然后通过动画beginTime参数来控制波纹的效果,简单理解就是每个延时一点产生的视觉效果。

眨眼一看波纹动画的代码量好多。我也很觉得在demo使用UIKit的时候其实代码了很少的,只是在ASDK也不知道为什么我用动画组的时候只能执行一个动画。也是排查了很久才找到这个问题。于是需要给每一个动画设置重复的属性。

还有一个点是在圆角上的Shadow,也不算什么难点吧,但是可以提一下,就是分为2块,一块是是作为Shadow,一块作为圆角的裁剪

iOSPalette一个开源的提取一张图片的主色调。使得页面更加的生活灵活,不同的封面图下有不同的光晕和波纹动画,效果十分的赞。算法是Google拔下来。具体看看作者简书就好了,我这里也不过多介绍了。

截图演示(声音播放部分无法演示)

img

主代码说明

1.Tabbar部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
BMTabBarController.m
- (void)tabBar:(UITabBar *)tabBar didSelectItem:(UITabBarItem *)item{
NSArray * sss = self.tabBar.subviews;
NSMutableArray *tabbatButtonArray = [@[] mutableCopy];
for (UIView *view in sss) {
if ([view isKindOfClass:NSClassFromString(@"UITabBarButton")]) {
[tabbatButtonArray addObject:view];
}
}

for (UIView *view in [tabbatButtonArray[item.tag] subviews]) {
if ([view isKindOfClass:NSClassFromString(@"UITabBarSwappableImageView")]) {
[self.animation removeFromSuperview];
NSString * name = item.title;
LOTAnimationView *animation = [LOTAnimationView animationNamed:name];
[view addSubview:animation];
animation.bounds = CGRectMake(0, 0,view.bounds.size.width,view.bounds.size.width);
animation.center = CGPointMake(view.bounds.size.width/2, view.bounds.size.height/2);
[animation playWithCompletion:^(BOOL animationFinished) {
// Do Something
}];
self.animation = animation;
}
}
}

使用同一个animation来播放动画,通过移除添加控制生命周期。

2.播放器页面高斯模糊,shandow,音效动画

1
BMMusicDisplayNode.m
  • 高斯模糊

因为使用asdk异步线程,告诉模糊放在了图片的归解档中。正常在主线程拿到下载后的图片中操作就好了。

1
2
3
4
5
6
7
8
9
10
11
_imageBackGroudNode.imageModificationBlock = ^UIImage * _Nullable(UIImage * _Nonnull image) {
// GPUimage
GPUImageGaussianBlurFilter *filter = [[GPUImageGaussianBlurFilter alloc] init];
filter.blurRadiusInPixels = 40.0;
[filter forceProcessingAtSize:image.size];
GPUImagePicture *pic = [[GPUImagePicture alloc] initWithImage:image];
[pic addTarget:filter];
[pic processImage];
[filter useNextFrameForImageCapture];
return [filter imageFromCurrentFramebuffer];
};
  • 获取主色作为shandow

    1。因为是圆角添加shandow,涉及masksToBounds所以是在coverPictureNode背后有一个同样大小的coverPictureShadowNode上添加的shandow。

    2。使用#import "iOSPalette.h"- (**void**)getPaletteImageColor:(GetColorBlock)block;中获取主颜色,并设置给coverPictureShadowNode。同时这个时候把颜色给音效动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
_coverPictureNode.imageModificationBlock = ^UIImage * _Nullable(UIImage * _Nonnull image) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[image getPaletteImageColor:^(PaletteColorModel *recommendColor, NSDictionary *allModeColorDic, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
weakSelf.coverPictureShadowNode.layer.shadowColor = [UIColor bm_colorWithHexString:recommendColor.imageColorString].CGColor;
weakSelf.coverPictureShadowNode.layer.shadowOffset = CGSizeMake(0,10);
weakSelf.coverPictureShadowNode.shadowRadius = 29;
weakSelf.coverPictureShadowNode.shadowOpacity = 0.5;

for (CALayer *layer in weakSelf.rippleArray) {
layer.borderColor = [UIColor bm_colorWithHexString:recommendColor.imageColorString].CGColor;
}
for (CALayer *layer in weakSelf.rippleCircleArray) {
layer.backgroundColor = [UIColor bm_colorWithHexString:recommendColor.imageColorString].CGColor;
}
});
}];
});
CGRect circleRect = (CGRect) {CGPointZero, CGSizeMake(image.size.width, image.size.height)};
UIGraphicsBeginImageContextWithOptions(circleRect.size, NO, 0);
UIBezierPath *circle = [UIBezierPath bezierPathWithRoundedRect:circleRect cornerRadius:circleRect.size.width/2];
[circle addClip];
[[UIColor whiteColor] set];
[circle fill];
[image drawInRect:circleRect];
UIImage *roundedImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return roundedImage;
};
  • 音效动画

音效动画分为2个小模块:1.旋转。2.波纹

旋转部分:

1
2
3
4
5
6
7
8
9
- (void)addAnimation {
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
animation.duration = 15;
animation.repeatCount = MAXFLOAT;
animation.removedOnCompletion = NO;
animation.toValue = @(M_PI*2);
[self.coverPictureNode.layer addAnimation:animation forKey:@"rotationAnimation"];

}

波纹部分:

​ 在波纹地方有其实就是有kCoverPictureRippleCount条圈通过不同的beginTime实现。在asdk中有一个小问题是当使用动画组的时候,仅显示第一个动画,还没找到原因。当使用正常View时,通过设置动画组,并删除动画相同属性就好了,这样代码也精简很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
- (void)addRippleAnimation {
self.rippleArray = [@[] mutableCopy];
self.rippleCircleArray = [@[] mutableCopy];
CALayer * animationLayer = [CALayer layer];
CGFloat maxRadius = kBMSCREEN_WIDTH /2;
for (int i = 0; i<kCoverPictureRippleCount; i++) {
CALayer * pulsingLayer = [CALayer layer];
pulsingLayer.frame = CGRectMake(0, 0, maxRadius*2, maxRadius*2);
pulsingLayer.position = CGPointMake(BM_FitW(kCoverPictureHW)/2, BM_FitW(kCoverPictureHW)/2);
pulsingLayer.backgroundColor = [UIColor clearColor].CGColor;
pulsingLayer.cornerRadius = maxRadius;
pulsingLayer.borderWidth = kCoverPictureRippleMaxBorderWidth;

CALayer *lay = [CALayer layer];
lay.frame = CGRectMake(0, 0, kCoverPictureRippleCircleSize, kCoverPictureRippleCircleSize);
lay.cornerRadius = kCoverPictureRippleCircleSize/2;
lay.masksToBounds = YES;
lay.position = CGPointMake(maxRadius*2 * sin(45), maxRadius*2 * sin(45));
[pulsingLayer addSublayer:lay];

CAMediaTimingFunction * defaultCurve = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionDefault];

CAAnimationGroup * animationGroup = [CAAnimationGroup animation];
animationGroup.fillMode = kCAFillModeBackwards;
animationGroup.beginTime = CACurrentMediaTime() + i * kCoverPictureRippleDuration / kCoverPictureRippleCount;
animationGroup.duration = kCoverPictureRippleDuration;
animationGroup.repeatCount = HUGE;
animationGroup.timingFunction = defaultCurve;

CABasicAnimation * scaleAnimation = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
scaleAnimation.fromValue = @(BM_FitW(kCoverPictureHW)/2 / maxRadius);
scaleAnimation.toValue = @1.0;
scaleAnimation.beginTime = CACurrentMediaTime() + i * kCoverPictureRippleDuration / kCoverPictureRippleCount;
scaleAnimation.fillMode = kCAFillModeBackwards;
scaleAnimation.timingFunction = defaultCurve;
scaleAnimation.duration = kCoverPictureRippleDuration;
scaleAnimation.repeatCount = HUGE;
scaleAnimation.removedOnCompletion = NO;
scaleAnimation.fillMode = kCAFillModeForwards;


CABasicAnimation *animation = [CABasicAnimation new];
animation.keyPath = @"transform.rotation.z";
animation.beginTime = CACurrentMediaTime() + i * kCoverPictureRippleDuration / kCoverPictureRippleCount;
animation.fromValue = [NSNumber numberWithFloat:i *(M_PI/2)]; // 起始角度
animation.toValue = [NSNumber numberWithFloat:i *(M_PI/2) + 2*M_PI]; // 终止角度
animation.duration = 20;
animation.repeatCount = HUGE;
animation.timingFunction = defaultCurve;
animation.removedOnCompletion = NO;
animation.fillMode = kCAFillModeForwards;

CAKeyframeAnimation * opacityAnimation = [CAKeyframeAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.beginTime = CACurrentMediaTime() + i * kCoverPictureRippleDuration / kCoverPictureRippleCount;
opacityAnimation.values = @[@0.3, @0.5, @0];
opacityAnimation.keyTimes = @[@0, @0.3, @1];
opacityAnimation.duration = kCoverPictureRippleDuration;
opacityAnimation.repeatCount = HUGE;
opacityAnimation.timingFunction = defaultCurve;
opacityAnimation.removedOnCompletion = NO;
opacityAnimation.fillMode = kCAFillModeForwards;

// 有一个位置问题,ASDK使用animationGroup 仅显示一个。
// animationGroup.animations = @[scaleAnimation, opacityAnimation,animation];
[pulsingLayer addAnimation:scaleAnimation forKey:@"plulsing"];
[pulsingLayer addAnimation:animation forKey:@"dsdasdasd"];
[pulsingLayer addAnimation:opacityAnimation forKey:@"plulsidsadang"];
[animationLayer addSublayer:pulsingLayer];
[self.rippleArray addObject:pulsingLayer];
[self.rippleCircleArray addObject:lay];
}
_animationLayer = animationLayer;
[self.coverPictureShadowNode.layer addSublayer:animationLayer];
}

停止动画:

​ 单纯移除动画会导致旋转归零,产生视觉不适。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)updateCoverPictureRotating {
if (!_palybtnNode.selected) {
// 停止动画
CFTimeInterval pausedTime = [self.coverPictureNode.layer convertTime:CACurrentMediaTime() fromLayer:nil];
self.coverPictureNode.layer.speed = 0.0;
self.coverPictureNode.layer.timeOffset = pausedTime;
_animationLayer.hidden = YES;
}else {
CFTimeInterval pausedTime = [self.coverPictureNode.layer timeOffset];
self.coverPictureNode.layer.speed = 1.0;
self.coverPictureNode.layer.timeOffset = 0.0;
self.coverPictureNode.layer.beginTime = 0.0;
CFTimeInterval timeSincePause = [self.coverPictureNode.layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
self.coverPictureNode.layer.beginTime = timeSincePause;
_animationLayer.hidden = NO;
}
}