Android -- 貝塞爾實現水波紋動畫(劃重點!!)

1,昨天看到了一個挺好的ui效果,是使用貝塞爾曲線實現的,就和你們來分享分享,還有,在寫博客的時候我常常會把本身在作某種效果時的一些問題給寫出來,而不是像不少文章直接就給出瞭解決方法,這裏給你們解釋一下,這裏寫出我遇到的一些問題不是爲了湊整片文章的字數,而是但願你們能從根源下知道它是怎麼解決的,而不是你直接百度搜索這個問題解決的代碼,好了,說了這麼多,只是想告訴你們,我後面會在過程當中提不少問題(邪惡臉,嘿嘿嘿),好吧,來看看今天的效果:html

2,what is the fuck?,這就是你說的很好看的效果?各位看官彆着急,這裏小弟也沒辦法,實在是找不到好的UI圖,就只能請各位將就一下了,好了言歸正傳,當咱們看到這種效果的時候,咱們已經有了一些思路,以下:java

1,使用paint繪製正弦函數(調用Math.sin(x)的方法)
2,使用逐幀動畫來實現
3,使用貝塞爾三階來實現波浪效果

  可能你們還有更多更好的方法,這上面幾點只是我能想到的幾點方法,我今天是使用的貝塞爾來實現的,不清楚貝塞爾使用的同窗能夠在我博客分類的系列中找到這一欄的分類。canvas

  OK,咱們先不要去管那些動畫,咱們一步一步的來,那麼咱們的視圖就只有兩部分了,一個是粉紅色帶水區域,一個是咱們中間隨着動的icon圖片,那咱們先來實現第一個粉紅色帶水的地方,咱們最後要實現的效果以下:ide

  ok,爲了咱們控件的擴展性,咱們這裏自定義一些屬性,這裏咱們同窗能夠先不要理解這一塊(等所有理解以後再來看這一塊)函數

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="WaveView">
        <!--中間小船的圖片-->
        <attr name="imageBitmap" format="reference"></attr>
        <!--水位是否要上升-->
        <attr name="rise" format="boolean"></attr>
        <!--水波紋向右移動的時候執行的時間-->
        <attr name="duration" format="integer"></attr>
        <!--起始點的Y座標-->
        <attr name="originY" format="integer"></attr>
        <!--水波紋的高度-->
        <attr name="waveHeight" format="integer"></attr>
        <!--水波紋的長度-->
        <attr name="waveLength" format="integer"></attr>

    </declare-styleable>
</resources>

  建立一個WaveView類,繼承自View,並初始化一些自定義屬性,這裏兩個重要的屬性一個是一個正弦的最高點,即咱們的水波紋的高度;一個是咱們一個正弦的長度,即咱們一個水波紋的橫座標的長度,下面是一些屬性的初始化 ,很簡單,沒什麼難的post

    //中間小船圖片的引用
    private int imageBitmap;
    //小船實際的bitmap
    private Bitmap bitmap;
    //是否上升水位
    private boolean rise;
    //水位起始點
    private int originY;
    //波紋平移的執行的時間
    private int duration;
    //波紋的寬度
    private int waveWidth;
    //波紋的高度
    private int waveHeight;

    //畫筆
    private Paint mPaint;
    //路徑
    private Path mPath;

    //控件的寬度高度
    private int width;
    private int height;

    public WaveView(Context context) {
        this(context, null);
    }

    public WaveView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public WaveView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs, defStyleAttr);
    }

    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.WaveView);
        imageBitmap = a.getResourceId(R.styleable.WaveView_imageBitmap, 0);
        rise = a.getBoolean(R.styleable.WaveView_rise, false);
        duration = a.getInt(R.styleable.WaveView_duration, 2000);
        originY = a.getInt(R.styleable.WaveView_originY, 500);
        waveWidth = a.getInt(R.styleable.WaveView_waveLength, 500);
        waveHeight = a.getInt(R.styleable.WaveView_waveHeight, 500);
        a.recycle();

        //壓縮圖片
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = 2; //壓縮圖片倍數
        if (imageBitmap > 0) {
            bitmap = BitmapFactory.decodeResource(getResources(), imageBitmap,options);
        } else {
            bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher, options);
        }

        //初始化畫筆
        mPaint = new Paint();
        mPaint.setColor(getResources().getColor(R.color.colorAccent));
        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.FILL_AND_STROKE);

        //初始化路徑
        mPath = new Path();
    }

  而後重寫OnMeasure中測量咱們空間的高度,這裏基本上是使用系統測量的寬高度,就是在height爲wrap_content的時候設置了800px,這裏的代碼也很簡單,很少解釋,直接上代碼動畫

   @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec); //獲取寬的模式
        int heightMode = MeasureSpec.getMode(heightMeasureSpec); // 獲取高的模式
        int widthSize = MeasureSpec.getSize(widthMeasureSpec); //獲取寬的尺寸
        int heightSize = MeasureSpec.getSize(heightMeasureSpec); //獲取高的尺寸

        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            height = 800;
        }
        //保存丈量結果
        setMeasuredDimension(width, height);
    }

  繼續,重寫OnDraw方法,注意了,這是今天整篇博客重點的地方,首先咱們知道要使用貝塞爾三階來實現,因此咱們能夠基本上寫出以下的代碼:ui

 @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //不斷的計算波浪的路徑
        calculatePath();
        //繪製水部分
        canvas.drawPath(mPath, mPaint);
}

  關鍵是咱們calculatePath()方法中的邏輯處理,這是直接使用貝塞爾,首先咱們把咱們的繪製起始點平移到咱們自定義originY屬性的位置this

mPath.moveTo(0, originY);

  而後在經過咱們的width長度和waveHeight的長度來判斷,到底在屏幕中繪製多少個正弦曲線spa

 for (int i = -waveWidth; i < width + waveWidth; i += waveWidth) {
            //利用三階貝塞爾曲線繪製
            mPath.rCubicTo(????);
}

  OK,這裏咱們繪製總體的思路沒什麼問題了,關鍵咱們三階貝塞爾曲線的兩個控制點和一個結束點的座標的確認了(這裏壓根不知道什麼是控制點和結束點的同窗整真的推薦你先去看看我博客的貝塞爾基礎知識了)

  這裏請你們看我在上圖中標註的四個點就分別是咱們的起始點、控制點一、控制點二、結束點,ok,因此咱們能夠寫成以下的代碼:

        mPath.moveTo(0, originY);
        //繪製波浪
        for (int i = -waveWidth; i < width + waveWidth; i += waveWidth) {
            //利用三階貝塞爾曲線繪製
            mPath.rCubicTo(waveWidth / 4, -waveHeight, waveWidth / 4 * 3, waveHeight, waveWidth, 0);
       }

  ok,寫到這裏了咱們就能夠看一下咱們的貝塞爾三階的效果了,效果圖以下:

  繪製的曲線有點淡,不過仍是繪製出來了,可是感受這裏的三階繪製的曲線和咱們想象中的正弦虛線仍是有些差距的,咱們將三階換成兩個二階試試

        mPath.moveTo(0, originY);
        //繪製波浪
        for (int i = -waveWidth; i < width + waveWidth; i += waveWidth) {
            //利用三階貝塞爾曲線繪製
//            mPath.rCubicTo(waveWidth / 4, -waveHeight, waveWidth / 4 * 3, waveHeight, waveWidth, 0);

            //利用二階貝塞爾曲線繪製
            mPath.rQuadTo(waveWidth / 4, -waveHeight, waveWidth / 2, 0);
            mPath.rQuadTo(waveWidth / 4, waveHeight, waveWidth / 2, 0);
        }

  效果圖以下:

  ok,沒問題,這樣的話就和要的效果差很少了,咱們繼續要實現下面的水是填充滿的那麼咱們還須要繪製一下這三線(下圖黃色的標記的),這樣才能組成一個封閉的區域。

 

  邏輯很簡單,我就直接上代碼了

        //繪製連線
        mPath.lineTo(width, height);
        mPath.lineTo(0, height);
        mPath.close();

  再看一下效果圖

   沒問題,到這裏咱們已經成功了咱們今天任務的三分之一了,咱們接着實現,如今咱們想着的是怎麼才能讓咱們的水波紋動起來,這裏確定有同窗會說,那確定屬性動畫啊,對的,沒錯,是使用屬性動畫,可是,怎麼使用?在哪裏使用是一個問題(第一個難點來了)!!

  這裏我想的思路是改變咱們繪製波長的起始座標,設置(-waveWidth,originY)爲其實座標,爲何這樣來呢?由於咱們打算最左邊多繪製一個波長的水(這裏有個bug,因此也要在最右邊多繪製一個波長,具體解釋看下圖中的標註),而後經過屬性動畫平移(且不但重複平移一個周長的長度),這樣就能夠達到咱們的動畫效果,

 因此代碼修改爲了以下:

       mPath.moveTo(-waveWidth + dx, originY);
       
        for (int i = -waveWidth; i < width + waveWidth; i += waveWidth) {
            //利用三階貝塞爾曲線繪製
//            mPath.rCubicTo(waveWidth / 4, -waveHeight, waveWidth / 4 * 3, waveHeight, waveWidth, 0);

            //利用二階貝塞爾曲線繪製
            mPath.rQuadTo(waveWidth / 4, -waveHeight, waveWidth / 2, 0);
            mPath.rQuadTo(waveWidth / 4, waveHeight, waveWidth / 2, 0);
        }


        //繪製連線
        mPath.lineTo(width, height);
        mPath.lineTo(0, height);
        mPath.close();

  ok,這樣咱們下面在編寫一個簡單的動畫,動態的改變dx的值,從而改變咱們動畫向右移動(這裏涉及到屬性動畫,不過裏面的知識都是最基礎的,你們應該能看懂)

//開始動畫
    public void startAnimation() {
        animator = ValueAnimator.ofFloat(0, 1);
        animator.setDuration(duration);
        animator.setRepeatCount(ValueAnimator.INFINITE);
        animator.setInterpolator(new LinearInterpolator());
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float fraction = (float) animation.getAnimatedValue();
                dx = (int) (waveWidth * fraction);
                postInvalidate();
            }
        });
        animator.start();
    }

  ok,在這裏咱們就能夠看一下咱們的動畫效果了,別忘記了在Activity中去調用

        mWaveView = (WaveView)findViewById(R.id.waveview);
        mWaveView.startAnimation();

  

  ok,這樣咱們下面的水波紋就搞定了,這樣咱們就差很少完成了二分之一了,咱們繼續,如今差的就是繪製咱們的小船了,先隨便找個點先把小船搞出來,再在後面慢慢的考慮它安放的具體位置,這裏我先寫個固定高度800

protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //不斷的計算波浪的路徑
        calculatePath();
        //繪製水部分
        canvas.drawPath(mPath, mPaint);
        //繪製小船部分
        canvas.drawBitmap(bitmap,width/2,800,mPaint);
    }

  看一下效果

  圖片卻是展現出來了,如今就是怎麼樣讓他隨着波浪上下滾動,有些同窗可能就會說,阿呆哥哥啊 ,很簡單啊,也是很明顯x座標是固定的,就是width的通常,Y座標就是挨着它波浪的高度,直接搞個屬性動畫,隨着波浪高度的改變而改變唄。

  恩,關鍵是挨着它的那個波浪的那個座標該怎麼計算,這是問題的關鍵點(這是咱們實現這個效果的第二個困難點)

  這裏提供一個思路,咱們繪製一條中垂線,即下圖這條藍色的線和每次咱們水波紋相交的點就是咱們小船圖片的放置點

   如今思路清晰了,如今就是要找到這個交點,那麼Android中Path類中有沒有方法是能夠拿到這個值得呢? 很明確的告訴你沒有,如今到這裏咱們的思路又斷了,可是我告訴你們這裏有一個Region類能夠代替的實現這種效果(因爲篇幅已經很長了,這就就不和你們詳細介紹Region類的),這個類的解釋就是獲取兩個區域的交集區域,例如:圖下的小矩形區域就是咱們大的矩形和水波紋的交集區域

  咱們按照數學的極限思想來想一下,當這裏咱們外面大的矩形區域左右座標無線接近的時候咱們矩形就能夠看作是一條直線了,這樣就達到了咱們以前的要求了

  思路就很清晰了,咱們來看代碼

        float x = width / 2;
        region = new Region();
        Region clip = new Region((int) (x - 0.1), 0, (int) x, height);
        region.setPath(mPath, clip);

  這裏要提醒一下,必定要放在繪製貝塞爾曲線以後、繪製其它三條線以前(這是一個坑,你們要注意一下)

  再看看拿到矩形區域並設置圖片的座標(這裏我直接取得這個矩形的有座標和上座標)

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //不斷的計算波浪的路徑
        calculatePath();
        //繪製水部分
        canvas.drawPath(mPath, mPaint);

        //獲取當前小船應該在的地方

        Rect rect = region.getBounds();
        canvas.drawBitmap(bitmap, rect.right, rect.top, mPaint);
  }

  看一下效果

  效果大體出來了,可能有些同窗說,這是由於bitmap的起始點不是他的中心點,那麼咱們繼續修改修改

canvas.drawBitmap(bitmap, rect.right - (bitmap.getWidth()/2), rect.top-(bitmap.getHeight()/2), mPaint);

  再看看效果

  這時候看起來舒服多了,大體的誤差沒什麼問題了,可是在波谷的時候仍是有一點問題,這是什麼緣由呢,這裏呢,咱們仍是有點誤差的,當Y座標大於originY的時候,咱們這裏使用rect.bottom拿到的值會更精確一些;當Y座標小於originY的時候,咱們這裏使用rect.top拿到的值會更精確一些(你們認真的思考一下,這裏其實很好懂得)

//獲取當前小船應該在的地方

        Rect rect = region.getBounds();
        Log.i("wangjitao", "right:" + rect.right + ",top:" + rect.bottom);
        if (rect.top < originY){
            canvas.drawBitmap(bitmap, rect.right - (bitmap.getWidth()/2), rect.top-(bitmap.getHeight()/2), mPaint);
        }else {
            canvas.drawBitmap(bitmap, rect.right - (bitmap.getWidth()/2), rect.bottom-(bitmap.getHeight()/2), mPaint);
        }
        

  效果以下:

  ok,如今咱們的座標就徹底正確了,沒問題了,搞定

  其實這裏還有更好擴展的小效果,以下:

1,提供剛進來的時候漲水效果
2,船水波紋飄動的時候,船的方向也隨着波紋的切線平行(這裏就要使用到sin 的求導,能夠我忘記完了)

  這些功能在這裏就不和你們實現了,你們能夠下去本身實現,今天有晚了,不過乾貨仍是挺多的,但願你們好好理解,特別是咱們遇到問題時候該怎麼解決,這個很關鍵。很少說了,睡覺了。See You Next Time.........

相關文章
相關標籤/搜索