Android自定义View——从零开始实现雪花飘落效果

4,848 阅读11分钟

版权声明:本文为博主原创文章,未经博主允许不得转载

系列教程:Android开发之从零开始系列

源码:AnliaLee/FallingView,欢迎star

大家要是看到有错误的地方或者有啥好的建议,欢迎留言评论

前言:转眼已是十一月下旬了,天气慢慢转冷,不知道北方是不是已经开始下雪了呢?本期教程我们就顺应季节主题,一起来实现 雪花飘落的效果吧。本篇效果思路参考自国外大神的Android实现雪花飞舞效果,并在此基础上实现进一步的封装和功能扩展

本篇只着重于思路和实现步骤,里面用到的一些知识原理不会非常细地拿来讲,如果有不清楚的api或方法可以在网上搜下相应的资料,肯定有大神讲得非常清楚的,我这就不献丑了。本着认真负责的精神我会把相关知识的博文链接也贴出来(其实就是懒不想写那么多哈哈),大家可以自行传送。为了照顾第一次阅读系列博客的小伙伴,本篇会出现一些在之前系列博客就讲过的内容,看过的童鞋自行跳过该段即可

国际惯例,先上效果图


绘制一个循环下落的“雪球”

我们先从最简单的部分做起,自定义View中实现循环动画的方法有很多,最简单直接的当然是用Animation类去实现,但考虑到无论是雪花、雪球亦或是雨滴什么的,每个独立的个体都有自己的起点、速度和方向等等,其下落的过程会出现很多随机的因素,实现这种非规律的动画Animation类就不怎么适用了,因此我们这次要利用线程通信实现一个简单的定时器,达到周期性绘制View的效果。这里我们简单绘制一个“雪球”(其实就是个白色背景的圆形哈哈)来看看定时器的效果,新建一个FallingView

public class FallingView extends View {

    private Context mContext;
    private AttributeSet mAttrs;

    private int viewWidth;
    private int viewHeight;

    private static final int defaultWidth = 600;//默认宽度
    private static final int defaultHeight = 1000;//默认高度
    private static final int intervalTime = 5;//重绘间隔时间

    private Paint testPaint;
    private int snowY;

    public FallingView(Context context) {
        super(context);
        mContext = context;
        init();
    }

    public FallingView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        mAttrs = attrs;
        init();
    }

    private void init(){
        testPaint = new Paint();
        testPaint.setColor(Color.WHITE);
        testPaint.setStyle(Paint.Style.FILL);
        snowY = 0;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int height = measureSize(defaultHeight, heightMeasureSpec);
        int width = measureSize(defaultWidth, widthMeasureSpec);
        setMeasuredDimension(width, height);

        viewWidth = width;
        viewHeight = height;
    }

    private int measureSize(int defaultSize,int measureSpec) {
        int result = defaultSize;
        int specMode = View.MeasureSpec.getMode(measureSpec);
        int specSize = View.MeasureSpec.getSize(measureSpec);

        if (specMode == View.MeasureSpec.EXACTLY) {
            result = specSize;
        } else if (specMode == View.MeasureSpec.AT_MOST) {
            result = Math.min(result, specSize);
        }
        return result;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawCircle(100,snowY,25,testPaint);
        getHandler().postDelayed(runnable, intervalTime);//间隔一段时间再进行重绘
    }

    // 重绘线程
    private Runnable runnable = new Runnable() {
        @Override
        public void run() {
            snowY += 15;
            if(snowY>viewHeight){//超出屏幕则重置雪球位置
                snowY = 0;
            }
            invalidate();
        }
    };
}

效果如图

在上述代码中View基本的框架我们已经搭好了,思路其实很简单,我们需要做仅仅是在每次重绘之前更新做下落运动的物体的位置即可

封装下落物体对象

相关博文链接

Android开发中无处不在的设计模式——Builder模式

[Android] 获取View的宽度和高度

要实现大雪纷飞的效果,很明显只有一个雪球是不够的,而且雪也不能只有雪球一个形状,我们希望可以自定义雪的样式,甚至不局限于下雪,还可以下雨、下金币等等,因此我们要对下落的物体进行封装。为了以后物体类对外方法代码的可读性,这里我们采用Builder设计模式来构建物体对象类,新建FallObject

public class FallObject {
    private int initX;
    private int initY;
    private Random random;
    private int parentWidth;//父容器宽度
    private int parentHeight;//父容器高度
    private float objectWidth;//下落物体宽度
    private float objectHeight;//下落物体高度

    public int initSpeed;//初始下降速度

    public float presentX;//当前位置X坐标
    public float presentY;//当前位置Y坐标
    public float presentSpeed;//当前下降速度

    private Bitmap bitmap;
    public Builder builder;

    private static final int defaultSpeed = 10;//默认下降速度

    public FallObject(Builder builder, int parentWidth, int parentHeight){
        random = new Random();
        this.parentWidth = parentWidth;
        this.parentHeight = parentHeight;
        initX = random.nextInt(parentWidth);//随机物体的X坐标
        initY = random.nextInt(parentHeight)- parentHeight;//随机物体的Y坐标,并让物体一开始从屏幕顶部下落
        presentX = initX;
        presentY = initY;

        initSpeed = builder.initSpeed;

        presentSpeed = initSpeed;
        bitmap = builder.bitmap;
        objectWidth = bitmap.getWidth();
        objectHeight = bitmap.getHeight();
    }

    private FallObject(Builder builder) {
        this.builder = builder;
        initSpeed = builder.initSpeed;
        bitmap = builder.bitmap;
    }

    public static final class Builder {
        private int initSpeed;
        private Bitmap bitmap;

        public Builder(Bitmap bitmap) {
            this.initSpeed = defaultSpeed;
            this.bitmap = bitmap;
        }

        /**
         * 设置物体的初始下落速度
         * @param speed
         * @return
         */
        public Builder setSpeed(int speed) {
            this.initSpeed = speed;
            return this;
        }

        public FallObject build() {
            return new FallObject(this);
        }
    }

    /**
     * 绘制物体对象
     * @param canvas
     */
    public void drawObject(Canvas canvas){
        moveObject();
        canvas.drawBitmap(bitmap,presentX,presentY,null);
    }

    /**
     * 移动物体对象
     */
    private void moveObject(){
        moveY();
        if(presentY>parentHeight){
            reset();
        }
    }

    /**
     * Y轴上的移动逻辑
     */
    private void moveY(){
        presentY += presentSpeed;
    }

    /**
     * 重置object位置
     */
    private void reset(){
        presentY = -objectHeight;
        presentSpeed = initSpeed;
    }
}

FallingView中相应地设置添加物体的方法

public class FallingView extends View {
	//省略部分代码...
    private List<FallObject> fallObjects;

    private void init(){
        fallObjects = new ArrayList<>();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if(fallObjects.size()>0){
            for (int i=0;i<fallObjects.size();i++) {
                //然后进行绘制
                fallObjects.get(i).drawObject(canvas);
            }
            // 隔一段时间重绘一次, 动画效果
            getHandler().postDelayed(runnable, intervalTime);
        }
    }

    // 重绘线程
    private Runnable runnable = new Runnable() {
        @Override
        public void run() {
            invalidate();
        }
    };

    /**
     * 向View添加下落物体对象
     * @param fallObject 下落物体对象
     * @param num
     */
    public void addFallObject(final FallObject fallObject, final int num) {
        getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                getViewTreeObserver().removeOnPreDrawListener(this);
                for (int i = 0; i < num; i++) {
                    FallObject newFallObject = new FallObject(fallObject.builder,viewWidth,viewHeight);
                    fallObjects.add(newFallObject);
                }
                invalidate();
                return true;
            }
        });
    }
}

Activity中向FallingView添加一些物体看看效果

//绘制雪球bitmap
snowPaint = new Paint();
snowPaint.setColor(Color.WHITE);
snowPaint.setStyle(Paint.Style.FILL);
bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888);
bitmapCanvas = new Canvas(bitmap);
bitmapCanvas.drawCircle(25,25,25,snowPaint);

//初始化一个雪球样式的fallObject
FallObject.Builder builder = new FallObject.Builder(bitmap);
FallObject fallObject = builder
		.setSpeed(10)
		.build();

fallingView = (FallingView) findViewById(R.id.fallingView);
fallingView.addFallObject(fallObject,50);//添加50个雪球对象

效果如图

到这里我们完成了一个最基础的下落物体类,下面开始扩展功能和效果


扩展一:增加导入Drawable资源的构造方法和设置物体大小的接口

我们之前的FallObject类中Builder只支持bitmap的导入,很多时候我们的图片样式都是从drawable资源文件夹中获取的,每次都要将drawable转成bitmap是件很麻烦的事,因此我们要在FallObject类中封装drawable资源导入的构造方法,修改FallObject

public static final class Builder {
	//省略部分代码...
	public Builder(Bitmap bitmap) {
		this.initSpeed = defaultSpeed;
		this.bitmap = bitmap;
	}

	public Builder(Drawable drawable) {
		this.initSpeed = defaultSpeed;
		this.bitmap = drawableToBitmap(drawable);
	}
}

/**
 * drawable图片资源转bitmap
 * @param drawable
 * @return
 */
public static Bitmap drawableToBitmap(Drawable drawable) {
	Bitmap bitmap = Bitmap.createBitmap(
			drawable.getIntrinsicWidth(),
			drawable.getIntrinsicHeight(),
			drawable.getOpacity() != PixelFormat.OPAQUE ? Bitmap.Config.ARGB_8888
					: Bitmap.Config.RGB_565);
	Canvas canvas = new Canvas(bitmap);
	drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
	drawable.draw(canvas);
	return bitmap;
}

有了drawable资源导入的构造方法,肯定需要配套改变FallObject图片样式大小的接口,依然是在FallObjectBuilder中扩展相应的接口

public static final class Builder {
	//省略部分代码...
	public Builder setSize(int w, int h){
		this.bitmap = changeBitmapSize(this.bitmap,w,h);
		return this;
	}
}

/**
 * 改变bitmap的大小
 * @param bitmap 目标bitmap
 * @param newW 目标宽度
 * @param newH 目标高度
 * @return
 */
public static Bitmap changeBitmapSize(Bitmap bitmap, int newW, int newH) {
	int oldW = bitmap.getWidth();
	int oldH = bitmap.getHeight();
	// 计算缩放比例
	float scaleWidth = ((float) newW) / oldW;
	float scaleHeight = ((float) newH) / oldH;
	// 取得想要缩放的matrix参数
	Matrix matrix = new Matrix();
	matrix.postScale(scaleWidth, scaleHeight);
	// 得到新的图片
	bitmap = Bitmap.createBitmap(bitmap, 0, 0, oldW, oldH, matrix, true);
	return bitmap;
}

Activity中初始化下落物体样式时我们就可以导入drawable资源和设置物体大小了(图片资源我是在阿里图标库下载的)

FallObject.Builder builder = new FallObject.Builder(getResources().getDrawable(R.drawable.ic_snow));
FallObject fallObject = builder
		.setSpeed(10)
		.setSize(50,50)
		.build();

来看下效果


扩展二:实现雪花“大小不一”、“快慢有别”的效果

之前我们通过导入drawable资源的方法让屏幕“下起了雪花”,但雪花个个都一样大小,下落速度也都完全一致,这显得十分的单调,看起来一点也不像现实中的下雪场景。因此我们需要利用随机数实现雪花大小不一快慢有别的效果,修改FallObject

public class FallObject {
	//省略部分代码...
    private boolean isSpeedRandom;//物体初始下降速度比例是否随机
    private boolean isSizeRandom;//物体初始大小比例是否随机

    public FallObject(Builder builder, int parentWidth, int parentHeight){
		//省略部分代码...
        this.builder = builder;
        isSpeedRandom = builder.isSpeedRandom;
        isSizeRandom = builder.isSizeRandom;

        initSpeed = builder.initSpeed;
        randomSpeed();
        randomSize();
    }

    private FallObject(Builder builder) {
		//省略部分代码...
        isSpeedRandom = builder.isSpeedRandom;
        isSizeRandom = builder.isSizeRandom;
    }

    public static final class Builder {
		//省略部分代码...
        private boolean isSpeedRandom;
        private boolean isSizeRandom;

        public Builder(Bitmap bitmap) {
			//省略部分代码...
            this.isSpeedRandom = false;
            this.isSizeRandom = false;
        }

        public Builder(Drawable drawable) {
			//省略部分代码...
            this.isSpeedRandom = false;
            this.isSizeRandom = false;
        }

        /**
         * 设置物体的初始下落速度
         * @param speed
         * @return
         */
        public Builder setSpeed(int speed) {
            this.initSpeed = speed;
            return this;
        }

        /**
         * 设置物体的初始下落速度
         * @param speed
         * @param isRandomSpeed 物体初始下降速度比例是否随机
         * @return
         */
        public Builder setSpeed(int speed,boolean isRandomSpeed) {
            this.initSpeed = speed;
            this.isSpeedRandom = isRandomSpeed;
            return this;
        }

        /**
         * 设置物体大小
         * @param w
         * @param h
         * @return
         */
        public Builder setSize(int w, int h){
            this.bitmap = changeBitmapSize(this.bitmap,w,h);
            return this;
        }

        /**
         * 设置物体大小
         * @param w
         * @param h
         * @param isRandomSize 物体初始大小比例是否随机
         * @return
         */
        public Builder setSize(int w, int h, boolean isRandomSize){
            this.bitmap = changeBitmapSize(this.bitmap,w,h);
            this.isSizeRandom = isRandomSize;
            return this;
        }
    }

    /**
     * 重置object位置
     */
    private void reset(){
        presentY = -objectHeight;
        randomSpeed();//记得重置时速度也一起重置,这样效果会好很多
    }

    /**
     * 随机物体初始下落速度
     */
    private void randomSpeed(){
        if(isSpeedRandom){
            presentSpeed = (float)((random.nextInt(3)+1)*0.1+1)* initSpeed;//这些随机数大家可以按自己的需要进行调整
        }else {
            presentSpeed = initSpeed;
        }
    }

    /**
     * 随机物体初始大小比例
     */
    private void randomSize(){
        if(isSizeRandom){
            float r = (random.nextInt(10)+1)*0.1f;
            float rW = r * builder.bitmap.getWidth();
            float rH = r * builder.bitmap.getHeight();
            bitmap = changeBitmapSize(builder.bitmap,(int)rW,(int)rH);
        }else {
            bitmap = builder.bitmap;
        }
        objectWidth = bitmap.getWidth();
        objectHeight = bitmap.getHeight();
    }
}

Activity中设置相应参数即可

FallObject.Builder builder = new FallObject.Builder(getResources().getDrawable(R.drawable.ic_snow));
FallObject fallObject = builder
		.setSpeed(10,true)
		.setSize(50,50,true)
		.build();

效果如图,是不是看起来感觉好多了๑乛◡乛๑


扩展三:引入“风”的概念

“风”其实是一种比喻,实际上要做的是让雪花除了做下落运动外,还会横向移动,也就是说我们要模拟出雪花在风中乱舞的效果。为了让雪花在X轴上的位移不显得鬼畜(大家可以直接随机增减x坐标值就知道为什么是鬼畜了哈哈),我们采用正弦函数来获取X轴上的位移距离,如图所示

正弦函数曲线见下图

我们选取-π到π这段曲线,可以看出角的弧度在为π/2时正弦值最大(-π/2时最小),因此我们在计算角度时还需要考虑其极限值。同时,因为我们添加了横向的移动,所以判断边界时要记得判定最左和最右的边界,修改FallObject

public class FallObject {
	//省略部分代码...
    public int initSpeed;//初始下降速度
    public int initWindLevel;//初始风力等级
	
    private float angle;//物体下落角度
	
    private boolean isWindRandom;//物体初始风向和风力大小比例是否随机
    private boolean isWindChange;//物体下落过程中风向和风力是否产生随机变化

    private static final int defaultWindLevel = 0;//默认风力等级
    private static final int defaultWindSpeed = 10;//默认单位风速
    private static final float HALF_PI = (float) Math.PI / 2;//π/2

    public FallObject(Builder builder, int parentWidth, int parentHeight){
		//省略部分代码...
        isWindRandom = builder.isWindRandom;
        isWindChange = builder.isWindChange;

        initSpeed = builder.initSpeed;
        randomSpeed();
        randomSize();
        randomWind();
    }

    private FallObject(Builder builder) {
		//省略部分代码...
        isWindRandom = builder.isWindRandom;
        isWindChange = builder.isWindChange;
    }

    public static final class Builder {
		//省略部分代码...
        private boolean isWindRandom;
        private boolean isWindChange;

        public Builder(Bitmap bitmap) {
			//省略部分代码...
            this.isWindRandom = false;
            this.isWindChange = false;
        }

        public Builder(Drawable drawable) {
			//省略部分代码...
            this.isWindRandom = false;
            this.isWindChange = false;
        }

        /**
         * 设置风力等级、方向以及随机因素
         * @param level 风力等级(绝对值为 5 时效果会比较好),为正时风从左向右吹(物体向X轴正方向偏移),为负时则相反
         * @param isWindRandom 物体初始风向和风力大小比例是否随机
         * @param isWindChange 在物体下落过程中风的风向和风力是否会产生随机变化
         * @return
         */
        public Builder setWind(int level,boolean isWindRandom,boolean isWindChange){
            this.initWindLevel = level;
            this.isWindRandom = isWindRandom;
            this.isWindChange = isWindChange;
            return this;
        }
    }

    /**
     * 移动物体对象
     */
    private void moveObject(){
        moveX();
        moveY();
        if(presentY>parentHeight || presentX<-bitmap.getWidth() || presentX>parentWidth+bitmap.getWidth()){
            reset();
        }
    }

    /**
     * X轴上的移动逻辑
     */
    private void moveX(){
        presentX += defaultWindSpeed * Math.sin(angle);
        if(isWindChange){
            angle += (float) (random.nextBoolean()?-1:1) * Math.random() * 0.0025;
        }
    }

    /**
     * 重置object位置
     */
    private void reset(){
        presentY = -objectHeight;
        randomSpeed();//记得重置时速度也一起重置,这样效果会好很多
        randomWind();//记得重置一下初始角度,不然雪花会越下越少(因为角度累加会让雪花越下越偏)
    }

    /**
     * 随机风的风向和风力大小比例,即随机物体初始下落角度
     */
    private void randomWind(){
        if(isWindRandom){
            angle = (float) ((random.nextBoolean()?-1:1) * Math.random() * initWindLevel /50);
        }else {
            angle = (float) initWindLevel /50;
        }

        //限制angle的最大最小值
        if(angle>HALF_PI){
            angle = HALF_PI;
        }else if(angle<-HALF_PI){
            angle = -HALF_PI;
        }
    }
}

Activity中调用新增加的接口

FallObject.Builder builder = new FallObject.Builder(getResources().getDrawable(R.drawable.ic_snow));
FallObject fallObject = builder
		.setSpeed(7,true)
		.setSize(50,50,true)
		.setWind(5,true,true)
		.build();

效果如图

至此本篇教程到此结束,如果大家看了感觉还不错麻烦点个赞,你们的支持是我最大的动力~