思路以及实现
- 插值器
ValueAnimator和animateFloatAsState实现固定频率向右平移阴影动画- 选择Canvas绘制单个带圆角的背景灰框,圆角大小可调节,背景框附着阴影动画
- Canvas绘制一个组合式的背景,给背景加阴影动画
- 继承FrameLayout,给父布局加阴影动画
选择Canvas绘制单个带圆角的背景灰框,圆角大小可调节,背景框附着阴影动画。原生代码实现如下
package com.nio.skeletondemo;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.RectF;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import androidx.annotation.NonNull;
/**
* @author Administrator
* @date 2024/5/31 0031 8:52
* @description: SkeletonSquare
*/
public class SkeletonSquare extends SurfaceView implements SurfaceHolder.Callback{
private int foregroundColor=Color.parseColor("#eeeeee");
/**
* #3FADA996
*/
private int gradientColor= Color.parseColor("#3FDCDFE6");
private ValueAnimator value;
private float offsetX;
private int duration=1000;
private Paint mPaint;
private Paint mBgPaint;
private float cornerRadius = 20.0f;
public SkeletonSquare(Context context) {
super(context);
init(context, null);
}
public SkeletonSquare(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
public SkeletonSquare(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
public SkeletonSquare(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
if (attrs!=null) {
TypedArray typedArray = context.obtainStyledAttributes(attrs,R.styleable.SkeletonSquare);
cornerRadius = typedArray.getDimension(R.styleable.SkeletonSquare_cornerRadius,cornerRadius);
foregroundColor=typedArray.getColor(R.styleable.SkeletonSquare_foregroundColor,foregroundColor);
duration=typedArray.getInteger(R.styleable.SkeletonSquare_duration, duration);
gradientColor=typedArray.getInteger(R.styleable.SkeletonSquare_gradientColor, gradientColor);
typedArray.recycle();
}
setZOrderOnTop(true);
getHolder().setFormat(PixelFormat.TRANSPARENT);
getHolder().addCallback(this);
mPaint=new Paint();
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mPaint.setAntiAlias(true);
mBgPaint=new Paint();
mBgPaint.setStyle(Paint.Style.FILL);
mBgPaint.setAntiAlias(true);
mBgPaint.setColor(foregroundColor);
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) {
if (value!=null&&value.isRunning()) {
value.end();
value.cancel();
}
value = ValueAnimator.ofFloat(0.0f,1.0f);
value.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(@NonNull ValueAnimator animation) {
offsetX= (float) animation.getAnimatedValue();
draw(width,height);
}
});
value.setDuration(duration);
value.setRepeatCount(ValueAnimator.INFINITE);
value.setRepeatMode(ValueAnimator.RESTART);
value.start();
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
getHolder().removeCallback(this);
if (value!=null&&value.isRunning()) {
value.end();
value.cancel();
}
}
private void draw(int width, int height) {
Canvas canvas = getHolder().lockCanvas();
if (canvas != null) {
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
float strokeWidth=width/4f;
RectF rect=new RectF(offsetX*width-strokeWidth, 0f,offsetX*width+strokeWidth, height);
LinearGradient linearGradient = new LinearGradient(rect.left, 0f, rect.right,0f,new int[]{Color.TRANSPARENT, gradientColor,Color.TRANSPARENT }, new float[]{0f ,0.5f,1f}, Shader.TileMode.MIRROR);
mPaint.setShader(linearGradient);
mPaint.setAlpha(Float.valueOf((1.0f-offsetX)*255).intValue());
RectF rectBg=new RectF(0f, 0f,width, height);
canvas.drawRoundRect(rectBg,cornerRadius,cornerRadius,mBgPaint);
canvas.drawRect(rect,mPaint);
// canvas.drawRoundRect(rect,cornerRadius,cornerRadius,mPaint);
getHolder().unlockCanvasAndPost(canvas);
}
}
}
attrs.xml
<declare-styleable name="SkeletonSquare">
<attr name="duration" format="integer"/>
<attr name="cornerRadius" format="dimension"/>
<attr name="foregroundColor" format="color"/>
<attr name="gradientColor" format="color"/>
</declare-styleable>
Canvas绘制一个组合式的背景,给背景加阴影动画。参数不是动态调整参数,仅作为实现思路需进一步完善
package com.nio.skeletondemo;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.RuntimeShader;
import android.graphics.Shader;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.AttributeSet;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.animation.LinearInterpolator;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
/**
* @author Administrator
* @date 2024/5/30 0030 10:27
* @description: SkeletonHoriztalView
*/
public class SkeletonHorizontalView extends SurfaceView implements SurfaceHolder.Callback {
private SurfaceHolder surfaceHolder;
private int strokeWidth=200;
private int barWidth=50;
private int barPadding=10;
private int barStep=15;
private float offsetX=0;
private Paint mPaint;
private ValueAnimator value;
private int duration=1000;
private int BG_COLOR=Color.parseColor("#F6F8FA");
private int colors[]={ Color.TRANSPARENT, Color.parseColor("#50F8F8FF"), Color.parseColor("#50F8F8FF"), Color.TRANSPARENT};
private Paint mBgPaint;
public SkeletonHorizontalView(Context context) {
super(context);
init();
}
public SkeletonHorizontalView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public SkeletonHorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public SkeletonHorizontalView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
final int minimumWidth = getSuggestedMinimumWidth();
final int minimumHeight = getSuggestedMinimumHeight();
int width = measureWidth(minimumWidth, widthMeasureSpec);
int height = measureHeight(minimumHeight, heightMeasureSpec);
setMeasuredDimension(width, height);
}
private int measureWidth(int defaultWidth, int measureSpec) {
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.AT_MOST:
defaultWidth = (int) getPaddingLeft() + getPaddingRight();
break;
case MeasureSpec.EXACTLY:
defaultWidth = specSize;
break;
case MeasureSpec.UNSPECIFIED:
defaultWidth = Math.max(defaultWidth, specSize);
}
return defaultWidth;
}
private int measureHeight(int defaultHeight, int measureSpec) {
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.AT_MOST:
defaultHeight = barStep*5+barWidth*3;
break;
case MeasureSpec.EXACTLY:
defaultHeight = specSize;
break;
case MeasureSpec.UNSPECIFIED:
defaultHeight = Math.max(defaultHeight, specSize);
break;
}
return defaultHeight;
}
private void init() {
surfaceHolder=getHolder();
surfaceHolder.addCallback(this);
mPaint=new Paint();
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mPaint.setAntiAlias(true);
mBgPaint=new Paint();
mBgPaint.setColor(Color.parseColor("#F1F1F1"));//"#F1F1F1"
mBgPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mBgPaint.setStrokeWidth(barWidth);
mBgPaint.setStrokeCap(Paint.Cap.ROUND);
// Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.icon_shader_layer);
// strokeWidth=bitmap.getWidth();
// Bitmap bitmapScale = Bitmap.createScaledBitmap(bitmap, strokeWidth, bitmap.getHeight(), true);
//// bitmap.recycle();
// BitmapShader bitmapShader=new BitmapShader(bitmapScale,Shader.TileMode.CLAMP,Shader.TileMode.CLAMP);
// mPaint.setShader(bitmapShader);
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
draw();
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) {
if (value!=null&&value.isRunning()) {
value.end();
value.cancel();
}
value = ValueAnimator.ofFloat(0, getWidth());
value.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(@NonNull ValueAnimator animation) {
offsetX= (float) animation.getAnimatedValue();
// Log.e("TAG","offsetX:"+offsetX);
draw();
}
});
value.setDuration(duration);
value.setRepeatCount(ValueAnimator.INFINITE);
value.setRepeatMode(ValueAnimator.RESTART);
value.start();
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
holder.removeCallback(this);
if (value!=null&&value.isRunning()) {
value.end();
value.cancel();
}
}
private void draw() {
Canvas canvas = surfaceHolder.lockCanvas();
if (canvas != null) {
canvas.drawColor(BG_COLOR);
for(int i=0;i<3;i++){
canvas.drawLine(strokeWidth*1.5f+barPadding,barWidth+barStep*i+barWidth*i,getWidth()-barPadding-strokeWidth,barWidth+barStep*i+barWidth*i,mBgPaint);
}
canvas.drawRect(barWidth,barWidth,barStep*2+barWidth*3,barWidth+barStep*2+barWidth*2, mBgPaint);
RectF rect=new RectF(offsetX-strokeWidth, 0f,offsetX+strokeWidth, barStep*5+barWidth*3);
LinearGradient linearGradient = new LinearGradient(offsetX-strokeWidth, 0f, offsetX+strokeWidth, 0,colors, new float[]{0.0f ,0.4f,0.6f,1f}, Shader.TileMode.CLAMP);
mPaint.setShader(linearGradient);
Log.e("TAG","rect:"+rect.toString()+",width:"+(rect.right-rect.left));
canvas.drawRect(rect, mPaint);
surfaceHolder.unlockCanvasAndPost(canvas);
}
}
}
选择Canvas绘制单个带圆角的背景灰框,圆角大小可调节,背景框附着阴影动画。Compose实现,这里compose作为第三方库形式引入,测试过程不能直接引用,存放到自定义‘KtSkeletonFragment : Fragment()’的layout布局中。代码如下
package com.nio.skeleton
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PaintingStyle.Companion.Fill
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
/**
* @author Administrator
* @date 2024/6/1 0001 14:19
* @description: SkeletonSquareKtView
*/
class SkeletonSquareKtView:FrameLayout {
constructor(context: Context) : super(context){init()}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs){init()}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
){init()}
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes){init()}
private fun init() {
val compose=ComposeView(this.context)
addView(compose,LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT))
compose.setContent {
var isStart by remember{
mutableStateOf(false)
}
var offset = animateFloatAsState(targetValue = if(isStart)1f else 0f, animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing) , repeatMode = RepeatMode.Restart
), label = "shader")
Canvas(modifier = Modifier.fillMaxSize()){
drawRoundRect(Color(0xFFeeeeee), cornerRadius = CornerRadius(4.dp.value,4.dp.value), topLeft = Offset.Zero,size= Size(size.width,size.height))
drawRoundRect(Brush.horizontalGradient(listOf(Color(0x1AC0C0C0),Color(0x3FC0C0C0),Color(0x1AFFFFFF)),size.width.times(offset.value)-size.width/2,size.width.times(offset.value)), cornerRadius = CornerRadius(4.dp.value,4.dp.value), topLeft = Offset.Zero,size= Size(size.width,size.height))
}
LaunchedEffect(Unit){
isStart=true
}
}
}
}
引用
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.nio.skeleton.SkeletonSquareKtView
android:layout_width="69dp"
android:layout_height="69dp"
android:layout_margin="8dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp">
<com.nio.skeleton.SkeletonSquareKtView
android:layout_width="100dp"
android:layout_height="14dp"
android:layout_marginBottom="10dp"/>
<com.nio.skeleton.SkeletonSquareKtView
android:layout_width="196dp"
android:layout_height="14dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="10dp"/>
<com.nio.skeleton.SkeletonSquareKtView
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginEnd="12dp" />
</LinearLayout>
</LinearLayout>
<fragment
android:name="com.nio.skeletondemo.KtSkeletonFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:id="@+id/fbl"
android:tag="@string/hello_blank_fragment"
app:layout_constraintTop_toBottomOf="@id/hv"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
继承FrameLayout,给父布局加阴影动画,Compose实现
package com.nio.skeleton
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
/**
* @author Administrator
* @date 2024/6/1 0001 16:50
* @description: SkeletonSquareKtParent
*/
class SkeletonSquareKtParent : FrameLayout {
constructor(context: Context) : super(context) {
init()
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
init()
}
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes) {
init()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val minimumWidth = suggestedMinimumWidth
val minimumHeight = suggestedMinimumHeight
val width = measureWidth(minimumWidth, widthMeasureSpec)
val height = measureHeight(minimumHeight, heightMeasureSpec)
// 初始化高度变量,将加上所有子view的高度
var totalHeight = height
// var maxLength = 0
val count = childCount
for (i in 1 until count) {
val child = getChildAt(i)
if (child.visibility != GONE) {
measureChild(
child,
widthMeasureSpec,
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
// 更新子view的总高度
val lp = child.layoutParams as LayoutParams
val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin
totalHeight += childHeight
// // 更新最长子view的宽度
// val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
// if (i == 0 || childWidth > maxLength) {
// maxLength = childWidth
// }
}
}
setMeasuredDimension(width, totalHeight)
}
private fun measureWidth(defaultWidth: Int, measureSpec: Int): Int {
var defaultWidth = defaultWidth
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
when (specMode) {
MeasureSpec.AT_MOST -> defaultWidth = paddingLeft + paddingRight
MeasureSpec.EXACTLY -> defaultWidth = specSize
MeasureSpec.UNSPECIFIED -> defaultWidth = Math.max(defaultWidth, specSize)
}
return defaultWidth
}
private fun measureHeight(defaultHeight: Int, measureSpec: Int): Int {
var defaultHeight = defaultHeight
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
when (specMode) {
MeasureSpec.AT_MOST -> defaultHeight = paddingTop+paddingBottom
MeasureSpec.EXACTLY -> defaultHeight = specSize
MeasureSpec.UNSPECIFIED -> defaultHeight = Math.max(defaultHeight, specSize)
}
return defaultHeight
}
private fun init() {
val compose = ComposeView(this.context)
addView(compose, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
compose.setContent {
var isStart by remember {
mutableStateOf(false)
}
var offset = animateFloatAsState(
targetValue = if (isStart) 1f else 0f, animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Restart
), label = "shader"
)
Canvas(modifier = Modifier.fillMaxSize()) {
drawRoundRect(
Brush.horizontalGradient(
listOf(
Color.Transparent,
Color(0x3Fc0c0c0),
Color.Transparent
),
size.width.times(offset.value) - size.width / 4,
size.width.times(offset.value)
), alpha = 1.0f-offset.value,
cornerRadius = CornerRadius(4.dp.value, 4.dp.value),
topLeft = Offset.Zero,
size = Size(size.width, size.height)
)
}
LaunchedEffect(Unit) {
isStart = true
}
}
}
}
注意compose作为第三方库时引入。我是创建了java项目,compose作为module引入,直接布局引用报错,换成kotlin Fragment存放自定义view
build.gradle.kts
id("com.android.application") version "8.2.2" apply false
id("com.android.library") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.nio.skeleton"
compileSdk = 34
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose= true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.9.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// implementation("androidx.core:core-ktx:1.10.1")
// implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
// implementation("androidx.activity:activity-compose:1.7.0")
// implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.material:material:1.6.7")
}