1,昨天看到了一個挺好的ui效果,是使用貝塞爾曲線實現的,就和大家來分享分享,還有,在寫博客的時候我經常會把自己在做某種效果時的一些問題給寫出來,而不是像很多文章直接就給出了解決方法,這里給大家解釋一下,這里寫出我遇到的一些問題不是為了湊整片文章的字數,而是希望大家能從根源下知道它是怎么解決的,而不是你直接百度搜索這個問題解決的代碼,好了,說了這么多,只是想告訴大家,我后面會在過程中提很多問題(邪惡臉,嘿嘿嘿),好吧,來看看今天的效果:
2,what is the fuck?,這就是你說的很好看的效果?各位看官別着急,這里小弟也沒辦法,實在是找不到好的UI圖,就只能請各位將就一下了,好了言歸正傳,當我們看到這種效果的時候,我們已經有了一些思路,如下:
1,使用paint繪制正弦函數(調用Math.sin(x)的方法) 2,使用逐幀動畫來實現 3,使用貝塞爾三階來實現波浪效果
可能大家還有更多更好的方法,這上面幾點只是我能想到的幾點方法,我今天是使用的貝塞爾來實現的,不清楚貝塞爾使用的同學可以在我博客分類的系列中找到這一欄的分類。
OK,我們先不要去管那些動畫,我們一步一步的來,那么我們的視圖就只有兩部分了,一個是粉紅色帶水區域,一個是我們中間隨着動的icon圖片,那我們先來實現第一個粉紅色帶水的地方,我們最后要實現的效果如下:
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,並初始化一些自定義屬性,這里兩個重要的屬性一個是一個正弦的最高點,即我們的水波紋的高度;一個是我們一個正弦的長度,即我們一個水波紋的橫坐標的長度,下面是一些屬性的初始化 ,很簡單,沒什么難的
//中間小船圖片的引用 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方法,注意了,這是今天整篇博客重點的地方,首先我們知道要使用貝塞爾三階來實現,所以我們可以基本上寫出如下的代碼:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //不斷的計算波浪的路徑 calculatePath(); //繪制水部分 canvas.drawPath(mPath, mPaint); }
關鍵是我們calculatePath()方法中的邏輯處理,這是直接使用貝塞爾,首先我們把我們的繪制起始點平移到我們自定義originY屬性的位置
mPath.moveTo(0, originY);
然后在通過我們的width長度和waveHeight的長度來判斷,到底在屏幕中繪制多少個正弦曲線
for (int i = -waveWidth; i < width + waveWidth; i += waveWidth) { //利用三階貝塞爾曲線繪制 mPath.rCubicTo(????); }
OK,這里我們繪制整體的思路沒什么問題了,關鍵我們三階貝塞爾曲線的兩個控制點和一個結束點的坐標的確認了(這里壓根不知道什么是控制點和結束點的同學整真的推薦你先去看看我博客的貝塞爾基礎知識了)
這里請大家看我在上圖中標注的四個點就分別是我們的起始點、控制點1、控制點2、結束點,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.........