實現(xiàn)一個可定制化的FlowLayout

本文已授權(quán)玉剛說公眾號

FlowLayout 繼承于 ViewGroup ,可以快速幫您實現(xiàn) Tablayout 以及 Label 標(biāo)簽,內(nèi)含多種效果,幫您快速實現(xiàn) APP UI 功能,讓您專注代碼架構(gòu),告別繁瑣UI。

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

實現(xiàn)一個可定制化的TabFlowLayout(一) -- 測量與布局

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

實現(xiàn)一個可定制化的TabFlowLayout(三) -- 動態(tài)數(shù)據(jù)添加與常用接口封裝

實現(xiàn)一個可定制化的TabFlowLayout(四) -- 與ViewPager 結(jié)合,實現(xiàn)炫酷效果

實現(xiàn)一個可定制化的TabFlowLayout -- 原理篇

實現(xiàn)一個可定制化的TabFlowLayout -- 說明文檔

FlowLayout 和 Recyclerview 實現(xiàn)雙聯(lián)表聯(lián)動

如果您也想快速實現(xiàn)banner,可以使用這個庫 https://github.com/LillteZheng/ViewPagerHelper

一 關(guān)聯(lián)

allprojects {
    repositories {
       ...
        maven { url 'https://jitpack.io' }
        
    }
}

最新版本請以工程為準(zhǔn):實現(xiàn)一個可定制化的FlowLayout

implementation 'com.github.LillteZheng:FlowHelper:v1.17'

如果要支持 AndroidX ,如果你的工程已經(jīng)有以下代碼,直接關(guān)聯(lián)即可:

android.useAndroidX=true
#自動支持 AndroidX 第三方庫
android.enableJetifier=true

效果

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

tab_click.gif
tab_viewpager.gif
tab_vertical.gif

除了 TabFlowLayout,還有 LAbelFlowLayout 標(biāo)簽式布局,支持自動換行與顯示更多

label.gif
label_showmore.gif

三、原理說明

這里主要以 TabFlowLayout 來說明,至于 LabelFlowLayout,相信大家看完分析,也知道該怎么去實現(xiàn)了。

3.1 測量與布局

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

  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 應(yīng)該是所有子控件的累加,不用管模式了
         */
        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);
    }

上面中,子控件的寬度進(jìn)行累加,高度則取子控件中最大的那個,再通過 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>

效果:

image

至于給TabFlowLayout 加上 padding 的效果,可以參考文章:
實現(xiàn)一個可定制化的TabFlowLayout(一) -- 測量與布局

3.2 實現(xiàn)滾動和平滑過渡

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

當(dāng)點擊一個控件的時候,它的向下傳遞過程大致如下: activity --> window – > viewGroud --> view 。當(dāng)然第一次走的是 disPatchTouchEvent 方法;通過源碼知道,如果我們對 onInterceptTouchEvent 返回true,則父控件接管當(dāng)前觸摸事件,不再往下傳遞,而是回調(diào)自己的 onTouchEvent 方法。

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastX = ev.getX();
                //拿到上次的down坐標(biāo)
                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);
    }

上面的代碼中,已經(jīng)接管了 Touch 的事件,接著可以在 onTouchEvent 中,拿到了移動的偏移量,
那怎么實現(xiàn) View 自身的移動呢?
沒錯,就是使用 ScrollerBy 和 ScrollerTo,它們只改變 View 的內(nèi)容而不會改變 View 的坐標(biāo) ,這正是我們需要的,需要注意的是,向左滑為正,向右為負(fù)。

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

        }
        return super.onTouchEvent(event);
    }

效果如下:

image

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

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

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

3.3 動態(tài)數(shù)據(jù)添加與常用接口封裝

這里參考至鴻洋的 FlowLayout FlowLayout

考慮到數(shù)據(jù)動態(tài)添加和方便客制化,這里也采用 adapter 的方式去加載數(shù)據(jù),頂部 tab 可能要有未讀消息,或者不同的控件,所以 layoutId 肯定是要有的,datas 數(shù)據(jù)肯定也是,且這個 data 類型用泛型修飾;
所以,大致的簡約代碼可以這樣寫:

/**
 * @author by  zhengshaorui on 2019/10/8
 * Describe: 數(shù)據(jù)構(gòu)建基類
 */
public abstract class TabAdapter<T> {
    private int mLayoutId;
    private List<T> mDatas;
    public TabAdapter(int layoutId, List<T> data) {
        mLayoutId = layoutId;
        mDatas = data;
    }

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

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

    /**
     * 獲取數(shù)據(jù)
     * @return
     */
    List<T> getDatas(){
        return mDatas;
    }

    /**
     * 公布給外部的數(shù)據(jù)
     * @param view
     * @param data
     * @param position
     */
    public abstract void bindView(View view,T data,int position);

    /**
     * 通知數(shù)據(jù)改變
     */
    public void notifyDataChanged(){
        if (mListener != null) {
            mListener.notifyDataChanged();
        }
    }

    /**
     * 構(gòu)建一個listener,用來改變數(shù)據(jù)
     */

    public AdapterListener mListener;
    void setListener(AdapterListener listener){
        mListener = listener;
    }
   ... 
}

所以,在TabFlowLayout 新增一個 setAdapter ,把數(shù)據(jù)設(shè)置進(jìn)去即可:

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) {
        //設(shè)置textview 的 text 和 color
        setText(view,R.id.item_text,data)
                .setTextColor(view,R.id.item_text,Color.BLACK);
    }
});

但里面是怎么實現(xiàn)的呢?其實就是從 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);
}

效果如下:

image

細(xì)節(jié)部分,參考這篇文章: 實現(xiàn)一個可定制化的TabFlowLayout(三) -- 動態(tài)數(shù)據(jù)添加與常用接口封裝

3.3.4 與ViewPager 結(jié)合,實現(xiàn)炫酷效果

首先要實現(xiàn)的效果如下:

image

可以看到 ,上面實現(xiàn)了幾個效果:

  1. 子控件的背景跟著自身大小自動變化
  2. 背景跟著viewpager的滾動自動滑動
  3. 當(dāng)移動到中間,如果后面有多余的數(shù)據(jù),則讓背景保持在中間,內(nèi)容移動

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

  1. onDraw 繪制內(nèi)容
    onDraw 為實際要關(guān)心的東西,即所有繪制都在這里。

  2. dispatchDraw 只對ViewGroup有意義
    dispatchDraw 通常來講,可以解釋成繪制 子 View
    View 繼承drawable,view 組件的繪制會先調(diào)用 draw(Canvas canvas) 方法,然后先繪制 Drawable背景,接著才是調(diào)用 onDraw ,然后調(diào)用 dispatchDraw方法。dispatchDraw 會分發(fā)給組件去繪制。
    不過 View 是沒有子 view 的,所以dispatchDraw對它來說沒意義。

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

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

View child = getChildAt(0);
if (child != null) {
    //拿到第一個數(shù)據(jù)
    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);
}

效果如下:

image

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

可以從 viewpager 的頁面監(jiān)聽中拿到 onPageScrolled 方法:

public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);

三個參數(shù)解釋如下:

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

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

image
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    /**
     * position 當(dāng)前第一頁的索引,比較有意思的是,當(dāng)右滑時,position 表示當(dāng)前頁面,當(dāng)左滑時,為當(dāng)前頁面減1;
     * positionOffset 當(dāng)前頁面移動的百分比
     * positionOffsetPixels 當(dāng)前頁面移動的像素
     */
    if (position < getChildCount() - 1) {
        //上一個view
        final View lastView = getChildAt(position);
        //當(dāng)前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();
  }
}

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

擴展

了解了 TabFlowLayout 實現(xiàn)過程,那么實現(xiàn) LabelFlowLayout 也能照壺畫瓢了。無非就是測量的時候,判斷是否要換行,然后再在 onLayout 去排列子控件的位置。

這里來了解一下,LabelFlowLayout 顯示更多的漸隱效果怎么實現(xiàn)的。

image

首先,當(dāng)我們限制為 2 行時,需要顯示一個更多的效果,這里為了方便客制化,添加一個 layoutId 讓用戶去配置。

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

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

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

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

首先,把 view 轉(zhuǎn)換成 bitmap,接著對 paint 設(shè)置一個 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);
    }

}

至此,F(xiàn)LowHelper 的原理就基本分析完了,大家可以先自己實現(xiàn)一遍,然后再參考工程代碼。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容