高仿Pinterest交互实现

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

高仿Pinterest交互

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

简物高仿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(PinterestSelectorImageView pinterestSelectorImageView:mPinterestSelectorImageViews){
            pinterestSelectorImageView.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交互

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

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

这个路径怎么画?画的路径方式有很多,最终只要把item区域隔开就行,不过这里有一个要点:按下的位置x,y不是layout view左上角的坐标,获取view在屏幕中的坐标,要用以下方法

    int[] location = new int[2];
    view.getLocationOnScreen(location);

画路径的代码我就不详细说了,这里我把我画路径的代码贴出来,就不描述了

    public void draw(Canvas canvas) {
        final int x = getViewLocaltionX(itemLayout);
        final int y = getViewLocaltionY(itemLayout) - getStatusBarHeight();
        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);
    }

    private int getViewLocaltionX(View v){
        return getViewLocation(v)[0];
    }

    private int getViewLocaltionY(View v){
        return getViewLocation(v)[1];
    }

    private int[] getViewLocation(View v){
        int[] location = new int[2];
        v.getLocationOnScreen(location);
        return location;
    }

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

简物高仿Pinterest交互

简物高仿Pinterest交互
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容