Android自定義View——從零開始實現雪花飄落效果

Anlia發表於2017-12-21

版權宣告:本文為博主原創文章,未經博主允許不得轉載

系列教程:Android開發之從零開始系列

原始碼:AnliaLee/FallingView,歡迎star

大家要是看到有錯誤的地方或者有啥好的建議,歡迎留言評論

前言:轉眼已是十一月下旬了,天氣慢慢轉冷,不知道北方是不是已經開始下雪了呢?本期教程我們就順應季節主題,一起來實現 雪花飄落的效果吧。本篇效果思路參考自國外大神的Android實現雪花飛舞效果,並在此基礎上實現進一步的封裝和功能擴充套件

本篇只著重於思路和實現步驟,裡面用到的一些知識原理不會非常細地拿來講,如果有不清楚的api或方法可以在網上搜下相應的資料,肯定有大神講得非常清楚的,我這就不獻醜了。本著認真負責的精神我會把相關知識的博文連結也貼出來(其實就是懶不想寫那麼多哈哈),大家可以自行傳送。為了照顧第一次閱讀系列部落格的小夥伴,本篇會出現一些在之前系列部落格就講過的內容,看過的童鞋自行跳過該段即可

國際慣例,先上效果圖

Android自定義View——從零開始實現雪花飄落效果


繪製一個迴圈下落的“雪球”

我們先從最簡單的部分做起,自定義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();
        }
    };
}
複製程式碼

效果如圖

Android自定義View——從零開始實現雪花飄落效果

在上述程式碼中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個雪球物件
複製程式碼

效果如圖

Android自定義View——從零開始實現雪花飄落效果

到這裡我們完成了一個最基礎的下落物體類,下面開始擴充套件功能和效果


擴充套件一:增加匯入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();
複製程式碼

來看下效果

Android自定義View——從零開始實現雪花飄落效果


擴充套件二:實現雪花“大小不一”、“快慢有別”的效果

之前我們通過匯入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();
複製程式碼

效果如圖,是不是看起來感覺好多了๑乛◡乛๑

Android自定義View——從零開始實現雪花飄落效果


擴充套件三:引入“風”的概念

“風”其實是一種比喻,實際上要做的是讓雪花除了做下落運動外,還會橫向移動,也就是說我們要模擬出雪花在風中亂舞的效果。為了讓雪花在X軸上的位移不顯得鬼畜(大家可以直接隨機增減x座標值就知道為什麼是鬼畜了哈哈),我們採用正弦函式來獲取X軸上的位移距離,如圖所示

Android自定義View——從零開始實現雪花飄落效果

正弦函式曲線見下圖

Android自定義View——從零開始實現雪花飄落效果

我們選取-π到π這段曲線,可以看出角的弧度在為π/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();
複製程式碼

效果如圖

Android自定義View——從零開始實現雪花飄落效果

至此本篇教程到此結束,如果大家看了感覺還不錯麻煩點個贊,你們的支援是我最大的動力~


相關文章