仿豆瓣彈性滑動控件,史上最全方位講解事件滑動沖突

本篇文章已授權(quán)微信公眾號 guolin_blog (郭霖)獨家發(fā)布

前言

先來一道趣味測試,后面的控件講解會比較枯燥乏味,看一看你的數(shù)學(xué)老師是誰教的?

小明向兩位朋友各自借了50元,用借來的錢,小明花費97元買了一件格子衫。這時候還剩3元錢,小明還給兩個小伙伴各1元,自己還剩下1元。

那么問題來了:小明此時欠兩位小伙伴各49元,再加上自己剩下的1元,49+49+1=99元。剩下的1元去哪了?

正文

近日產(chǎn)品提出了一個新需求,在首頁列表中新增可以橫向滑動的卡片類型,效果類似豆瓣彈性滑動控件,看下最終效果圖:

滑動彈性控件

小編剛開始以為只要實現(xiàn)了 豆瓣彈性滑動控件 就萬事大吉了,沒想到這只是一個開始。滑動控件 只不過是一道開胃菜,事件沖突 才是重頭戲。

首先分析下效果圖中的布局,典型的 ViewPager + fragment + RecyclerView 布局方式,在垂直的 RecyclerView 中嵌入了 彈性滑動控件item,那么會有哪些事件沖突呢?

  1. 彈性滑動控件 會消費左右滑動事件,內(nèi)部的卡片 RecyclerView 同時也會消費左右滑動事件,左右滑動事件就會沖突。光是文字的描述,可能不大好理解,結(jié)合以下圖片加以說明:手指向左滑動,是 RecyclerView 消費左滑的事件呢?還是 彈性滑動控件 消費左滑的事件?
    scr
  2. 垂直的 RecyclerView 會默認(rèn)消費上下滑動事件, 彈性滑動控件 在左右滑動的同時,y 軸方向的偏移量不會為 0,因為手指的滑動很難保持在一條水平線上,垂直的 RecyclerView 就會消費 y 方向的事件,導(dǎo)致界面抖動,滑動不靈敏。那么 彈性滑動控件 在左右滑動的時候就需要攔截掉垂直的 RecyclerView 的滑動事件消費。
  3. 彈性滑動控件 滑動到左右邊緣的時候,最外層的 ViewPager 會默認(rèn)消費掉左右滑動事件,導(dǎo)致滑向上一個 tab 或下一個 tab ,無任何的彈性效果。處理方式, 在彈性滑動控件 左右滑動的時候,需要禁止掉 ViewPager 的事件消費。

一個滑動控件需要解決這么多事件沖突,想一想,是時候使用抽屜里的菜刀了,但讓我沒想到的是,我拿著菜刀急沖沖找到產(chǎn)品,他卻很淡定的從抽屜里拿出了手槍,拿出了手槍,我內(nèi)心告訴自己不能慫,嘴上卻不爭氣的說道:沒問題,so easy ,給我2天時間,我真想給自己一大嘴巴,那么接下來就開整唄。

豆瓣彈性滑動控件

需要實現(xiàn) 豆瓣彈性滑動控件 的效果,先調(diào)研下豆瓣的布局方式:

在這里插入圖片描述

uiautomatorviewer.bat 工具中可以分析出,豆瓣是通過自定義 LinearLayout 來實現(xiàn)的,包含了橫向的 RecyclerView 與右側(cè)的 釋放查看TextView 文本子控件。那么 彈性滑動控件 實現(xiàn)的大概思路如下:RecyclerView 滑動到左右邊緣,記錄 x 軸方向的偏移量,通過方法 setTranslationX 設(shè)置 RecyclerView 的平移量,手指抬起則執(zhí)行簡單的平移動畫,接下來會詳細(xì)講解,比較乏味,請系好安全帶。

分解 彈性滑動 過程,新建HorizontalScrollView繼承RelativeLayout,并沒有繼承LinearLayout,后面會講到:

  1. RecyclerView 滑動到左邊緣,繼續(xù)向右滑動,HorizontalScrollView 攔截事件,同時記錄x方向的偏移量dx,RecyclerView 調(diào)用 setTranslationX 方法設(shè)置平移量 RecyclerView.setTranslationX(dx),這里又分兩種情況:第一種手指抬起執(zhí)行平移動畫;第二種向左滑動除了 RecyclerView.setTranslationX(dx) 還需要判定 RecyclerView.getTranslationX() 是否等于 0 ,如果等于 0 則不攔截事件,返回 super.dispatchTouchEvent(ev)
  2. RecyclerView 滑動到右邊緣,繼續(xù)向左滑動,處理同1,還需根據(jù)偏移量來判定右側(cè)的文本顯示狀態(tài)。
  3. RecyclerView 未滑動到左右邊緣,HorizontalScrollView 不攔截事件,RecyclerView 消費左右滑動事件。

請結(jié)合以下代碼加以理解:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (mHorizontalRecyclerView == null) {
            return super.dispatchTouchEvent(ev);
        }
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
               // 重置變量
                mHintLeftMargin = 0;
                mMoveIndex = 0;
                mConsumeMoveEvent = false;
                mLastX = ev.getRawX();
                mLastY = ev.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                // 釋放動畫
                if (ReboundAnim != null && ReboundAnim.isRunning()) {
                    break;
                }
                float mDeltaX = (ev.getRawX() - mLastX);
                float mDeltaY = ev.getRawY() - mLastY;
                
                mLastX = ev.getRawX();
                mLastY = ev.getRawY();
                mDeltaX = mDeltaX * RATIO;

                // 右滑
                if (mDeltaX > 0) {
                    //  canScrollHorizontally 判定是否滑動到邊緣
                    if (!mHorizontalRecyclerView.canScrollHorizontally(-1) || mHorizontalRecyclerView.getTranslationX() < 0) {
                        float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX();
                        if (mHorizontalRecyclerView.canScrollHorizontally(-1) && transX >= 0) {
                            transX = 0;
                        }
                        mHorizontalRecyclerView.setTranslationX(transX);
                        setHintTextTranslationX(mDeltaX);
                    }
                } else if (mDeltaX < 0) { // 左滑
                    if (!mHorizontalRecyclerView.canScrollHorizontally(1) || mHorizontalRecyclerView.getTranslationX() > 0) {
                        float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX();
                        if (transX <= 0 && mHorizontalRecyclerView.canScrollHorizontally(1)) {
                            transX = 0;
                        }
                        mHorizontalRecyclerView.setTranslationX(transX);
                        setHintTextTranslationX(mDeltaX);
                    }
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                // 釋放動畫
                if (ReboundAnim != null && ReboundAnim.isRunning()) {
                    break;
                }

                if (mHintLeftMargin <= mOffsetWidth && mListener != null) {
                   // 松手看更多的事件監(jiān)聽
                    mListener.onRelease();
                }
                // 手指抬起動畫
                ReboundAnim = ValueAnimator.ofFloat(1.0f, 0);
                ReboundAnim.setDuration(300);
                ReboundAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        float value = (float) animation.getAnimatedValue();
                        mHorizontalRecyclerView.setTranslationX(value * mHorizontalRecyclerView.getTranslationX());
                        mMoreTextView.setTranslationX(value * mMoreTextView.getTranslationX());
                    }
                });
                ReboundAnim.start();
                break;
        }
        return mHorizontalRecyclerView.getTranslationX() != 0 ? true : super.dispatchTouchEvent(ev);
    }

代碼邏輯很清晰,有不理解的童鞋,請留言。彈性效果實現(xiàn)了,但右側(cè)還有一個豎直的文本控件,ui 需要的效果如下圖,需要實現(xiàn)的功能如下:

dou_4
  • 內(nèi)容垂直排版
  • 文字間的間距需要可控
  • 可以設(shè)置圖標(biāo)
  • 貝塞爾曲線陰影,根據(jù)手指偏移量來動態(tài)改變貝塞爾曲線的控制點

很遺憾,原生的 TextView 并不支持內(nèi)容垂直排版,間距也不可控,但欣慰的是支持設(shè)置圖標(biāo),那么重寫 onDraw 方法,自己繪制垂直文本,可謂是一個不錯的方案。

VerticalTextView 繼承 AppCompatTextView ,通過 setVerticalText() 方法設(shè)置繪制文本:

    public void setVerticalText(CharSequence text) {
        mDefaultText = text;
        invalidate();
    }

通過獲取基線 baseline 坐標(biāo),以及整個字符的高度,來調(diào)整文本居中對齊,然后根據(jù)每個字符的高度,累加繪制文本:

    @Override
    protected void onDraw(Canvas canvas) {
        mPaint.setTextSize(getTextSize());
        mPaint.setColor(getCurrentTextColor());
        mPaint.setTypeface(getTypeface());
        CharSequence text = mDefaultText;
        if (getText() != null && !text.toString().trim().equals("")) {
            Rect bounds = new Rect();
            mPaint.getTextBounds(text.toString(), 0, text.length(), bounds);

            // 最開始就忘記 + getPaddingLeft 導(dǎo)致繪制的文本偏左
            float startX = getLayout().getLineLeft(0) + getPaddingLeft();

            if (getCompoundDrawables()[0] != null) {
                Rect drawRect = getCompoundDrawables()[0].getBounds();
                // 減去圖標(biāo)的寬度
                startX += (drawRect.right - drawRect.left);
            }

            startX += getCompoundDrawablePadding();

            float startY = getBaseline();

            int cHeight = (bounds.bottom - bounds.top + mCharSpacing);

            // 居中對齊
            startY -= (text.length() - 1) * cHeight / 2;

            for (int i = 0; i < text.length(); i++) {
                String c = String.valueOf(text.charAt(i));

                canvas.drawText(c, startX, startY + i * cHeight, mPaint);
            }
        }
        super.onDraw(canvas);
        // 繪制貝塞爾陰影
        if (mIsDrawShadow) {
            mShadowPath.reset();
            mShadowPath.moveTo(getWidth(), getHeight() / 4);
            mShadowPath.quadTo(mShadowOffset, getHeight() / 2, getWidth(), getHeight() / 4 * 3);
            canvas.drawPath(mShadowPath, mShadowPaint);
        }
    }

突然有個想法,如果以路徑 Path 來繪制文本,豈不更棒,有興趣的小伙伴可以下來試一試。彈性滑動控件 到這里就告一段落了,接下來主要處理集成到項目中的滑動事件沖突。

垂直RecyclerView滑動沖突

垂直 RecyclerView 會消費上下滑動事件,導(dǎo)致 彈性滑動控件 在水平方向滑動的時候,y 軸方向產(chǎn)生的偏移量被垂直 RecyclerView 消費,請看下圖:

src_5

那么怎么來處理與垂直 RecyclerView 產(chǎn)生的事件沖突呢?處理事件沖突的方式有兩種:

  1. View 禁止父 View 攔截 Touch 事件,在分析 ViewGroupdispatchTouchEvent() 源碼時,我們知道:Touch 事件是由父 View 分發(fā)的。如果一個 Touch 事件是子 View 需要的,但是被其父 View 攔截了,子 View 就無法處理該 Touch 事件了。在此情形下,子 View 可以調(diào)用 requestDisallowInterceptTouchEvent( ) 禁止父 ViewTouch 的攔截
  2. 在父 View 中準(zhǔn)確地進(jìn)行事件分發(fā)和攔截 ,我們可以重寫父 View 中與 Touch 事件分發(fā)相關(guān)的方法,比如onInterceptTouchEvent( )。這些方法中摒棄系統(tǒng)默認(rèn)的流程,結(jié)合自身的業(yè)務(wù)邏輯重寫該部分代碼,從而使父View 放行子 View 需要的 Touch

這里以第一種的方式解決與垂直方向的 RecyclerView 滑動沖突,第二種方式解決與 ViewPager 的滑動沖突。原理非常的簡單,判定 x 方向的偏移量是否大于 y 方向的偏移量,大于則禁止父 View 攔截 Touch 事件,反之則不攔截,具體代碼如下:

    float mDeltaX = (ev.getRawX() - mLastX);
    float mDeltaY = ev.getRawY() - mLastY;
    if (!mConsumeMoveEvent) {
        // 處理事件沖突
        if (Math.abs(mDeltaX) > Math.abs(mDeltaY)) {
            getParent().requestDisallowInterceptTouchEvent(true);
        } else {
            getParent().requestDisallowInterceptTouchEvent(false);
        }
    }
    mMoveIndex++;
    if (mMoveIndex > 2) {
        mConsumeMoveEvent = true;
    }
    mLastX = ev.getRawX();
    mLastY = ev.getRawY();

很多時候觸摸屏幕會導(dǎo)致第一次 ACTION_MOVE 獲取的 mDeltaXmDeltaY 都為 0,導(dǎo)致父 View 攔截了 Touch 事件,彈性效果失效,為了解決這個問題,這里用到了一個小技巧,多判定一次攔截條件。大家發(fā)現(xiàn)沒有,代碼中還有一處優(yōu)化的地方,getParent() 方法獲取的父控件不一定是列表控件,比較合理的方式使用遞歸去獲取,相關(guān)代碼如下:

    private ViewParent getParentListView(ViewParent viewParent) {
        if (viewParent == null) return null;
        if (viewParent instanceof RecyclerView || viewParent instanceof ListView) {
            return viewParent;
        } else {
            getParentListView(viewParent.getParent());
        }
        return null;
    }

ViewPager滑動沖突

ViewPager 會默認(rèn)消費左右滑動事件,當(dāng) 彈性控件 滑動到左右邊緣時,繼續(xù)滑動會觸發(fā) ViewPager 的滑動,請看下圖:

src_6

這里采用第二種方式處理滑動沖突,在父 View 中準(zhǔn)確地進(jìn)行事件分發(fā)和攔截,那么我們什么時候分發(fā)?又什么時候攔截呢?如果我們左右滑動的是非 彈性控件 區(qū)域,那么 ViewPager 應(yīng)該攔截事件,反之則分發(fā)事件。

那么我們才能知道觸摸的是 彈性控件 區(qū)域呢?可能在屏幕中的任何位置,我們知道 view 的層級是樹形結(jié)構(gòu),那么針對 ViewPager 的子 view 進(jìn)行遍歷,拿到設(shè)有 彈性控件tag 標(biāo)記,來進(jìn)行事件的分發(fā)和攔截,具體代碼如下,不知道小伙伴又沒更好的方案:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mInterceptEvent = !childInterceptEvent(this, (int) ev.getRawX(), (int) ev.getRawY());
                break;
        }
        // 攔截與分發(fā)
        return mInterceptEvent ? super.onInterceptTouchEvent(ev) : false;
    }

    // 遍歷樹
    private boolean childInterceptEvent(ViewGroup parentView, int touchX, int touchY) {
        boolean isConsume = false;
        for (int i = parentView.getChildCount() - 1; i >= 0; i--) {
            View childView = parentView.getChildAt(i);
            if (!childView.isShown()) {
                continue;
            }
            boolean isTouchView = isTouchView(touchX, touchY, childView);
            if (isTouchView && childView.getTag() != null && TAG_DISPATCH.equals(childView.getTag().toString())) {
                isConsume = true;
                break;
            }
            if (childView instanceof ViewGroup) {
                ViewGroup itemView = (ViewGroup) childView;
                if (!isTouchView) {
                    continue;
                } else {
                    isConsume |= childInterceptEvent(itemView, touchX, touchY);
                    if (isConsume) {
                        break;
                    }
                }
            }
        }
        return isConsume;
    }
    // 是否在觸摸區(qū)域內(nèi)
    private boolean isTouchView(int touchX, int touchY, View view) {
        Rect rect = new Rect();
        view.getGlobalVisibleRect(rect);
        return rect.contains(touchX, touchY);
    }

感興趣的小伙伴的可以以第一種方式來解決滑動沖突。文中涉及的知識點都是個人的看法,如果你覺得有什么地方不妥,歡迎指出?每個人在開發(fā)當(dāng)中的場景可能都不一樣,有時候需要根據(jù)特定的規(guī)則去處理滑動沖突,但是處理沖突的基本原理和方式是相同的,希望本篇文章對大家有所幫助,想了解更多炫酷控件,別忘了關(guān)注小編。

結(jié)語

源碼小編整理后會上傳到 MeiWidgetView ,同時非常希望各位小伙伴能夠動手點顆 star ,你的鼓勵與支持才是讓小編繼續(xù)創(chuàng)作的源泉。

最后編輯于
?著作權(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)容 抽屜菜單 ListView WebView SwitchButton 按鈕 點贊按鈕 進(jìn)度條 TabLayo...
    小狼W閱讀 1,666評論 0 10
  • 原文鏈接:https://github.com/opendigg/awesome-github-android-u...
    IM魂影閱讀 33,143評論 6 472
  • 6:30就起來了。陪老婆到醫(yī)院看急性腸胃炎。7:40掛上號,在那等。我問她還燒不燒,她說不燒。還吐不吐,她說不吐。...
    大泥沙閱讀 592評論 1 5
  • 開發(fā)一個項目,版本控制工具是必不可少的,常見的有SVN和git等,本人傾向于用git工具。這就需要一個遠(yuǎn)程倉庫,常...
    CarlosLynn閱讀 8,478評論 3 15
  • 輕風(fēng)吹不停,湖邊還是熱。 立秋三伏在,乘涼無去所。 〖《中華新韻》二波〗
    木貞ma閱讀 150評論 0 9

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