實現一個可定製化的FlowLayout -- 原理篇

夏至的稻穗發表於2020-02-02

本文已授權玉剛說公眾號

FlowLayout 繼承於 ViewGroup ,可以快速幫您實現 Tablayout 以及 Label 標籤,內含多種效果,幫您快速實現 APP UI 功能,讓您專注程式碼架構,告別繁瑣UI。

如果你也想自己寫一個,可以參考以下幾篇文章

實現一個可定製化的TabFlowLayout(一) -- 測量與佈局

實現一個可定製化的TabFlowLayout(二) -- 實現滾動和平滑過渡

實現一個可定製化的TabFlowLayout(三) -- 動態資料新增與常用介面封裝

實現一個可定製化的TabFlowLayout(四) -- 與ViewPager 結合,實現炫酷效果

實現一個可定製化的TabFlowLayout -- 原理篇

實現一個可定製化的TabFlowLayout -- 說明文件

FlowLayout 和 Recyclerview 實現雙聯表聯動

如果您也想快速實現banner,可以使用這個庫 github.com/LillteZheng…

一 關聯

allprojects {
    repositories {
       ...
        maven { url 'https://jitpack.io' }
        
    }
}
複製程式碼

最新版本請以工程為準:實現一個可定製化的FlowLayout

implementation 'com.github.LillteZheng:FlowHelper:v1.17'
複製程式碼

如果要支援 AndroidX ,如果你的工程已經有以下程式碼,直接關聯即可:

android.useAndroidX=true
#自動支援 AndroidX 第三方庫
android.enableJetifier=true
複製程式碼

效果

首先,就是 TabFlowLayout 的效果,它的佈局支援橫豎兩種方式,首先先看支援的效果:

沒有結合ViewPager 結合ViewPager
實現一個可定製化的FlowLayout -- 原理篇 實現一個可定製化的FlowLayout -- 原理篇
TabFlowLayout豎直,RecyclerView聯動效果
實現一個可定製化的FlowLayout -- 原理篇

除了 TabFlowLayout,還有 LAbelFlowLayout 標籤式佈局,支援自動換行與顯示更多

LabelFlowLayout LabelFlowLayout 顯示更多
實現一個可定製化的FlowLayout -- 原理篇 實現一個可定製化的FlowLayout -- 原理篇

三、原理說明

這裡主要以 TabFlowLayout 來說明,至於 LabelFlowLayout,相信大家看完分析,也知道該怎麼去實現了。

3.1 測量與佈局

從上面的效果看,自定義有挺多種選擇,比如繼承 LinearLayout 或者 HorizontalScrollView … ,但其實直接繼承ViewGroup去動態測量更香; 首先,步驟也很簡單:

  1. 繼承 ViewGroup
  2. 重寫 onMeasure,計運算元控制元件的大小從而確定父控制元件的大小
  3. 重寫 onLayout ,確定子控制元件的佈局

直接看第二步,由於是橫向,在測量的時候,需要確定子控制元件的寬度累加,而高度,則取子控制元件中,最大的那個即可,程式碼如下所示:

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

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int childCount = getChildCount();
        int width = 0;
        int height = 0;
        /**
         * 計算寬高,由於是橫向 width 應該是所有子控制元件的累加,不用管模式了
         */
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == View.GONE){
                continue;
            }
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
            //拿到 子控制元件寬度
            int cw = child.getMeasuredWidth() + params.leftMargin + params.rightMargin;
            int ch = child.getMeasuredHeight() + params.topMargin + params.bottomMargin;

            width += cw;
            //拿到 子控制元件高度,拿到最大的那個高度
            height = Math.max(height, ch);

        }
        if (MeasureSpec.EXACTLY == heightMode) {
            height = heightSize;
        }
        setMeasuredDimension(width, height);
    }
複製程式碼

上面中,子控制元件的寬度進行累加,高度則取子控制元件中最大的那個,再通過 setMeasuredDimension(width, height); 賦值給父控制元件。

接著第三步,重寫 onLayout ,確定子控制元件的佈局,由於是橫向,所以,只需要 child 的 left 一直累加即可:

  @Override
   protected void onLayout(boolean changed, int l, int t, int r, int b) {
       int count = getChildCount();
       int left = 0;
       int top = 0;
       for (int i = 0; i < count; i++) {
           View child = getChildAt(i);
           MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
           int cl = left + params.leftMargin;
           int ct = top + params.topMargin;
           int cr = cl + child.getMeasuredWidth() ;
           int cb = ct + child.getMeasuredHeight();
           //下個控制元件的起始位置
           left += child.getMeasuredWidth() + params.leftMargin + params.rightMargin;
           child.layout(cl, ct, cr, cb);
       }
   }
複製程式碼

這樣,一個簡單的橫向佈局的 TabFlowLayout 即搞定了,我們們寫一些控制元件實驗一下:

    <com.zhengsr.tablib.TabFlowLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#15000000"
        >

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="001"/>
		....
    </com.zhengsr.tablib.TabFlowLayout>
複製程式碼

效果:

實現一個可定製化的FlowLayout -- 原理篇

至於給TabFlowLayout 加上 padding 的效果,可以參考文章: 實現一個可定製化的TabFlowLayout(一) -- 測量與佈局

3.2 實現滾動和平滑過渡

前面中,我們已經通過 FlowLayout 實現測量和佈局,這次新建一個類 ScrollFlowLayout 是專門實現滾動邏輯。 View 的事件傳遞,大概可以這樣簡單描述:

當點選一個控制元件的時候,它的向下傳遞過程大致如下: activity --> window – > viewGroud --> view 。當然第一次走的是 disPatchTouchEvent 方法;通過原始碼知道,如果我們對 onInterceptTouchEvent 返回true,則父控制元件接管當前觸控事件,不再往下傳遞,而是回撥自己的 onTouchEvent 方法。

由於繼承 ViewGroup ,所以我們需要重寫它的 onInterceptTouchEvent 方法:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastX = ev.getX();
                //拿到上次的down座標
                mMoveX = ev.getX();
                break;

            case MotionEvent.ACTION_MOVE:
                float dx = ev.getX() - mLastX;
                if (Math.abs(dx) >= mTouchSlop) {
                    //由父控制元件接管觸控事件
                    return true;
                }
                mLastX = ev.getX();
                break;
            default:
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
複製程式碼

上面的程式碼中,已經接管了 Touch 的事件,接著可以在 onTouchEvent 中,拿到了移動的偏移量, 那怎麼實現 View 自身的移動呢? 沒錯,就是使用 ScrollerBy 和 ScrollerTo,它們只改變 View 的內容而不會改變 View 的座標 ,這正是我們需要的,需要注意的是,向左滑為正,向右為負。

  • ScrollerTo(int x,int y) 絕對座標移動,以原點為參考點
  • ScrollerBy(int x,int y) 相對座標移動,以上一次座標為參考點
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                //scroller 向右為負,向左為正
                int dx = (int) (mMoveX - event.getX());
                scrollBy(dx, 0);
                mMoveX = event.getX();
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;

        }
        return super.onTouchEvent(event);
    }

複製程式碼

效果如下:

實現一個可定製化的FlowLayout -- 原理篇

但看起來還存在一些問題:

  1. 邊界限制
  2. 滾動不夠流暢

上面問題的優化,請參考工程或者文章 實現一個可定製化的TabFlowLayout(二) -- 實現滾動和平滑過渡

3.3 動態資料新增與常用介面封裝

這裡參考至鴻洋的 FlowLayout FlowLayout

考慮到資料動態新增和方便客製化,這裡也採用 adapter 的方式去載入資料,頂部 tab 可能要有未讀訊息,或者不同的控制元件,所以 layoutId 肯定是要有的,datas 資料肯定也是,且這個 data 型別用泛型修飾; 所以,大致的簡約程式碼可以這樣寫:

/**
 * @author by  zhengshaorui on 2019/10/8
 * Describe: 資料構建基類
 */
public abstract class TabAdapter<T> {
    private int mLayoutId;
    private List<T> mDatas;
    public TabAdapter(int layoutId, List<T> data) {
        mLayoutId = layoutId;
        mDatas = data;
    }

    /**
     * 獲取個數
     * @return
     */
    int getItemCount(){
        return mDatas == null ? 0 : mDatas.size();
    }

    /**
     * 獲取id
     * @return
     */
    int getLayoutId(){
        return mLayoutId;
    }

    /**
     * 獲取資料
     * @return
     */
    List<T> getDatas(){
        return mDatas;
    }

    /**
     * 公佈給外部的資料
     * @param view
     * @param data
     * @param position
     */
    public abstract void bindView(View view,T data,int position);

    /**
     * 通知資料改變
     */
    public void notifyDataChanged(){
        if (mListener != null) {
            mListener.notifyDataChanged();
        }
    }

    /**
     * 構建一個listener,用來改變資料
     */

    public AdapterListener mListener;
    void setListener(AdapterListener listener){
        mListener = listener;
    }
   ... 
}
複製程式碼

所以,在TabFlowLayout 新增一個 setAdapter ,把資料設定進去即可:

TabFlowLayout flowLayout = findViewById(R.id.triflow);
flowLayout.setAdapter(new TabFlowAdapter<String>(R.layout.item_msg,mTitle2) {

    @Override
    public void bindView(View view, String data, int position) {
        //設定textview 的 text 和 color
        setText(view,R.id.item_text,data)
                .setTextColor(view,R.id.item_text,Color.BLACK);
    }
});
複製程式碼

但裡面是怎麼實現的呢?其實就是從 adapter 中拿到 layoutId 和 count,再addView 即可

removeAllViews();
TabAdapter adapter = mAdapter;
int itemCount = adapter.getItemCount();
for (int i = 0; i < itemCount; i++) {
    View view = LayoutInflater.from(getContext()).inflate(adapter.getLayoutId(),this,false);
    adapter.bindView(view,adapter.getDatas().get(i),i);
    configClick(view,i);
    addView(view);
}

複製程式碼

效果如下:

實現一個可定製化的FlowLayout -- 原理篇

細節部分,參考這篇文章: 實現一個可定製化的TabFlowLayout(三) -- 動態資料新增與常用介面封裝

3.3.4 與ViewPager 結合,實現炫酷效果

首先要實現的效果如下:

實現一個可定製化的FlowLayout -- 原理篇

可以看到 ,上面實現了幾個效果:

  1. 子控制元件的背景跟著自身大小自動變化
  2. 背景跟著viewpager的滾動自動滑動
  3. 當移動到中間,如果後面有多餘的資料,則讓背景保持在中間,內容移動

首先,實現一個紅色背景框框;首先,思考一下,在 viewgroup 實現 canvas , 是在 onDraw(Canvas canvas) 繪製,還是在 dispatchDraw(Canvas canvas) 呢?答案為 dispathDraw ,為什麼?

  1. onDraw 繪製內容 onDraw 為實際要關心的東西,即所有繪製都在這裡。

  2. dispatchDraw 只對ViewGroup有意義 dispatchDraw 通常來講,可以解釋成繪製 子 View View 繼承drawable,view 元件的繪製會先呼叫 draw(Canvas canvas) 方法,然後先繪製 Drawable背景,接著才是呼叫 onDraw ,然後呼叫 dispatchDraw方法。dispatchDraw 會分發給元件去繪製。 不過 View 是沒有子 view 的,所以dispatchDraw對它來說沒意義。

所以,當自定義 ViewGroup 時,假如 ViewGroup 沒有背景,是不會回撥 onDraw 方法的,只會回撥dispatchDraw,有背景才會走正常順序。(不信? 你可以把你的 tabflowlayout 背景去掉,在 onDraw 繪製,看看有沒有用)

這樣,我們先拿到,第一個子 view 的大小,確定 rect:

View child = getChildAt(0);
if (child != null) {
    //拿到第一個資料
    MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
    mRect.set(getPaddingLeft()+params.leftMargin,
            getPaddingTop()+params.topMargin,
            child.getMeasuredWidth()-params.rightMargin,
            child.getMeasuredHeight() - params.bottomMargin);
}

複製程式碼

接著在 dispatchDraw 中繪製圓角矩形:

@Override
protected void dispatchDraw(Canvas canvas) {
    //繪製一個矩形
    canvas.drawRoundRect(mRect, 10, 10, mPaint);
    super.dispatchDraw(canvas);
}
複製程式碼

效果如下:

實現一個可定製化的FlowLayout -- 原理篇

接著,怎麼讓這個背景跟著 viewpager 移動呢?

可以從 viewpager 的頁面監聽中拿到 onPageScrolled 方法:

public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
複製程式碼

三個引數解釋如下:

  • position :當前第一頁的索引,比較有意思的是,當右滑時,position 表示當前頁面,當左滑時,為當前頁面減1;
  • positionOffset:當前頁面移動的百分比,[0,1]之間;右滑0-1,左滑 1-0;
  • positionOffsetPixels:當前頁面移動的畫素

從上面可以看到,我們只需要 position 和 positionOffset 即可,即上一個 左邊為要移動的偏移量,加上 子 view 的寬度變化即可:

實現一個可定製化的FlowLayout -- 原理篇

@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    /**
     * position 當前第一頁的索引,比較有意思的是,當右滑時,position 表示當前頁面,當左滑時,為當前頁面減1;
     * positionOffset 當前頁面移動的百分比
     * positionOffsetPixels 當前頁面移動的畫素
     */
    if (position < getChildCount() - 1) {
        //上一個view
        final View lastView = getChildAt(position);
        //當前view
        final View curView = getChildAt(position + 1);
        //左邊偏移量
        float left = lastView.getLeft() + positionOffset * (curView.getLeft() - lastView.getLeft());
        //右邊表示寬度變化
        float right = lastView.getRight() + positionOffset * (curView.getRight() - lastView.getRight());
        mRect.left = left;
        mRect.right = right;
        postInvalidate();
  }
}
複製程式碼

這樣就可以移動了,細節部分請參考這篇文章:實現一個可定製化的TabFlowLayout(四) -- 與ViewPager 結合,實現炫酷效果

擴充套件

瞭解了 TabFlowLayout 實現過程,那麼實現 LabelFlowLayout 也能照壺畫瓢了。無非就是測量的時候,判斷是否要換行,然後再在 onLayout 去排列子控制元件的位置。

這裡來了解一下,LabelFlowLayout 顯示更多的漸隱效果怎麼實現的。

實現一個可定製化的FlowLayout -- 原理篇

首先,當我們限制為 2 行時,需要顯示一個更多的效果,這裡為了方便客製化,新增一個 layoutId 讓使用者去配置。

那怎麼讓它顯示在下面呢?

首先,拿到 layoutId 之後,先轉換為view,為了拿到 view 的正確寬高,需要把它給 LabelFlowLayout 去協助測量,並增加 view 的高度一半用來顯示,所以在 onMeasure 中,可以這樣去寫:

/**
 * layoutId 需要父控制元件即 LabelFlowLayout 去幫助測量,才能通過
 * getMeasuredxxx 拿到正確的寬高、
 */
if (mView != null) {
    measureChild(mView, widthMeasureSpec, heightMeasureSpec);
    //新增它的 1/2 來變模糊
    mViewHeight += mView.getMeasuredHeight() / 2;
    setMeasuredDimension(mLineWidth, mViewHeight);
}
複製程式碼

那虛化效果怎麼弄呢?其實可以從 paint 下手。

首先,把 view 轉換成 bitmap,接著對 paint 設定一個 shader ,上半部分為透明色,下半部分則是和背景色一直,如下:

 /**
 * 拿到 view 的 bitmap
 */
mView.layout(0, 0, getWidth(), mView.getMeasuredHeight());
mView.buildDrawingCache();
mBitmap = mView.getDrawingCache();
/**
 * 同時加上一個 shader,讓它有模糊效果
 */
Shader shader = new LinearGradient(0, 0, 0,
        getHeight(), Color.TRANSPARENT, mShowMoreColor, Shader.TileMode.CLAMP);
mPaint.setShader(shader);
mBitRect.set(l, getHeight() - mView.getMeasuredHeight(), r, getHeight());
複製程式碼

然後再 dispatchDraw 中把效果和 bitmap 繪製上去即可:

@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    if (isLabelMoreLine() && mBitmap != null) {
        canvas.drawPaint(mPaint);
        canvas.drawBitmap(mBitmap, mBitRect.left, mBitRect.top, null);
    }

}
複製程式碼

至此,FLowHelper 的原理就基本分析完了,大家可以先自己實現一遍,然後再參考工程程式碼。

相關文章