交互控件淺解析,安卓View帶入門

博主是愛奇藝員工,以上幾個都是從愛 奇藝泡泡客戶端中截取的。

本文中一共舉出了四個栗子:內容由簡到難,但是分析方法和基本原理都是相似的。
本文四個控件的代碼都是筆者自己手寫的。希望可以給自己留下些筆記,也給后來者一些啟發。

一. 下拉回彈控件 + 收起

device-2017-11-25-120023.mp4_1511582458.gif

功能點分析

  • 下拉手勢判定 + View位移
  • 松手之后 + View位移

View位移推薦使用translationY, 建議在做位移操作時不要直接調用View.setTranslationY()
而是應該封裝一個統一的方法

 public float getCurrentOffset(){
        return getTranslationY();
    }


    public void setOffset(float targetScrollX){
        //標準坐標軸 右下為正
        //進行左右平移時,需要保證平移的scrollX 范圍是 0 - mRefreshView.width()
        targetScrollX = checkOffsetX(targetScrollX);

//        scrollTo(0,(int)targetScrollX);
        setTranslationY(targetScrollX);
    }

    private float checkOffsetX(float target) {
        if(target > getMaxOffset() /*|| Math.abs(target - getMaxOffset()) < 10*/){
            target = getMaxOffset();
        }else if(target < 0){
            target = 0;
        }
        return target;
    }

這樣的好處是:如果希望修改一種位移方式(例如使用ScrollTo)時,所做的修改量很小。

核心的事件處理部分:


/*相關變量*/


    private float mTouchSlop;//最小位移
    /*上一次的點擊位置*/
    private float mXDown;
    private float mYDown;

  
    private float mYLastMove;//上一次move事件的Y坐標
    private float mYMove;


  @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if(mHandler!=null && mHandler.shouldForbidden() || ev.getPointerCount() > 1){
            return super.onInterceptTouchEvent(ev);
        }

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mXDown = ev.getRawX();
                mYDown = ev.getRawY();
                mYLastMove = mYDown;
                break;

            case MotionEvent.ACTION_MOVE:
                mXMove = ev.getRawX();
                mYMove = ev.getRawY();
                float diffX = (mXMove - mXDown);
                float diffY = (mYMove - mYDown);

                if(Math.abs(diffY) < mTouchSlop || Math.abs(diffY * 0.5) < Math.abs(diffX)){// 過濾掉水平方向的手勢
                    break;
                }

                mYLastMove = mYMove;
                return true;
        }
        return super.onInterceptTouchEvent(ev);
    }

    public float getMaxOffset(){
        return mTargetView.getHeight();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mYMove = event.getRawY();

                float deltaY =  1.2f * (mYMove - mYLastMove);//正規坐標軸下的偏移
                setOffset(getCurrentOffset() + deltaY);
                mYLastMove = mYMove;
                break;
            case MotionEvent.ACTION_UP:
                // 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
                onRelease();
                break;
        }
        return true;
    }

    public float getCurrentOffset(){
        return getTranslationY();
    }

整體思路還是按照View的動作攔截機制完成的。
在onInterceptTouchEvent進行動作判別、攔截。
在onTouchEvnet中完成偏移量計算、View的位移、以及回彈動畫的播放。

回彈動畫

  public void onRelease(){
        final boolean hasGotPoint = Math.abs(getCurrentOffset()) >= mTriggerPoint;
        mAnimator = ValueAnimator.ofFloat(getCurrentOffset(), hasGotPoint? getMaxOffset() : 0).setDuration(ANIMATOR_DURATION);
        mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float animatedValue = (float)animation.getAnimatedValue();
                setOffset(animatedValue);
            }
        });
        mAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {
                    //todo 進入詳情頁
                    if(mListener!=null && hasGotPoint) {
                        mListener.onTriggered();
                    }
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
        mAnimator.start();
    }

二. 視頻縮放 + View動畫

device-2017-11-25-120156.mp4_1511582541.gif

這個效果看起來稍微復雜,但是基本實現思路是類似的
1.找到合適的動作觸發時機
2.對View進行操作

除此之外還有幾個點需要注意:

1.從上圖可以看到視頻的主要形態有三種,100%,80%以及隱藏。狀態的跳轉需要記錄。
由于這個view的動畫基本上是只要觸發就會進行下去的。

  1. 內部還有個ListView。需要處理好和ListView的沖突。

3.另外,由于動作幾乎是立即觸發并且不可逆的(施加動作之后就會執行形變)
所以,我們只在onInterceptTouchEvnet中就可以完成主要邏輯了。

   @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (listView == null || videoLayout == null) {//子控件還未初始化
            return super.onInterceptTouchEvent(ev);
        }
        if (!enable) {//禁用開關
            return super.onInterceptTouchEvent(ev);
        }

        //操作區域在listView以上,即視頻區域內
        int y = (int) ev.getRawY();
        int x = (int) ev.getRawX();

        int[] location = new int[2];
        listView.getLocationOnScreen(location);
        if (y < location[1]) {
            return super.onInterceptTouchEvent(ev);
        }

        if (isAnimationPlaying) {
            return true;//動畫播放期間禁止操作
        }

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 發生down事件時,記錄y坐標
                mLastMotionY = y;
                mLastMotionX = x;
                break;

            case MotionEvent.ACTION_MOVE:
                deltaY = y - mLastMotionY;
                if (Math.abs(deltaY) < 20) {
                    break;
                }
                if (!isVideoStop() && isListViewTopping()) {
                    //非暫停態
                    if (deltaY < 0 && videoState == VIDEO_LAYOUT_NORMAL_SIZE) {
                        zoomInVideoLayout(VIDEO_LAYOUT_HALF_SIZE);
                        return true;
                    } else if (deltaY > 0 && videoState != VIDEO_LAYOUT_NORMAL_SIZE) {
                        zoomInVideoLayout(VIDEO_LAYOUT_NORMAL_SIZE);
                        return true;
                    }
                }

                if (isVideoStop()) {
                    if (deltaY < 0 && videoState != VIDEO_LAYOUT_ZERO_SIZE) {
                        zoomInVideoLayout(VIDEO_LAYOUT_ZERO_SIZE);
                        return true;
                    } else if (deltaY > 0 && isListViewTopping()) {
                        if (videoState == VIDEO_LAYOUT_ZERO_SIZE) {
                            zoomInVideoLayout(VIDEO_LAYOUT_NORMAL_SIZE);
                            return true;
                        }
                    }
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

三. 左拉刷新

從原理上來講,這個控件其實和常見的下拉刷新控件是一樣的。只是方向變為了向左滑動。

device-2017-11-25-224157.mp4_1511620973.gif

完全從零做起的,實現一個這個小控件也是挺有意思的。

主要思路是,在視覺區域以外的地方添加一個新View(indicate 刷新狀態)
主要動作是對整個View做位移動畫。

  @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        mTargetView.layout(l,t,r,b);//在此栗子中是圖片
        mRefreshView.layout(r,t,r + mRefreshView.getMeasuredWidth(),b);//左拉提示,旋轉指示等
    }

而動作判別又是我們熟悉的那一套代碼啦

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if(mHandler!=null && mHandler.shouldForbidden()){
            return super.onInterceptTouchEvent(ev);
        }

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mXDown = ev.getRawX();
                mYDown = ev.getRawY();
                mXLastMove = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mXMove = ev.getRawX();
                mYMove = ev.getRawY();
                float diffX = (mXMove - mXDown);
                float diffY = (mYMove - mYDown);

                if(Math.abs(diffX * 0.5) < Math.abs(diffY)){
                    break;
                }

                mXLastMove = mXMove;
                // 當手指拖動值大于TouchSlop值時,認為應該進行滾動,攔截子控件的事件
                //向左滑動
                if (diffX < 0  && Math.abs(diffX) > mTouchSlop && !canTargetScrollLeft()) {
                    return true;
                }else if(diffX > 0 && Math.abs(diffX) > mTouchSlop && isRefreshViewDisplayed()){
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }



    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mXMove = event.getRawX();

                float diffX =  1.6f * (mXMove - mXLastMove);//正規坐標軸下的偏移
                diffX = diffX * (1.2f - (getCurrentOffset()/getMaxOffset()));//阻尼修正

                float target = checkOffsetX(getCurrentOffset()- diffX);


                if(getMaxOffset() * mPercentFactor < target){
                    mRefreshView.setExplodeState(true);//爆炸特效 + 提示轉換
                }else{
                    mRefreshView.setExplodeState(false);
                }

                setOffset(target);
                mXLastMove = mXMove;
                break;
            case MotionEvent.ACTION_UP:
                // 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
                //todo 進入詳情頁
                if(mListener!=null && mRefreshView.isHasExploded()) {
                    mListener.onTriggered();
                }
                postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        onRelease();
                    }
                }, mRefreshView.isHasExploded() ? 500 :0);
                break;
        }
        return super.onTouchEvent(event);
    }

主要動作核心代碼:

    public void setOffset(float targetScrollX){
        //標準坐標軸 右下為正
        //進行左右平移時,需要保證平移的scrollX 范圍是 0 - mRefreshView.width()
        targetScrollX = checkOffsetX(targetScrollX);
        float percent = (targetScrollX / getMaxOffset())/ mPercentFactor;
        percent = Math.min(percent,1);
        mRefreshView.updatePullPercent(percent);
        scrollTo((int)targetScrollX,0);
    }

    private float checkOffsetX(float targetScrollX) {
        if(targetScrollX > mRefreshView.getWidth() /*|| Math.abs(targetScrollX - getMaxOffset()) < 10*/){
            targetScrollX = mRefreshView.getWidth();
        }else if(targetScrollX < 0){
            targetScrollX = 0;
        }
        return targetScrollX;
    }

被刷新的View被抽象出來作為mRefreshView,相對比較簡單,只要實現了

void  updatePullPercent(float percent);
void setExploedState(boolean explored); 

這里除了問題提示之外,還有一個
旋轉的箭頭以及漸變的綠色背景。

箭頭是現成的UI圖,綠色背景稍微麻煩一些,需要使用顏色漸變來完成。

下面的RotateArrowView 實現了這個功能,順便將箭頭也add了進來。

//只包括了這個類的核心代碼
public class RotateArrowView extends FrameLayout {


    private ArgbEvaluator argbEvaluator = new ArgbEvaluator();

    ...

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int x = getMeasuredWidth()/2;
        int y = getMeasuredHeight()/2;
        int radius = getWidth()/2;
        canvas.drawCircle(x,y,radius,mPaint);
    }

    public void updatePercent(float percent){
        int evaluateColor = (int)argbEvaluator.evaluate(percent, startColor, endColor);
        mPaint.setColor(evaluateColor);
        arrow.setRotation(180* percent);//箭頭的角度需要旋轉
        postInvalidate();
    }
}

ArgbEvaluator 是谷歌提供的一個方便的顏色漸變計算器。

之前對ViewGroup在直覺上有個誤解,就是復寫父view的onDraw要考慮和子View z-index上的層級關系。
實際上ViewGroup的onDraw復寫之后,并不會影響到其子View(只是默默地在最后面畫了一個背景)。

其實思考一下也是,父View以及子View的z-index層級關系是在layout時就已經確定好的。如果需要在onDraw再去費心考慮,對于api使用者而言是一個災難。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372

推薦閱讀更多精彩內容

  • 評《百鳥朝鳳》 有句老話叫:一生最多真傳兩人。說的武術界,為了本門派的名譽、地位,將真本事教給最親信的徒弟以保門...
    三阿木閱讀 352評論 0 0
  • 學而時習之,不亦說乎?——孔丘《論語?學而》 單例模式的核心在于:** 確保一個實例,并提供全局訪問。 ** 首先...
    編碼的哲哲閱讀 956評論 4 9
  • 自姑娘出生到現在,大大小小的家庭party搞了上十次。 7.2號,在貝好友的邀約下搞了一次音樂美食party。好友...
    JC賈閱讀 163評論 0 1