交互控件浅解析,安卓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阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 评《百鸟朝凤》 有句老话叫:一生最多真传两人。说的武术界,为了本门派的名誉、地位,将真本事教给最亲信的徒弟以保门...
    三阿木阅读 333评论 0 0
  • 学而时习之,不亦说乎?——孔丘《论语•学而》 单例模式的核心在于:** 确保一个实例,并提供全局访问。 ** 首先...
    编码的哲哲阅读 943评论 4 9
  • 自姑娘出生到现在,大大小小的家庭party搞了上十次。 7.2号,在贝好友的邀约下搞了一次音乐美食party。好友...
    JC贾阅读 148评论 0 1