作者 | Seven
导读
随着移动互联网的快速发展,业界涌现出大量有创意又有趣的交互体验。扫光动效就是其中一种有意思的加载动效,常见的扫光动效有骨架屏扫光、logo 扫光。那么这两种扫光动效的原理是什么,如何实现这两种扫光效果,以及在 iOS 和 Andoird 双端实现起来有什么差异,本文会为你详细揭晓。
全文 10549 字,预计阅读时间 27 分钟。
01 引言
扫光动效作为移动端的常见加载动效,与传统的转圈加载相比,能给人更好的视觉和感官体验。其主要特点是光效会随着时间进行扫射,文字或图案有被颜色填充的感觉。
笔者先后做过骨架屏扫光、熊掌扫光 loading, 本文将分别从 iOS 和 Android 的视角, 介绍这两种扫光动效的实现和双端的技术差异。
△熊掌扫光动效
02 骨架屏扫光动效
骨架屏是一种界面加载过程中的过渡效果。它在页面数据加载完成前,先给用户展示出页面的大致结构,在拿到接口数据后渲染出实际页面内容然后替换掉。这种技术能够降低用户的焦灼情绪,使界面加载过程变得自然通畅,提升用户体验。常用于文章列表、动态列表页等相对比较规则的列表页面。这里以支付半屏面板面板为例,可以看到有光效在骨架图上扫过的效果。
△骨架屏扫光
2.1 骨架屏扫光原理分析
骨架屏的扫光场景比较简单, 因为其背景是不透明, 可以通过在骨架图上面叠加一个遮罩视图作为光块, 对遮罩进行移动来达到扫光的效果。
其视图的层级整体分位两层,底层为自定义视图的骨架部分,上层为渐变透明遮罩。其中,骨架图部分为常规的列表实现,这里不再赘述,而渐变透明遮罩作为扫光的,可以用切图或通过代码来实现。遮罩切图相对代码实现,会增加一部分包体积,所以可以选择自定义一个和骨架图一样大小的视图,覆盖到骨架图之上,并设置其为从左到右渐变透明。
此外,位移动画,可以通过设置在 xxx 时间内,将遮罩视图在水平方向上,从骨架图的左侧移动到骨架图的右侧来实现。
2.2 iOS 实现
Core Animation 是 AppKit 和 UIKit 完美的底层支持,同时也被整合进入 Cocoa 和 Cocoa Touch 的工作流之中,它是 app 界面渲染和构建的最基础架构。Core Animation 主要职责包含:渲染、构建和实现动画,尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的 layer(iOS 中具体而言就是 CALayer),并且被存储为树状层级结构。这个树也形成了 UIKit 以及在 iOS 应用程序当中我们所能在屏幕上看见的一切的基础。
在 iOS 上,可以通过 CALayer +View 动画方法+Transform 来实现。layer 是 UIView 的底层图层, 负责视图的绘制,动画,边框,阴影等视觉效果。动画部分直接用 View 的类方法 animateWithDuration 即可,在动画回调中通过设置视图的 Transform 属性来实现水平位移。
渐变遮罩部分可以按如下代码定义,通过一个 ImageView 作为遮罩视图,通过设置 CAGradientLayer 做为其 layer 实现透明渐变效果:
// 创建自定义视图作为遮罩视图
_lightCover = [[UIImageView alloc] initWithFrame:self.bounds];
CAGradientLayer *gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = _lightCover.bounds;
// 渐变色颜色数组
gradientLayer.colors = [NSArray arrayWithObjects:
(id)[UIColorFromRGBA(0xFFFFFF, 0) CGColor],
(id)[UIColorFromRGBA(0xFFFFFF, 0.3) CGColor],
(id)[UIColorFromRGBA(0xFFFFFF, 0.5) CGColor],
(id)[UIColorFromRGBA(0xFFFFFF, 0.3) CGColor],
(id)[UIColorFromRGBA(0xFFFFFF, 0) CGColor], nil];
// 渐变的开始点 (不同的起始点可以实现不同位置的渐变,如图)
gradientLayer.startPoint = CGPointMake(0, 0.5f);
// 渐变的结束点
gradientLayer.endPoint = CGPointMake(1, 0.5f);
// 把渐变图层添加到遮罩视图的顶层
[_lightCover.layer insertSublayer:gradientLayer atIndex:0];
// 设置初始位置
_lightCover.transform = CGAffineTransformMakeTranslation(-self.bounds.size.width, 0);
复制代码
通过定时器循环位移动画:
// 定时器:动画时间duration + 延迟时间delay = 定时器间隔时间intervalTime
self.lightSweepTimer = [NSTimer scheduledTimerWithTimeInterval:intervalTime target:self selector:@selector(lightSweepAnimation) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.lightSweepTimer forMode:NSRunLoopCommonModes];
复制代码
动画部分直接用 View 的 animateWithDuration 方法实现:
self.lightCover.transform = CGAffineTransformMakeTranslation(-self.bounds.size.width, 0);
[UIView animateWithDuration:duration animations:^{
self.lightCover.transform = CGAffineTransformMakeTranslation(self.bounds.size.width, 0.f);
} completion:^(BOOL finished) {
}];
复制代码
因为定时器执行时间较长,可以在加载时先执行一次动画:
//定时器时间较长,先执行一次动画
[self lightSweepAnimation];
复制代码
2.3 Android 实现
Android 的渲染技术主要建立在 View 系统之上,View 系统处理视图的布局和绘制。View 代表一个控件,主要负责自己的绘制,ViewGroup 代表一个容器,主要负责管理和布局它包含的子 View 和子 ViewGroup。
这里可以通过自定义 Shape+ObjectAnimator 实现。Shape 是一种特殊的 View,通过 XML 中定义的<shape>标签来实现自定义形状和相关效果,可以通过<shape>的相关属性来绘制出各种形状,并为其应用渐变色、阴影、边框等效果。ObjectAnimator 可以用于所有支持动画的属性,包括位置、大小、旋转、缩放和透明度等,只需指定要动画的属性名称和目标值即可。
遮罩部分可按如下定义一个渐变矩形:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#00ffffff"
android:centerColor="#7fffffff"
android:endColor="#00ffffff"
></gradient>
</shape>
复制代码
再定义一个 Handler,用于在主线程刷新视图:
Handler mHandler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
int what = msg.what;
if (msgAnimation == what) {
runAnimation();
}
return false;
}
});
复制代码
通过 ObjectAnimator 以及属性 translationX 定义位移动画:
private void runAnimation() {
if (displayWidth == 0) {
displayWidth = defaultWidth;
}
ObjectAnimator translationX = ObjectAnimator.ofFloat(mMoveLight, "translationX", -displayWidth, displayWidth);
translationX.setDuration(duration);
translationX.setRepeatCount(0);
translationX.start();
translationX.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
sendMsg(msgAnimation, delayTime);
}
});
}
复制代码
延迟一段时间后继续执行动画:
private void sendMsg(int what, int delayTime) {
checkParent();
if (mHandler != null) {
mHandler.sendEmptyMessageDelayed(what, delayTime);
}
}
复制代码
开始加载的时候,先执行一次动画:
public void startLoading() {
setVisibility(VISIBLE);
sendMsg(msgAnimation, 0);
}
复制代码
03 熊掌扫光动效
熊掌扫光主要是作为页面加载时的过渡效果,会在内容加载完成前展示,其通常在页面内容上面,不能完全遮挡底部内容。而且在日间模式(存在多种内容背景底色),夜间模式(在日间模式的基础上,覆盖了一层灰色透明蒙层),暗黑模式多种场景模式下,也会对扫光的效果产生干扰,尤其是在日间模式灰色背景以及夜间模式下,甚至可能无法看到扫光。
△分别为日间模式白底,日间模式灰底,夜间模式,暗黑模式
3.1 iOS 实现
熊掌扫光的复杂场景,仅靠双层视图叠加无法满足需要,滑块会有各种异常情况(具体见 3.2.1 部分)。在 iOS 上可以使用三层结构,最底层是待扫光的图,中间是移动的光块,最上层是根据底图绘制的镂空图层,三层视图叠加在一起,形成光和图案混合的效果。
△iOS 通过遮罩实现扫光效果原理
iOS 的 CoreAnimation 框架非常优秀,其 View 的实现,刚好满足了这种三层结构需要。
view 视图
View 是基本的用户界面元素,用于展示和处理用户界面。它们可以是标准的 UI 控件(如 UILabel、UIButton 等),也可以是自定义的视图。
每个 View 都有自己的绘制区域,可以包含其他视图作为其子视图。
layer 图层
Layer 是 View 的底层绘制层次结构中的一个组成部分。每个 View 都有一个与之关联的 Layer 对象(CALayer 类的实例)。
Layer 负责处理 View 的内容的绘制和显示,包括视图的背景颜色、边框、阴影等。每个 Layer 都有一个自己的绘制区域,与 View 的边界对应。
mask 遮罩
Mask 是一种用于控制图层可见性的机制。它是一个透明的图像或形状,可以与 Layer 关联。
通过应用遮罩,可以定义 Layer 中哪些区域应该是可见的,哪些区域应该是隐藏的。
遮罩通常是由另一个 Layer 或者自定义的图像创建的,它们确定了图层中内容的可见部分。
iOS 可以通过 layer 作为光,mask 作为遮罩,来实现光混合在熊掌 logo 上的效果:
// loadingView 设置熊掌底图
self.loadingImgView.image = [self lightImg];
// 创建渐变图层
CAGradientLayer *gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = CGRectMake(0, 0, loadingWidth, loadingHeight);
// 设置渐变颜色
gradientLayer.colors = @[(__bridge id)[UIColor colorWithWhite:1 alpha:0].CGColor,
(__bridge id)[UIColor colorWithWhite:1 alpha:0.9].CGColor,
(__bridge id)[UIColor colorWithWhite:1 alpha:0.9].CGColor,
(__bridge id)[UIColor colorWithWhite:1 alpha:0].CGColor];
gradientLayer.locations = @[@0.0, @0.49, @0.495, @1.0];
gradientLayer.startPoint = CGPointMake(0, 0.5);
gradientLayer.endPoint = CGPointMake(1, 0.35);
[self.loadingImgView.layer addSublayer:gradientLayer];
// 创建透明遮罩
CALayer *maskLayer = [[CALayer alloc] init];
maskLayer.frame = CGRectMake(0, 0, loadingWidth, loadingHeight);
maskLayer.backgroundColor = [UIColor clearColor].CGColor;
// 设置遮罩内容
maskLayer.contents = (__bridge id _Nullable)([self lightImg].CGImage);
self.loadingImgView.layer.mask = maskLayer;
复制代码
在 layer 层通过 CABasicAnimation 实现水平位移:
// 定义基本动画, 控制在x轴方向的位移
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.translation.x"];
animation.duration = 2;
// 重复次数1000000次(无限次)
animation.repeatCount = 1000000;
// 动画不会自动反转
animation.autoreverses = false;
animation.fromValue = @(-loadingWidth);
animation.toValue = @(loadingWidth);
// 动画在完成后不会被移除
animation.removedOnCompletion = NO;
// 动画结束后图层保持最后一个状态
animation.fillMode = kCAFillModeForwards;
[self.gradientLayer addAnimation:animation forKey:@"loading_animation_key"];
复制代码
3.2 Android 实现
这里以 Andoird 上的双层视图叠加为例,可以看到会有各种各样的问题。
3.2.1 通过双层自定义视图叠加
通过叠加切图实现,存在的问题有:日间模式(灰色背景)下无法看到滑块,同时暗黑模式下滑块较为明显。
△叠加切图
通过叠加 Shape 实现,在骨架屏场景上进行扫光效果还行,但在熊掌扫光上效果不佳。存在的问题有:如果是日间模式下的的白底背景正常,但灰底背景无法看到扫光滑块。此外,不加旋转角度,在暗黑模式效果还行,叠加旋转度角度后,可以看到明显的滑块痕迹。
△叠加 shape 图层
△叠加 shape 图层(旋转角度)
甚至还可以通过 LinearGradient 实现自定义带斜率的渐变扫光,其核心方法如下:
// float k = 1f * h / w;
mValueAnimator = ValueAnimator.ofFloat(0f - offset * 2, w + offset * 2);
mValueAnimator.setRepeatCount(repeatCount);
mValueAnimator.setInterpolator(new LinearInterpolator());
mValueAnimator.setDuration(duration);
mValueAnimator.addUpdateListener(animation -> {
float value = (float) animation.getAnimatedValue();
LinearGradient mLinearGradient = new LinearGradient(
value,
k * value,
value + offset,
k * (value + offset),
colors,
positions,
Shader.TileMode.CLAMP
);
mPaint.setShader(mLinearGradient);
invalidate();
});
mValueAnimator.start();
复制代码
在 ValueAnimator 的更新回调中:
由于 ValueAnimator 不断更新,所以线性渐变的两个控制点也在不断变化,产生渐变动画效果:
但是这种方式,在仅白底背景下效果较好,在灰底背景或暗黑效果下不尽人意,可以看到较明显的滑块。
3.2.2 通过 Canvas 绘图
Android 上不像 iOS 一样,View 本身还可以再设置多个图层。如果要混合渲染扫光和背景图,除非自己再自定义一个遮罩层形成三层结构,或者直接通过更底层的绘图来处理。
那么,怎么把扫光和背景图混合渲染呢?答案是可以通过 PorterDuffXferMode 来实现。
PorterDuffXferMode 使用 PorterDuff.Mode 规则将所绘制图形和 Canvas 上图形混合,最终更新 Canvas 展示新的图形。PorterDuffXferMode 的使用也非常简单,在需要使用的时候 paint.setXfermode(PorterDuff.Mode mode)设置混合模式。
PorterDuff.Mode 共分为 16 种模式:CLEAR、SRC、DST、SRC_OVER、DST_OVER、SRC_IN、DST_IN、SRC_OUT、DST_OUT、SRC_ATOP、DST_ATOP、XOR、DARKEN、LIGHTEN、MULTIPLY、SCREEN。
Android 使用 Canvas 在 View 上绘制图形 ,所绘制的图形中的像素称作源像素(source,简称 src),所绘制的矩形在 Canvas 中对应位置的矩形内的像素称作目标像素(destination,简称 dst)。源像素的 ARGB 四个分量会和 Canvas 上同一位置处的目标像素的 ARGB 四个分量按照 Xfermode 定义的规则进行计算,形成最终的 ARGB 值,然后用该最终的 ARGB 值更新目标像素的 ARGB 值。
以官网提供的图示来说明,假设有一个蓝色的源像素图形和一个红色的目标像素图形。
通过 DST_IN, 可以得到相交部分是红色的扇形,即相交的部分保留目标像素,不相交的部分,丢弃源像素。
这样,首先通过 PorterDuffXfermode 来设置 DST_IN 的混合效果,通过 LinearGradient 来创建遮罩的渐变效果。其次使用 Canvas 和 Paint 来绘制和渲染位图,先绘制一个未经过遮罩处理的位图,作为 src,再绘制一个经过遮罩处理的位图,作为 dst,两者组合一起,形成扫光效果,最后在通过使用 ValueAnimator 来实现动画效果。
△图中亮光的部分为 dst
首先,创建带斜率的渐变遮罩位图:
mMaskBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(mMaskBitmap);
// 可以通过参数控制getGradientColors 的值,在不同模式下为不同的渐变颜色
Shader gradient = new LinearGradient(
0, 0,
width, 0,
getGradientColors(),
getGradientPositions(),
Shader.TileMode.REPEAT);
canvas.rotate(mTilt, width / 2, height / 2);
Paint paint = new Paint();
paint.setShader(gradient);
// 适度增大矩形区域,适配倾斜
int padding = (int) (Math.sqrt(2) * Math.max(width, height)) / 2;
canvas.drawRect(-padding, -padding, width + padding, height + padding, paint);
复制代码
其次,在 dispatchDraw 方法 中依次绘制源位图和目标位图:
// 先绘制一个未经过遮罩处理的位图,作为 src
drawUnmasked(new Canvas(unmaskBitmap));
Canvas unmaskRenderCanvas = (new Canvas(maskBitmap));
unmaskRenderCanvas.drawColor(0, PorterDuff.Mode.CLEAR);
super.dispatchDraw(unmaskRenderCanvas);
canvas.drawBitmap(unmaskBitmap, 0, 0, mAlphaPaint);
// 再绘制一个经过遮罩处理的位图,作为dst
Canvas maskRenderCanvas = (new Canvas(maskBitmap));
maskRenderCanvas.clipRect(
mMaskOffsetX,
mMaskOffsetY,
mMaskOffsetX + maskBitmap.getWidth(),
mMaskOffsetY + maskBitmap.getHeight());
maskRenderCanvas.drawColor(0, PorterDuff.Mode.CLEAR);
super.dispatchDraw(maskRenderCanvas);
maskRenderCanvas.drawBitmap(maskBitmap, mMaskOffsetX, mMaskOffsetY, mMaskPaint);
canvas.drawBitmap(maskBitmap, 0, 0, null);
复制代码
接着,通过 ValueAnimator 实现位移并触发实时绘制闪光效果:
mMaskTranslation.set(-width, 0, width, 0);
mAnimator = ValueAnimator.ofFloat(0.0f, 1.0f + (float) mRepeatDelay / mDuration);
mAnimator.setDuration(mDuration + mRepeatDelay);
mAnimator.setRepeatCount(mRepeatCount);
mAnimator.setRepeatMode(mRepeatMode);
mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = Math.max(0.0f, Math.min(1.0f, (Float) animation.getAnimatedValue()));
mMaskOffsetX = (int) (mMaskTranslation.fromX * (1 - value) + mMaskTranslation.toX * value);
mMaskOffsetY = (int) (mMaskTranslation.fromY * (1 - value) + mMaskTranslation.toY * value);
invalidate();
}
});
复制代码
最终效果如下图所示:
△日间模式
△夜间模式
△暗黑模式
04 结语
在上面内容中,我们介绍到了基于遮罩实现的扫光效果,遮罩常见的应用有圆角效果,穿人像弹幕,还有在新手指引中用于绘制挖孔效果,或者是刮彩票效果。
在渲染技术上主要是运用到了 iOS 系统中的 Core Animation 框架以及 Android 的 View 系统。
iOS 上通常会使用 Core Animation 来高效、方便地实现动画。它使用 CALayer 进行图形渲染和动画操作。Apple 并没有直接在 UIView 上提供 masking 的支持,而是在其底层的 CALayer 上实现。这使开发者可以灵活控制和修改 mask,达到更强大的效果。而 Android 想要制作更灵活和强大的效果,可以通过 Canvas 来实现。
——END——
推荐阅读:
Android SDK安全加固问题与分析
搜索语义模型的大规模量化实践
如何设计一个高效的分布式日志服务平台
视频与图片检索中的多模态语义匹配模型:原理、启示、应用与展望
百度离线资源治理
百度APP iOS端包体积50M优化实践(三) 资源优化
评论