歡迎光臨
每天分享高質量文章

【炫酷】Android 波浪式扭曲側邊欄,窗簾(Curtain Menu)效果

來自:Android技術雜貨鋪(微訊號:Tech-Android) 排版整編

作者:Android500
原文:https://www.jianshu.com/p/ca2331962b2e

寫這篇文章的初衷是因為早些時候看到一款morning routine上的窗簾皺褶效果,自己也想去實現它,網上也有一些案例但是效果不太好而且沒有任何的註釋改動難度比較,因此想自己的想法去實現這個效果。如下圖就是我們最終想模仿實現的效果:

在開始寫程式碼之前,我們得先瞭解一些兩個重要的知識點:

1.Canvas方法drawBitmapMesh的使用:

Canvas提供了一個方法

drawBitmapMesh(Bitmap bitmap, int meshWidth, int meshHeight, float[] verts, int vertOffset, int[] colors,int colorffset,Paint paint)

這個方法可以對bitmap進行扭曲,引數說明如下:

  • bitmap     需要扭曲的源點陣圖

  • meshWidth   控制在橫向上把該源點陣圖劃成成多少格

  • meshHeight  控制在縱向上把該源點陣圖劃成成多少格

  • verts     長度為(meshWidth + 1) * (meshHeight + 1) * 2的陣列,它記錄了扭曲後的點陣圖各頂點位置

  • vertOffset 控制verts陣列中從第幾個陣列元素開始才對bitmap進行扭曲

2.正弦曲線,公式:y=Asin(ωx+φ)+k

正弦曲線可表示為y=Asin(ωx+φ)+k,定義為函式y=Asin(ωx+φ)+k在直角坐標繫上的圖象,其中sin為正弦符號,x是直角坐標系x軸上的數值,y是在同一直角坐標繫上函式對應的y值,k、ω和φ是常數(k、ω、φ∈R且ω≠0)

  • A——振幅,當物體作軌跡符合正弦曲線的直線往複運動時,其值為行程的1/2。

  • (ωx+φ)——相位,反映變數y所處的狀態。

  • φ——初相,x=0時的相位;反映在坐標繫上則為影象的左右移動。

  • k——偏距,反映在坐標繫上則為影象的上移或下移。

  • ω——角速度, 控制正弦週期(單位弧度內震動的次數)。

在已經大概瞭解了drawBitmapMesh的使用和正弦曲線的定義後,我們往下就是要去瞭解兩者之間如何配合使用去扭曲圖片實現波浪褶皺效果。相對一張(w * h)畫素的圖片來說,這張圖片是由h條長度為W畫素的水平直線緊湊排列而成,實現如下左圖的波浪褶皺效果跟右圖把紅色直線透過正弦曲線公式扭曲成正弦曲線的原理是一樣的。

左圖的實現程式碼如下:

public class CurtainView extends View {
    private Bitmap mbitmap;
    private static int WIDTH = 30;
    private static int HEIGHT = 30;
    //最大水平的波形高度
    private float WAVE_HEIGHT = 50;

    //小格相交的總的點數
    private int COUNT = (WIDTH + 1) * (HEIGHT + 1);
    private float[] verts = new float[COUNT * 2];
    private float[] origs = new float[COUNT * 2];

    private float k;

    private float progress;


    public CurtainView(Context context) {
        super(context);
        init();
    }

    public CurtainView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

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

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

        for (int i = 0; i 1; i++) {
            for (int j = 0; j 1; j++) {
                //把每一個水平畫素透過正弦公式轉換成正弦曲線
                //WAVE_HEIGHT表示波峰跟波低的垂直距離,皺褶後會向上超過水平線,所以往下偏移WAVE_HEIGHT / 2
                //5表示波浪的密集度,表示波峰波谷總共有五個,對應上面左圖的1,2,3,4,5
                //j就是水平像的X軸坐標
                //K決定正弦曲線起始點(x=0)點的Y坐標,k=0就是從波峰波谷的中間開始左->右繪製曲線
                float yOffset = WAVE_HEIGHT / 2  + WAVE_HEIGHT / 2 * (float) Math.sin((float) j / WIDTH * 5 * Math.PI + k);
                verts[(i * (WIDTH + 1) + j) * 2 + 1] = origs[(i * (WIDTH + 1) + j) * 2 + 1] + yOffset;//
            }
        }

        canvas.drawBitmapMesh(mbitmap, WIDTH, HEIGHT, verts, 0null0null);
    }

    int bitmapwidth;
    int bitmapheight;

    public void init() {
        mbitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.timg);
        bitmapwidth = mbitmap.getWidth();
        bitmapheight = mbitmap.getHeight();

        COUNT = (WIDTH + 1) * (HEIGHT + 1);
        verts = new float[COUNT * 2];
        origs = new float[COUNT * 2];

        int index = 0;
        for (int i = 0; i 1; i++) {
            float fy = bitmapheight / (float) HEIGHT * i;
            for (int j = 0; j 1; j++) {
                float fx = bitmapwidth / (float) WIDTH * j;
                //偶數位記錄x坐標  奇數位記錄Y坐標
                origs[index * 2 + 0] = verts[index * 2 + 0] = fx;
                origs[index * 2 + 1] = verts[index * 2 + 1] = fy;
                index++;
            }
        }
    }
}

關鍵程式碼:

//把每一個水平畫素直線透過正弦公式轉換成正弦曲線
//WAVE_HEIGHT表示波峰跟波谷的垂直距離,皺褶後會向上超過水平線,所以往下偏移WAVE_HEIGHT / 2
//5表示波浪的密集度,表示波峰波谷總共有五個,對應上面左圖的1,2,3,4,5
//j就是水平像的X軸坐標
//K決定正弦曲線起始點(x=0)點的Y坐標,k=0就是從波峰波谷的中間開始左->右繪製曲線,像右圖一樣

float yOffset = WAVE_HEIGHT / 2  + WAVE_HEIGHT / 2 * (float) Math.sin((float) j / WIDTH * 5 * Math.PI + k);

3 . 動態調整繫數,改變波浪圖片的皺褶成度

上一節已經實現瞭如何把一張圖片扭曲成波浪效果,那麼這一節我們介紹如何動態調整繫數,去改變波浪圖片的皺褶成度。我們自一次觀察下圖morning routine的效果:

仔細觀察我們發現,當往右滑動時,頭部的溝壑(也就是正弦曲線)是從0遞增到最大的;如果具體到一條水平畫素直線的話,這其實就是一條直線扭曲成正弦曲線的一個過程。直線如何扭曲成正弦曲線、正弦曲線如何恢覆成直線其實就是繫數WAVE_HEIGHT(波峰波谷距離)從0MAX_WAVE_HEIGHT、MAX_WAVE_HEIGHT0的動態變化過程,因此我們只要在前一篇文章正弦曲線的公式程式碼加入滑動的滑動百分比progress(1>=progress>=0)就可以了:

float yOffset = WAVE_HEIGHT / 2 * progress + WAVE_HEIGHT / 2 * progress * (float) Math.sin((float) j / WIDTH * 5 * Math.PI + k);

上面已經介紹了動態滑動扭曲時如何計算每條水平直線上每個畫素的y軸偏移量,但是根據上圖效果其實每個畫素的x坐標也是變化的;摺疊選單的最左邊的畫素點x坐標等於選單向左滑動的距離,摺疊選單的最右邊的畫素點x坐標緊緊的擠壓邊緣不移動,所以當滑動時每個畫素的x坐標是偏移量是從左往右衰減成0的,由此我們可以推匯出滑動時計算的每個畫素x坐標的公式:

//bitmapwidth 原圖寬度//origsX 原圖時畫素的X坐標//progress當前滑動百分比//xPostion 畫素的新x坐標//這個公式計算出的xPostion 越往右跟origsX 的差距越小,最後一畫素差距為0
float xPostion = origsX + (bitmapwidth - origsX ) * progress;

原理我們已經理解的差不多了,那麼最終的效果如下圖:

4 . 波浪式扭曲效果最佳化

4 . 1.豎直方向畫素最佳化

前面我們已經實現圖片的扭曲效果,但是隻是僅僅扭曲了水平直線上的畫素,這些扭曲後的畫素在豎直方向還是處於一條直線中一次,圖片的垂直邊是豎直的看著很不自然。下麵第一步我們要做的最佳化就是把這些豎直線上的畫素y坐標代入正弦公式得到Y軸上最佳化過後的x坐標,那麼整個扭曲圖片看起來就更自然了。前面我們已經詳細介紹了水平方向畫素的扭曲原理,豎直方向上的扭曲我們就直接上核心程式碼吧,核心程式碼如下:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        for (int i = 0; i 1; i++) {
            for (int j = 0; j 1; j++) {

                //把每一個水平畫素透過正弦公式轉換成正弦曲線
                //H_MAX_WAVE_HEIGHT表示波峰跟波低的垂直距離,皺褶後會王桑超過水平線,所以往下偏移WAVE_HEIGHT / 2
                //5表示波浪的密集度,表示波峰波谷總共有五個,對應上面左圖的1,2,3,4,5
                //j就是水平像的X軸坐標
                //K決定正弦曲線起始點(x=0)點的Y坐標,k=0就是從波峰波谷的中間開始左->右繪製曲線
                float yOffset = H_MAX_WAVE_HEIGHT / 2 * progress + H_MAX_WAVE_HEIGHT / 2 * progress * (float) Math.sin((float)j/WIDTH*5*Math.PI+k);

                //垂直方向豎直壓縮時的坐標
                float xPostion = origs[(i*(WIDTH+1)+j)*2+0] + (bitmapwidth - origs[(i*(WIDTH+1)+j)*2+0]) * progress;
                //垂直方向正弦曲線最佳化後的坐標,1.1->個波峰波谷
                float vXSinPostion = V_MAX_WAVE_HEIGHT / 2 * progress * (float) Math.sin((float)i/WIDTH*1.1*Math.PI + k);
                //每個畫素扭曲後的x坐標
                //origs[(i*(WIDTH+1)+j)*2+0] 原圖x坐標
                verts[(i*(WIDTH+1)+j)*2+0]= vXSinPostion *((bitmapwidth - xPostion) / bitmapwidth) + xPostion;
                //每個畫素扭曲後的Y坐標
                //origs[(i*(WIDTH+1)+j)*2+1] 原圖y坐標
                verts[(i * (WIDTH + 1) + j) * 2 + 1] = origs[(i * (WIDTH + 1) + j) * 2 + 1] + yOffset;//
            }
        }
        canvas.drawBitmapMesh(mbitmap, WIDTH, HEIGHT, verts, 0null0null);
    }

上面的程式碼主要是在x軸畫素正弦曲線扭曲的同時對豎直y軸畫素做1.1個波峰、波谷的扭曲;扭曲後的x軸坐標也要依據偏移量由左往右衰減的的特性來計算

水平方向和豎直方向扭曲效果圖如下:

4.2.陰影效果最佳化

上圖的效果已經非常的接近我們想要的效果了,但是還要給皺褶後的每個溝壑新增陰影效果才更美觀,這裡我們用drawBitmapMesh的colors引數為每個扭曲後的畫素繪製陰影顏色(僅支援api level >= 18)。直接上程式碼吧:

//yOffset 表示每個畫素y軸的偏移量,yOffset越大表示越接近谷底陰影效果越
int channel = 255 - (int)(yOffset * 3);channel = channel 0 ? 0 : channel;channel = channel > 255 ? 255 : channel;colors[index] = 0xFF000000 | channel <16 | channel <8 | channel;index += 1;

效果如下:

完整程式碼:

public class CurtainView extends View {
    private Bitmap mbitmap;
    private static int WIDTH = 30;
    private static int HEIGHT = 30;
    //最大水平的波形高度
    private float H_MAX_WAVE_HEIGHT = 50;
    //最大垂直的波形高度
    private float V_MAX_WAVE_HEIGHT = 500;

    //小格相交的總的點數
    private int COUNT = (WIDTH + 1) * (HEIGHT + 1);
    private float[] verts = new float[COUNT * 2];
    private float[] origs = new float[COUNT * 2];
    private int[] colors = new int[COUNT * 2];
    private float k;
    private float progress;

    public CurtainView(Context context) {
        super(context);
        init();
    }

    public CurtainView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

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

    public void setProgress(float progress){
        this.progress = progress;
        invalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int index = 0;
        for (int i = 0; i 1; i++) {
            for (int j = 0; j 1; j++) {

                //把每一個水平畫素透過正弦公式轉換成正弦曲線
                //H_MAX_WAVE_HEIGHT表示波峰跟波低的垂直距離,皺褶後會王桑超過水平線,所以往下偏移WAVE_HEIGHT / 2
                //5表示波浪的密集度,表示波峰波谷總共有五個,對應上面左圖的1,2,3,4,5
                //j就是水平像的X軸坐標
                //K決定正弦曲線起始點(x=0)點的Y坐標,k=0就是從波峰波谷的中間開始左->右繪製曲線
                float yOffset = H_MAX_WAVE_HEIGHT / 2 * progress + H_MAX_WAVE_HEIGHT / 2 * progress * (float) Math.sin((float)j/WIDTH*5*Math.PI+k);

                //垂直方向豎直壓縮時的坐標
                float xPostion = origs[(i*(WIDTH+1)+j)*2+0] + (bitmapwidth - origs[(i*(WIDTH+1)+j)*2+0]) * progress;
                //垂直方向正弦曲線最佳化後的坐標,1.1->個波峰波谷
                float vXSinPostion = V_MAX_WAVE_HEIGHT / 2 * progress * (float) Math.sin((float)i/WIDTH*1.1*Math.PI + k);
                //每個畫素扭曲後的x坐標
                //origs[(i*(WIDTH+1)+j)*2+0] 原圖x坐標
                verts[(i*(WIDTH+1)+j)*2+0]= vXSinPostion *((bitmapwidth - xPostion) / bitmapwidth) + xPostion;
                //每個畫素扭曲後的Y坐標
                //origs[(i*(WIDTH+1)+j)*2+1] 原圖y坐標
                verts[(i * (WIDTH + 1) + j) * 2 + 1] = origs[(i * (WIDTH + 1) + j) * 2 + 1] + yOffset;//

                int channel = 255 - (int)(yOffset * 3);
                channel = channel 0 ? 0 : channel;
                channel = channel > 255 ? 255 : channel;
                colors[index] = 0xFF000000 | channel <16 | channel <8 | channel;
                index += 1;
            }
        }
        canvas.drawBitmapMesh(mbitmap, WIDTH, HEIGHT, verts, 0, colors, 0null);
    }

    int bitmapwidth;
    int bitmapheight;

    public void init() {
        mbitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.timg);
        bitmapwidth = mbitmap.getWidth();
        bitmapheight = mbitmap.getHeight();

        COUNT = (WIDTH + 1) * (HEIGHT + 1);
        verts = new float[COUNT * 2];
        origs = new float[COUNT * 2];

        int index = 0;
        for (int i = 0; i 1; i++) {
            float fy = bitmapheight / (float) HEIGHT * i;
            for (int j = 0; j 1; j++) {
                float fx = bitmapwidth / (float) WIDTH * j;
                //偶數位記錄x坐標  奇數位記錄Y坐標
                origs[index * 2 + 0] = verts[index * 2 + 0] = fx;
                origs[index * 2 + 1] = verts[index * 2 + 1] = fy;
                index++;
            }
        }
    }
}

5. 應用場景和使用方法

xml佈局檔案使用:

<com.hx.curtain.drawer.CurtainContentLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/curtain_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    app:behind_menu="@layout/menu_left"
    app:content="@layout/layout_curtain_content"
    app:h_waveCount="5"
    app:maxRate="0.5"
    app:v_waveCount="1.1" />

監聽滑動的繫數

CurtainContentLayout curtain_layout = findViewById(R.id.curtain_layout);
curtain_layout.setCurtainLayoutListener(new CurtainContentLayout.OnCurtainLayoutListener() {
    @Override
    public void onSlide(View caurtainLayout, float slideOffset) {
        Log.e("CurtainActivity""slideOffset: " + slideOffset);
    }
});

自定義屬性

  • behind_menu menu後面的佈局

  • content menu的內容,必須提供這個屬性,不然會異常

  • h_waveCount 水平方向摺疊的波浪個數

  • v_waveCount 豎直方向摺疊的波浪個數

  • maxRate menu最大的收縮比

效果預覽:

本功能已經抽成了開源庫Awesome Drawer,更多細節請看GitHub:https://github.com/Android1404/AwesomeDrawer


●編號345,輸入編號直達本文

●輸入m獲取到文章目錄

推薦↓↓↓

Java程式設計

更多推薦18個技術類公眾微信

涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

贊(0)

分享創造快樂