Android自制滑动删除Activity组件

今天在做项目的时候,看到iOS端的实现效果是二级页面可以手指在界面上向右滑动关闭页面,而不再是右上角的返回按钮(说实在的,不能单手操作是Android的痛).
于是思考Android上如何实现此功能.

为避免重复造轮子,首先肯定是google,百度了.

很好,网上的各种方案都是在Activity中监听手势,然后实现伪滑动关闭页面.这个方案不是我想要的.

还有一种方案,编写一个可监听滑动的布局,然后在每个想要滑动的activity中作为根布局.这种方案,作者只是提出了思路,并没有完整的思路,但却给了我启发.

思路如下:
1. 首先,Activity是无法控制移动的,但是他其中的View却可以,我们只需要控制View整体跟随手指进行移动即可.
2. 基于第一条,我们需要获取到Activity的根View, 然后监听View的touchEvent.根据手指移动距离实现View中的内容平滑滚动.
3. 基于第2条,如果界面中没有ListView,ScrollView等能够截获事件焦点的控件的话,一切正常. 一旦出现这些控件,我们的滑动将被提前拦截,导致无法实现滑动关闭页面.
4. 于是,我们必须要对事件进行提前拦截,即在View的interceptTouchEvent中作拦截.但显然,对于我们直接获取到的View是无法重写它的intercept逻辑的.因此我们必须重新实现一个能够拦截事件的Layout.
5. 假如此Layout可以正常工作了,能够实现监听手指滑动,也可以提前抢占焦点了,但如何让这个Layout嵌入到我们的Activity中去呢?
6. 一种方案显然可以在xml中将此Layout作为根布局来使用.但总觉得与现有系统耦合的太紧,不具有热插拔能力(就是通过添加/注释一行代码即可实现的能力).
7. 我的目标是要能够通过一行代码解决此问题!
8. 如何既不在xml中加入定制的Layou又能用一行代码解决问题呢?
9. 我起初的思路是,改造Activity,实现一个拥有此功能的抽象类.然后 希望滑动关闭的Activity都来继承此类就可以了.
10. 很好,通过上边代码,我只需要更改现有设计的继承类即可实现目标.
11. 但是很快问题出现了,系统中有的界面本身继承的是FragmentActivity,有的继承自AppCompatActivity,等等.直接改变继承关系导致原有设计功能上的缺失和改变!
12. 不用继承的话,我又该如何作呢?灵光一现,是否可以通过类化:FlipHelper.inject(activity)来达到同样的目的呢,说干就干. 通过在一个静态类FlipHelper中注入界面activity.然后获取到Activity的根View…最后实现目标.
13. 整个过程的实现,变的非常独立,不依赖于任何其他功能.甚至最终我将所有的实现代码写在了一个类中(FlipHelper).也只有区区200行(含注释).
完整的演示代码:https://github.com/andrewlu1/CustomView/
现贴出来,共同分析实现过程:

package cn.andrewlu.app.customview;

import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.Shader;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.FrameLayout;

/**
 * Created by andrewlu on 16-1-15.
 * 通过向activity的结构中注入一层能够拦截事件的View布局,从而可以让内容页跟着手指移动.
 */
public final class FlipHelper {
    public static void inject(Activity activity) {
        ViewGroup root = (ViewGroup) activity.getWindow().findViewById(Window.ID_ANDROID_CONTENT);
        if (root == null || root.getChildAt(0) == null) {
            throw new RuntimeException("inject method must called after setContentView");
        }

        //向其中注入一层布局.
        HDraggableLayout container = new HDraggableLayout(activity);
        // container.setBackgroundColor(Color.argb(100, 0, 0, 0));
        container.setContentDescription("the injected view.");
        View child = root.getChildAt(0);
        child.setBackgroundColor(Color.WHITE);
        ViewGroup.LayoutParams p = child.getLayoutParams(); //new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        root.addView(container, p);
        root.removeView(child);
        container.addView(child);
    }
}


/**
 * Created by andrewlu on 16-1-15.
 * 可以在水平方向拖动的水平滚动布局.表现效果为:当手指向右滑动时,内容会根据手指位置向右一起滑动.
 * 以后可能会扩展向左滑动功能.现在不作讨论.
 * 只能通过new创建出来.因此只实现一个构造函数.
 */
class HDraggableLayout extends FrameLayout {
    private SimpleGestureListener onGestureListener = new SimpleGestureListener();

    public HDraggableLayout(Context context) {
        super(context);
        View shadowView = new ShadowView(context);
        LayoutParams p = new LayoutParams(20, ViewGroup.LayoutParams.MATCH_PARENT);
        p.gravity = Gravity.LEFT | Gravity.TOP;
        p.leftMargin = -20;
        addView(shadowView, p);
    }

    //实现定向/定量的滚动事件拦截.
    private PointF mTouchPoint = new PointF();
    private PointF mTouchPointDist = new PointF();
    private final static int MIN_SLOP = 24;
    private long eventTime = 0;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mTouchPoint.x = ev.getRawX();
                mTouchPoint.y = ev.getRawY();
                eventTime = ev.getEventTime();
                onGestureListener.onDown(ev);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                mTouchPointDist.x = ev.getRawX() - mTouchPoint.x;
                mTouchPointDist.y = ev.getRawY() - mTouchPoint.y;

                //只负责起始时的拦截.拦截后,事件就完全交给touch来处理了.
                if (Math.abs(mTouchPointDist.y) > Math.abs(mTouchPointDist.x)) break;
                if (mTouchPointDist.x < MIN_SLOP) break;

                Log.i("onInterceptTouchEvent", String.format("dx:%f,dy:%f", mTouchPointDist.x, mTouchPointDist.y));
                return true;
            }
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_MOVE: {
                mTouchPointDist.x = ev.getRawX() - mTouchPoint.x;
                mTouchPointDist.y = ev.getRawY() - mTouchPoint.y;
                //负责滑动距离的计算.
                if (getScrollX() - mTouchPointDist.x > 0) {
                    mTouchPointDist.x = getScrollX();
                }
                mTouchPoint.x = ev.getRawX();
                mTouchPoint.y = ev.getRawY();
                onGestureListener.onScroll(mTouchPointDist.x, mTouchPointDist.y);
                onGestureListener.onFling(mTouchPointDist.x * 1000 / (ev.getEventTime() - eventTime), mTouchPointDist.y * 1000 / (ev.getEventTime() - eventTime));
                eventTime = ev.getEventTime();
                break;
            }
            case MotionEvent.ACTION_UP: {
                onGestureListener.onUp();
                break;
            }
        }
        return super.onTouchEvent(ev);
    }

    private ValueAnimator backAnim, finishAnim;

    //回滚
    private void scrollBack() {
        backAnim = ObjectAnimator.ofInt(this, "scrollX", getScrollX(), 0).setDuration(200);
        backAnim.start();
    }

    //滚动到结束.
    private void scrollFinish() {
        finishAnim = ObjectAnimator.ofInt(this, "scrollX", getScrollX(), -getWidth()).setDuration(200);
        finishAnim.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {
                Context c = getContext();
                if (c instanceof Activity) {
                    ((Activity) c).onBackPressed();
                }
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
        finishAnim.start();
    }

    private class SimpleGestureListener {
        public boolean onDown(MotionEvent ev) {

            return false;
        }

        public boolean onScroll(float distanceX, float distanceY) {
            scrollBy(-(int) distanceX, 0);
            return true;
        }

        public boolean onFling(float velocityX, float velocityY) {
            Log.i("onFling", String.format("vX:%f,vY:%f", velocityX, velocityY));
            return false;
        }

        public void onUp() {
            Log.i("onUp", "=====================");
            if (Math.abs(getScrollX()) < getWidth() / 2) {
                scrollBack();
            } else {
                scrollFinish();
            }
        }
    }
}

//实现一个带阴影的控件.阴影呈现在左边.
class ShadowView extends View {
    private Paint shadowPaint = new Paint();

    public ShadowView(Context context) {
        super(context);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        Shader mShader = new LinearGradient(getWidth(), 0, 0, 0,
                new int[]{Color.GRAY, Color.TRANSPARENT}, null, Shader.TileMode.CLAMP);
        shadowPaint.setShader(mShader);
        canvas.drawRect(0, 0, getWidth(), getHeight(), shadowPaint);
    }
}

下面帖出完整的代码解释:
1. 核心函数:Fliphelper.inject(Activity)实现了向Activity的View根层级注入一层自定义的Layout. 这样可以省去在Xml中修改根布局的问题.
具体的做法,就是通过

 ViewGroup root = (ViewGroup) activity.getWindow().findViewById(Window.ID_ANDROID_CONTENT);

可以得到包含Activity中的界面的根布局.注意,这里是不是xml文件中的那个根布局,而是嵌套在xml外层的一个根布局,通常是一个FrameLayout.
而root.getChildAt(0)就能够得到activity的实际布局内容了.也就是xml所实例化出来的View.
接下来我们需要构造我们自定义的HDraggableLayout对象,并将它添加到root中去.同时,将root.getChildAt(0)从原有布局即root中移除,并添加到我们自定义的布局对象中去.

这样我们的View层级中多出了一个可以拦截事件的布局(至少我们期望的是它能够在适当的时候拦截我们的触摸事件,而不是拦截所有,否则界面中的所有控件都无法响应滑动事件了)

接着我们来看这一层能够拦截事件的Layout如何实现:
很简单,我们在HDraggableLayout的 onInterceptTouchEvent()函数中,拦截MOVE事件,当满足一定的条件的时候,就返回true,表示我们要拦截这个事件,不让这个事件再向下传递了.
那么这个一定的条件是什么呢?

mTouchPointDist.x = ev.getRawX() - mTouchPoint.x;
mTouchPointDist.y = ev.getRawY() - mTouchPoint.y;
//只负责起始时的拦截.拦截后,事件就完全交给touch来处理了.
if (Math.abs(mTouchPointDist.y) > Math.abs(mTouchPointDist.x)) break;
if (mTouchPointDist.x < MIN_SLOP) break;
return true;

代码中我们可以看到,采取放行策略,即哪些条件可以放行此事件,反之其他的条件都要拦截.那么哪些条件可以放行呢? 当垂直方向的移动距离大于水平方向时,此时我们可能期望的是界面中的ListView的上下滚动,而不是界面随手指移动,因此放行. 第二条件,当水平移动距离小于一个最少距离(24DP)时,也放行,为什么呢?因为手指触摸到屏幕时,由于触摸面积的问题,微小的动作都为引起此MOVE事件,如果因为手指轻轻碰一下屏幕就产生位移,会有太灵敏的感觉,体验绝对不好.因此不拦截.

一旦拦截到事件后,剩下的MOVE事件就会全部发给自身的onTouchEvent().这样在onTouch中让手指跟随界面移动.即可.于是代码如下:

   @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_MOVE: {
                mTouchPointDist.x = ev.getRawX() - mTouchPoint.x;
                mTouchPointDist.y = ev.getRawY() - mTouchPoint.y;
                //负责滑动距离的计算.
                if (getScrollX() - mTouchPointDist.x > 0) {
                    mTouchPointDist.x = getScrollX();
                }
                mTouchPoint.x = ev.getRawX();
                mTouchPoint.y = ev.getRawY();
                onGestureListener.onScroll(mTouchPointDist.x, mTouchPointDist.y);
                onGestureListener.onFling(mTouchPointDist.x * 1000 / (ev.getEventTime() - eventTime), mTouchPointDist.y * 1000 / (ev.getEventTime() - eventTime));
                eventTime = ev.getEventTime();
                break;
            }
            case MotionEvent.ACTION_UP: {
                onGestureListener.onUp();
                break;
            }
        }
        return super.onTouchEvent(ev);
    }

同样是检测MOVE事件,只不过这里不是做拦截,而是计算滑动距离,然后让界面跟着手指左右移动.当然要做好边界检测,控制界面只能向左滑到0,向右滑到getWidth()

最后在松开手指的时候,检测滑动的距离是否超过半屏,从而用属性动画的方式,控制界面是退回原位0还是继续向右滑动最后消失.当界面消失时,我们调用了


            @Override
            public void onAnimationEnd(Animator animation) {
                Context c = getContext();
                if (c instanceof Activity) {
                    ((Activity) c).onBackPressed();
                }
            }

Activity.onBackPressed().默认的实现就是finish界面.如果你重写了activity的onBackPressed().那么一定记得带上finish().否则此组件无法实现关闭界面的效果,只是将界面移出了屏幕.而没有destroy()界面.
当然你也可以修改此组件,让这个关闭变成一个回调的过程,这样你就可以在回调中实现关闭动作,而不必依赖Activity的onBackPressed()功能.

还有最后一个小的技巧,我们将界面滑离原来的位置时,发现左边边界与底部颜色融合到一起了,没有层次分明的效果,于是想到要在左边边缘加上阴影效果,很可惜的是,android的View没有阴影的属性,只能自行脑补一个阴影实现.我的思路如下:
在HDraggableLayout的左边加入一个带阴影效果的自定义View.让它刚好偏离出屏幕,当我们滑动的时候,就可以把这个阴影拖出来了.
阴影View的实现如下:

//实现一个带阴影的控件.阴影呈现在左边.
class ShadowView extends View {
    private Paint shadowPaint = new Paint();
    public ShadowView(Context context) {
        super(context);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        Shader mShader = new LinearGradient(getWidth(), 0, 0, 0,
                new int[]{Color.GRAY, Color.TRANSPARENT}, null, Shader.TileMode.CLAMP);
        shadowPaint.setShader(mShader);
        canvas.drawRect(0, 0, getWidth(), getHeight(), shadowPaint);
    }
}

将阴影加入Layout的代码如下:

    public HDraggableLayout(Context context) {
        super(context);
        View shadowView = new ShadowView(context);
        LayoutParams p = new LayoutParams(20, ViewGroup.LayoutParams.MATCH_PARENT);
        p.gravity = Gravity.LEFT | Gravity.TOP;
        p.leftMargin = -20;
        addView(shadowView, p);
    }

有兴趣可以改造此组件,可以增加改变阴影颜色,阴影宽度等方法.

一切就这么简单.一个能够将你的Activity变成可滑动删除的组件就写好了.
实际测试的时候发现,Activity的默认背景颜色为黑色,而不是透明的.想在代码中直接给Activity设置一个透明的样式无论如何都没有起效果.于是采用了最笨的办法,在Manifest.xml中直接给Activity设置一个背景透明的Theme样式.样式如下:

    <style name="myTransparent" parent="AppBaseTheme">
        <item name="android:windowBackground">@android:color/transparent</item>
        <item name="android:windowIsTranslucent">true</item>
        <item name="android:windowAnimationStyle">@android:style/Animation.Translucent</item>
    </style>

大功告成!看看效果吧.
这里写图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值