Osheep

时光不回头,当下最重要。

高仿Pinterest交互实现

Pinterest中有一个非常棒的交互,大概的操作就是在列表页可以通过长按单个Item来讲Item选中并且让周围的item变透明白,然后弹出选项菜单,可以通过手指的移动来选择菜单,我们来看个图,对比下简物中的实现以及Pinterest的效果

《高仿Pinterest交互实现》

高仿Pinterest交互

右边是Pinterest,左边是Jianwoo,图标的红色是我app的主题红,和Pinterest的是有一点不一样,其它的仿制程度和Pinterest没有多少差异,下面放一个Jianwoo中实现后的交互动画图

《高仿Pinterest交互实现》

简物高仿Pinterest交互

这种交互效果是不是很棒!下面我们来讨论一下具体实现思路,会遇到什么问题,要怎么解决,
做事,思路最重要!

分析

要实现的效果有哪几个?

  • 1、长按item后,要弹出菜单
  • 2、弹出菜单后要可以通过触摸屏幕选择菜单
  • 3、菜单到了选择区域,图标要有变大的动画并且显示当前菜单的标题文字
  • 4、除了选中区域外,均要变成白色
  • 5、长按后Title栏和底部Tab栏有退出动画,松手后有回来的动画

既然已经确定了问题,那我们就来解决问题

长按Item后弹出菜单

问题:长按后弹出菜单,菜单出现在哪儿?(菜单所在的容器控件应该是什么)是当前的Fragment/Activity上吗?
这是第一个问题,要弹出菜单,菜单不能凭空出现,首先要确定承载它的容器,用当前Activity或者Fragment的根布局去添加,行不行?行,但应不应该这样去做,这样做好不好?答案是不好,why?
试想一下:如果弹出的菜单是在当前的的Activity/Fragment的布局之上,那就意味着你Activity/Fragment的布局就得为这个交互做适应,LineaLayout肯定就不能作为Activity/Fragment的根布局了,而且最重要的是:一旦你决定用Activity/Fragment的布局去添加这些View元素,那就意味着你这个交互就和Activity/Fragment绑定在一起了,要复用将会非常麻烦!这是绝对不应该采取的方案,开始错了,后面将会错一大片
那我们应该用什么,我最开始想到的是WindowManager,为什么?因为WindowManager不需要Activity作为宿主,自由方便,通过合理的设置WindowManager.LayoutParams.type还可以使弹出的悬浮窗不需要权限,这很美好,但是后面遇到了一个世纪性的bug:小米机型上如果用户设置了不允许悬浮窗弹出,那将意味着WindowManager无法使用,这种情况下连Toast也无法使用了!这不得不说是一个产商为了用户而做的一个半阉割,于是我实现了Dialog的实现方案,但是其实,不管用WIndowManger还是Dialog,其实都不影响你去实现主要功能,因为WindowManager/Dialog只需要你提供一个View给它,以及Dialog再需要一个宿主Activity而已

既然已经确定了弹出的View所出现的容器,那我们就进行下一步问题解决,如何确定弹出菜单的位置?
这是一个长按弹出的菜单,那首先对Item view设置OnTouchListener监听,通过监听ACTION_DOWN可以获取用户按下的在屏幕中的坐标x,y
在Adapter中

    getHolder(holder).mImage.setOnTouchListener(listener);
    public TouchToSelector onTouch(View v, MotionEvent event){
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                /**
                 * 获取按下的x,y坐标
                 */
                setTouchX(event.getRawX());
                setTouchY(event.getRawY());
                setDownTime(System.currentTimeMillis());
                create(v);
                break;
            case MotionEvent.ACTION_CANCEL:
                ....
                break;
            case MotionEvent.ACTION_UP:
                ....
                break;
        }
        return this;
    }

注意获取一个Item的监听事件在屏幕中的x,y坐标是getRawX() && getRawY()
我们已经获取到了按下的坐标,那现在可以对弹出菜单的位置进行计算了,Pinterest中的图标出现策略不仅仅是在按下手指周边出现图标,并且会根据按下坐标的x,y确定图标出现的方向,也就是当手指在屏幕上边时,图标是朝下的,当手指的屏幕右边是,图标是朝左的,也就是

  • 1、将屏幕分成四个区域,通过x,y坐标确定区域,确定图标出现方向
  • 2、根据图标出现的方向,以及x,y坐标,基于每个图标之间间隔角度为45°计算出图标的中心点坐标,这里既可以用二次函数,也可以用三角函数,我这里用的是三角函数

第一步:根据按下屏幕中的x,y坐标,获取象限区域(这里和数学上的象限顺序并不一致)

    /**
     * 按下在屏幕中的x,y坐标
     * getScreenWidth() 屏幕宽度
     * getScreenHeight() 屏幕高度
     * @param x x坐标
     * @param y y坐标
     * @return
     */
    private int getQuadrant(int x, int y){
        /**
         * 第一区域
         */
        if(x < getScreenWidth() / 2 && y < getScreenHeight() / 2){
            return 1;
        }
        /**
         * 第二区域
         */
        if(x < getScreenWidth() && x > getScreenWidth() / 2 && y < getScreenHeight() / 2){
            return 2;
        }
        /**
         * 第三区域
         */
        if(x < getScreenWidth() && x > getScreenWidth() / 2 && y > getScreenHeight() / 2 && y < getScreenHeight()){
            return 3;
        }
        /**
         * 第四区域
         */
        if(x < getScreenWidth() / 2 && y > getScreenHeight() / 2 && y < getScreenHeight()){
            return 4;
        }
        return 3;
    }

获取到了象限区域后,我们可以知道图标出现的位置与按下点所形成的角度,通过角度以及确定出现的距离长度,可以计算出图标出现的x,y坐标,然后通过设置图标的LayoutParams可以让图标出现在指定的位置(以下相关代码并非在同一个类)

    /**
     * 图标位置和按下点和x轴形成三角形的 x 所在的直角边宽度
     */
    protected int mWidth;
    /**
     * 图标位置和按下点和x轴形成三角形的 y 所在的直角边高度
     */
    protected int mHeight;
    /**
     * 图标所在位置左上角x
     */
    protected int mIndexX;
    /**
     * 图标所在位置左上角y
     */
    protected int mIndexY;

    public void initParams(){
        setVisibility(View.GONE);
        mBaseDegree = 0;
        setItemId(mITouchView.getItemId());
        mNormalResId = mITouchView.getImageResNormal();
        mPressResId = mITouchView.getImageResPress();
        mTitle = mITouchView.getImageTitle();
        mIndex = mITouchView.getImageIndex();
        setImageResource(getNormalResId());
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(getIconWidth(),getIconHeight());
        /**
         * 高
         */
        mHeight = Math.abs((int)(Math.sin(getAngle(getAngle())) * getR()));
        /**
         * 宽
         */
        mWidth = Math.abs((int)(Math.cos(getAngle(getAngle())) * getR()));

        mIndexX = getTouchX() - mWidth - getIconWidth() / 2;
        mIndexY = getTouchY() - mHeight - getIconHeight() / 2 - getStatusBarHeight();
        params.setMargins(getIndexX(), getIndexY(), 0, 0);
        setLayoutParams(params);
    }

    /**
     * 根据index顺序确定以及象限区域应该出现的角度
     * getFactor() 图标与按下坐标连线之间的角度 这里是45°
     * getSingleAngle()最终返回的是根据象限计算的基本角度
     * @return
     */
    public int getAngle(){
        return getIndex() * getFactor() + getSingleAngle();
    }

    /**
     * 根据传入的角度计算真实的角度(用于Math中的三角函数计算)
     * @param angle
     * @return
     */
    public double getAngle(int angle){
        return angle * Math.PI / 180;
    }

    /**
     * 两个图标的角度
     * @return
     */
    public int getSingleAngle(){
        return getBaseDegree();
    }

    public int getBaseDegree() {
        switch (getQuadrant(getTouchX(), getTouchY())){
            case 1:
                return mBaseDegree + 135;
            case 2:
                return mBaseDegree + 225;
            case 3:
                return mBaseDegree + 0;
            case 4:
                return mBaseDegree + 45;
        }
        int degree = 180 - (1 - getQuadrant(getTouchX(), getTouchY())) * 90;
        degree = degree > 360 ? degree - 360 : degree;
        return degree + mBaseDegree;
    }

知道了图标的x,y坐标,也知道图标的角度,那现在可以开始做一个出现和消失的动画了

    public void show(){
        setVisibility(View.VISIBLE);
        invalidate();
        TranslateAnimation animation = new TranslateAnimation(getShowAndHideXY()[0], 0, getShowAndHideXY()[1], 0);
        animation.setDuration(200);
        animation.setStartOffset(TouchToSelecttorConfig.ANIMATION_START_OFFSET);
        animation.setInterpolator(new LinearOutSlowInInterpolator());
        startAnimation(animation);
    }

    public void hide(){
        TranslateAnimation animation = new TranslateAnimation(0, getShowAndHideXY()[0], 0, getShowAndHideXY()[1]);
        animation.setDuration(200);
        animation.setInterpolator(new LinearOutSlowInInterpolator());
        animation.setFillAfter(true);
        startAnimation(animation);
    }

     /**
     * getW() 图标位置和按下点和x轴形成三角形的 x 所在的直角边宽度
     * getH() 图标位置和按下点和x轴形成三角形的 y 所在的直角边高度
     * 按下去时的移动动画的fromX 和 fromY
     * @return
     */
    private int[] getShowAndHideXY(){
        int[] showAndHideXY = new int[2];
        switch (getQuadrant(getTouchX(), getTouchY())){
            case 1:
                showAndHideXY[0] = getW() * -1;
                showAndHideXY[1] = getH() * -1;
                break;
            case 2:
                showAndHideXY[0] = getW();
                showAndHideXY[1] = getH() * -1;
                break;
            case 3:
                showAndHideXY[0] = getW();
                showAndHideXY[1] = getH();
                break;
            case 4:
                showAndHideXY[0] = getW() * -1;
                showAndHideXY[1] = getH();
                break;
        }
        return showAndHideXY;
    }

出现的位置以及出现消失的动画都解决了,那下面就来解决如何在Adapter Item view设置OnTouchListener后将触摸事件扩散到整个屏幕?
我们刚刚对Adapter Item view设置了一个OnTouchListener

getHolder(holder).mImage.setOnTouchListener(listener);

但是这个监听所管控的区域仅仅是view的大小区域,而弹出的窗口可能是WindowManager也可能是Dialog,也就是和Activity不在一个View层面上,那我们要怎样让弹出的View可以接收到我们的onTouch事件呢?
我们无法将view的onTouch事件分发给WindowManager/Dialog,但我们可以把事件分发给Activity所在Content或者当前Fragment的子容器ScrollView,而弹出的View只需要触摸的x,y坐标而已,我们把分发后接收到的x,y坐标传给弹出的View不就好了,seems like a good idea

那这里就涉及到View的事件分发机制了,比如界面层次依次有如下容器和View的嵌套:Activity容器A,Fragment容器B,ScrollView容器C,Recyclerview容器D,Adapter View E,如果你了解Android的事件分发机制的话你会知道,事件分发顺序依次是从A -> B -> C -> D -> E,如果中间某个容器重写了onTouchEvent或者设置了onTouchListener并且设置返回值为true,那当前层次ViewGroup将会消费掉事件,并且不会再往下分发,当然如果你在当前容器设置onIntercepteTouchEvent返回true拦截掉事件也会阻止事件往下分发,那我们在这里其实没有必要让父容器拦截掉事件
因为本身这不是处理滑动冲突,我们只需要拿到当前View的一个操作区域更大的父容器并且给其设置一个onTouchListener监听返回值为true就可以把事件消费并且不再往下分发了,这是重点
那我们怎么做呢
1、因为我们是长按后才弹出菜单,也就是要求应该是弹出菜单后才开始给父容器设置onTouchListener,那这件事情是在Adapter view的onTouch方法中的ACTION_DOWN做的,如何处理呢?我们可以启用一个计时器,这个计时器等待的时间就是我们长按的时间,时间到了我们就开始后面的操作

    public TouchToSelector onTouch(View v, MotionEvent event){
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                /**
                 * 获取按下的x,y坐标
                 */
                setTouchX(event.getRawX());
                setTouchY(event.getRawY());
                /**
                 * 记录按下时间
                 */
                setDownTime(System.currentTimeMillis());
                create(v);
                break;
            case MotionEvent.ACTION_CANCEL:
                ...
                break;
            case MotionEvent.ACTION_UP:
                ....
                break;
        }
        return this;
    }

    private TouchToSelector create(View view){
        this.mView = view;
        this.mContext = view.getContext();
        setCanLongClick(true);
        initTimer();
        return this;
    }

    private void initTimer(){
        mTimer = new Timer();
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                if(isCanLongClick()){
                    mHandler.sendEmptyMessage(LONG_CLICK);
                }
            }
        };
        mTimer.schedule(task, TouchToSelecttorConfig.LONG_CLICK);
    }

这就是长按时间的实现方式以及流程,那我们在收到了计时器到时间的消息后,我们就应该拿到父容器并且设置onTouchListener,将事件消费并且不往下分发,handleMessage方法就不写了

   private void handleOnLongClick(){
        dispatchTouchEvent();
        initWindowManager();
        initBg();
        dispatchEvent();
        if(null != onLongClickListener){
            onLongClickListener.onLongClick(mView);
        }
    }

    private void dispatchTouchEvent(){
        getScrollView().setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                if(null != mBg){
                    mBg.onTouchEvent(event);
                }
                switch (event.getAction()){
                    case MotionEvent.ACTION_CANCEL:
                    case MotionEvent.ACTION_UP:
                        return getScrollView().onTouchEvent(event);
                }
                return true;
            }
        });
    }

    private ViewGroup getScrollView(){
        return (ViewGroup) findViewById(mScrollViewId != 0 ? mScrollViewId : android.R.id.content);
    }

注意了,我在onTouch监听里面将事件传递给了mBg,mBg是啥呢,就是我们弹出菜单的的父容器,那我们只需要在mBg里面做坐标判断,就可以知道,是否触碰到了哪个图标了,下面代码节选mBg.onTouchEvent()->ACTION_MOVE以及图标ImageView内部的方法判断,通过距离和角度的判断来响应是哪个图标被选中,选中的图标将结果通过监听返回出去

    public void handleActionMove(MotionEvent event){
        for(TouchToSelectorImageView touchToSelectorImageView:mTouchToSelectorImageViews){
            touchToSelectorImageView.handleActionMove(event);
        }
    }
    public void handleActionMove(MotionEvent event){
        if(!isInDistance(event)){
            if(mScaleToBig){
                hideTitle();
                handleItemUnSelect();
            }
        }
        if(isInDistance(event) && isInAngle(event)){
            scaleToBig();
        }else {
            scaleToSmall();
        }
    }

    private boolean isInDistance(MotionEvent event){
        int distance = (int)getTwoPointDistance((int)event.getRawX(), (int)event.getRawY(), getTouchX(), getTouchY() + getStatusBarHeight());
        return distance > getR() / 3  && distance < (getR() + getR() / 3);
    }

    /**
     * 得到按下的点和移动点所连成线与X轴的角度
     * @return
     */
    private boolean isInAngle(MotionEvent event){
        int x1 = (int)event.getRawX();
        int y1 = (int)event.getRawY();
        int degree = getTwoPointDegreeDiffTouchXY(x1, y1);
        int minDegree = getMinDegree();
        int maxDegree = getMaxDegree();
        return degree > minDegree && degree < maxDegree;
    }

现在触摸和判断是否选中图标也解决了,还剩下一个问题,那就是周边变白是如何实现的,我们再回过头来看下图

《高仿Pinterest交互实现》

高仿Pinterest交互

除了ITEM所在Layout,其它区域均为白色,怎么实现呢,其实不难

  • 1、设置弹出的View容器背景为透明
  • 2、给背景添加一个View,这个View被切割了一款区域,即“选中item的区域”
  • 3、如何切割呢,这里要用到Path,用Path来画路径,然后给View画上去,即在draw方法里面
canvas.drawPath(path, paint);

这个路径怎么画?画的路径方式有很多,最终只要把item区域隔开就行,这里我把我画路径的代码贴出来,就不描述了

float indexY = y + (dialogMode ? -BaseUtils.getStatusBarHeight() : 0);
float itemMeasureWidth = itemLayout.getMeasuredWidth();
float itemMeasureHeight = itemLayout.getMeasuredHeight();
Paint paint=new Paint();
/**
 * 去锯齿
 */
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL);
/**
 * 设置paint的颜色
 */
paint.setColor(Color.parseColor(color));
Path path = new Path();

path.moveTo(0, 0);
path.lineTo(getScreenWidth(), 0);
path.lineTo(getScreenWidth(), indexY);
path.lineTo(x, indexY);
path.lineTo(x, indexY + itemMeasureHeight);
path.lineTo(x + itemMeasureWidth, indexY + itemMeasureHeight);
path.lineTo(x + itemMeasureWidth, indexY);
path.lineTo(getScreenWidth(), indexY);
path.lineTo(getScreenWidth(), getScreenHeight());
path.lineTo(0, getScreenHeight());
path.close();
canvas.drawPath(path, paint);
super.draw(canvas);

注意indexY是在区分Dialog模式和WindowManager模式下,status_bar高度的加减问题,如果状态栏存在,那就需要减去这个高度
以上就是高仿Pinterest交互的基本实现原理和思路了

《高仿Pinterest交互实现》

简物高仿Pinterest交互
《高仿Pinterest交互实现》

简物高仿Pinterest交互
点赞