Android滑动冲突解决方法

来源:互联网 发布:淘宝需要点确认收货吗 编辑:程序博客网 时间:2024/05/18 01:25

转自:http://www.jianshu.com/p/8bc0765dffc9

叙述

滑动冲突可以说是日常开发中比较常见的一类问题,也是比较让人头疼的一类问题,尤其是在使用第三方框架的时候,两个原本完美的控件,组合在一起之后,忽然发现整个世界都不好了。

关于滑动冲突

滑动冲突分类

滑动冲突,总的来说就是两类。

  1. 同方向滑动冲突
    比如ScrollView嵌套ListView,或者是ScrollView嵌套自己

  2. 不同方向滑动冲突
    比如ScrollView嵌套ViewPager,或者是ViewPager嵌套ScrollView,这种情况其实很典型。现在大部分应用最外层都是ViewPager+Fragment 的底部切换(比如微信)结构,这种时候,就很容易出现滑动冲突。不过ViewPager里面无论是嵌套ListView还是ScrollView,滑动冲突是没有的,毕竟是官方的东西,可能已经考虑到了这些,所以比较完善。

复杂一点的滑动冲突,基本上就是这两个冲突结合的结果。

滑动冲突解决思路

滑动冲突,就其本质来说,两个不同方向(或者是同方向)的View,其中有一个是占主导地位的,每次总是抢着去处理外界的滑动行为,这样就导致一种很别扭的用户体验,明明只是横向的滑动了一下,纵向的列表却在垂直方向发生了动作。就是说,这个占主导地位的View,每一次都身不由己的拦截了这个滑动的动作,因此,要解决滑动冲突,就是得明确告诉这个占主导地位的View,什么时候你该拦截,什么时候你不应该拦截,应该由下一层的View去处理这个滑动动作。

这里不明白的同学,可以去了解一下Android Touch事件的分发机制,这也是解决滑动冲突的核心知识。

第二种滑动冲突,解决起来是比较简单的。这里就结合例子说一下。

滑动冲突

这里,说一下背景情况。之前做下拉刷新、上拉加载更多时一直使用的是PullToRefreshView这个控件,因为很方便,不用导入三方工程。在其内部可以放置ListView,GridView及ScrollView,非常方便,用起来可谓是屡试不爽。但是直到有一天,因项目需要,在ListView顶部加了一个轮播图控件BannerView(这个可以参考之前写的一篇学习笔记)。结果发现轮播图滑动的时候,和纵向的下拉刷新组件冲突了。

如之前所说,解决滑动冲突的关键,就是明确告知接收到Touch的View,是否需要拦截此次事件。

解决方法

解决方案1,从外部拦截机制考虑

这里,相当于是PullToRefreshView嵌套了ViewPager,那么每次优先接收到Touch事件的必然是PullToRefreshView。因为正常情况下,父控件会优先接收到touch事件。这样就清楚了,看代码:

在PullToRefreshView的onInterceptTouchEvent方法中:

    @Override    public boolean onInterceptTouchEvent(MotionEvent e) {        int y = (int) e.getRawY();        int x = (int) e.getRawX();        boolean resume = false;        switch (e.getAction()) {            case MotionEvent.ACTION_DOWN:                // 发生down事件时,记录y坐标                mLastMotionY = y;                mLastMotionX = x;                resume = false;                break;            case MotionEvent.ACTION_MOVE:                // deltaY > 0 是向下运动,< 0是向上运动                int deltaY = y - mLastMotionY;                int deleaX = x - mLastMotionX;                if (Math.abs(deleaX) > Math.abs(deltaY)) {                    resume = false;                } else {                //当前正处于滑动                    if (isRefreshViewScroll(deltaY)) {                        resume = true;                    }                }                break;            case MotionEvent.ACTION_UP:            case MotionEvent.ACTION_CANCEL:                break;        }        return resume;    }

这里最关键的代码就是这行

if (Math.abs(deleaX) > Math.abs(deltaY)) {                    resume = false;                }

横向滑动距离大于纵向时,无须拦截这次滑动事件,滑动事件会传递到下一层的view,也就是这里的轮播图控件,这样横向滑动轮播图的时候,PullToRefreshView就不会有下拉的动作了。其实,就是这么简单,但前提是你必须明确了解Android Touch事件的传递机制,期间各个方法执行的顺序及意义。

ps: 关于上文中提到的isRefreshViewScroll 方法代码(这个方法其实是PullToRefreshView这个控件自带的一个方法)

/** * 是否应该到了父View,即PullToRefreshView滑动 * * @param deltaY , deltaY > 0 是向下运动,< 0是向上运动 * @return */private boolean isRefreshViewScroll(int deltaY) {        if (mHeaderState == REFRESHING || mFooterState == REFRESHING) {            return false;        }        // 对于ListView和GridView        if (mAdapterView != null) {            // 子view(ListView or GridView)滑动到最顶端            if (deltaY > 0) {                View child = mAdapterView.getChildAt(0);                if (child == null) {                    // 如果mAdapterView中没有数据,不拦截                    return false;                }                if (mAdapterView.getFirstVisiblePosition() == 0                        && child.getTop() == 0) {                    mPullState = PULL_DOWN_STATE;                    return true;                }                int top = child.getTop();                int padding = mAdapterView.getPaddingTop();                if (mAdapterView.getFirstVisiblePosition() == 0                        && Math.abs(top - padding) <= 8) {// 这里之前用3可以判断,但现在不行,还没找到原因                    mPullState = PULL_DOWN_STATE;                    return true;                }            } else if (deltaY < 0) {                View lastChild = mAdapterView.getChildAt(mAdapterView                        .getChildCount() - 1);                if (lastChild == null) {                    // 如果mAdapterView中没有数据,不拦截                    return false;                }                // 最后一个子view的Bottom小于父View的高度说明mAdapterView的数据没有填满父view,                // 等于父View的高度说明mAdapterView已经滑动到最后                if (lastChild.getBottom() <= getHeight()                        && mAdapterView.getLastVisiblePosition() == mAdapterView                        .getCount() - 1) {                    mPullState = PULL_UP_STATE;                    return true;                }            }        }        // 对于ScrollView        if (mScrollView != null) {            // 子scroll view滑动到最顶端            View child = mScrollView.getChildAt(0);            if (deltaY > 0 && mScrollView.getScrollY() == 0) {                mPullState = PULL_DOWN_STATE;                return true;            } else if (deltaY < 0                    && child.getMeasuredHeight() <= getHeight()                    + mScrollView.getScrollY()) {                mPullState = PULL_UP_STATE;                return true;            }        }        return false;

解决方案2,从内容逆向思维分析

有时候,我们不想去修改或者是无法修改最先接收到Touch事件的View 时,比如这里我不想去修改PullToRefreshView的代码。就必须考虑从当前从Touch传递事件中最后的那个View逆向考虑。首先,由Android中View的Touch事件传递机制,我们知道Touch事件,首先必然由最外层View接收到,并很有可能被它拦截,如果无法更改这个最外层View,那么是不是就没辙了呢?其实不然,Android这么高大上的系统必然考虑到了这个问题,好了废话不说,先看代码

    private BannerView carouselView;    private Context mContext;    private PullToRefreshView refreshView;    refreshView.setOnTouchListener(new View.OnTouchListener() {            @Override            public boolean onTouch(View v, MotionEvent event) {                carouselView.getParent().requestDisallowInterceptTouchEvent(false);                return false;            }        });        carouselView.setOnTouchListener(new View.OnTouchListener() {            @Override            public boolean onTouch(View v, MotionEvent event) {                carouselView.getParent().requestDisallowInterceptTouchEvent(true);                int x = (int) event.getRawX();                int y = (int) event.getRawY();                switch (event.getAction()) {                    case MotionEvent.ACTION_DOWN:                        lastX = x;                        lastY = y;                        break;                    case MotionEvent.ACTION_MOVE:                        int deltaY = y - lastY;                        int deltaX = x - lastX;                        if (Math.abs(deltaX) < Math.abs(deltaY)) {                            carouselView.getParent().requestDisallowInterceptTouchEvent(false);                        } else {                            carouselView.getParent().requestDisallowInterceptTouchEvent(true);                        }                    default:                        break;                }                return false;            }        });

首先说一下这个方法

public abstract void requestDisallowInterceptTouchEvent (boolean disallowIntercept)

Called when a child does not want this parent and its ancestors to intercept touch events with onInterceptTouchEvent(MotionEvent).
This parent should pass this call onto its parents. This parent must obey this request for the duration of the touch (that is, only clear the flag after this parent has received an up or a cancel.

Parameters
disallowIntercept
True if the child does not want the parent to intercept touch events.

API里的意思很明确,子View如果不希望其父View拦截Touch事件时,可调用此方法。当disallowIntercept这个参数为true时,父View将不拦截。

PS:这个方法的命名和其参数的使用逻辑,让我想到了一句很有意思的话,敌人的敌人就是朋友,真不知道Google的大神们是怎么想的,非要搞一个反逻辑。

好了,言归正传。这里拦截直接也很明确,在carouselView的onTouch方法中每次进入就设定父View不拦截此次事件,然后在MOTION_MOVE时候,根据滑动的距离判断再决定是父View是否有权利拦截Touch事件(即滑动行为)。

关键的处理逻辑就是这里:

if (Math.abs(deltaX) < Math.abs(deltaY)) {                            carouselView.getParent().requestDisallowInterceptTouchEvent(false);                        } else {                            carouselView.getParent().requestDisallowInterceptTouchEvent(true);                        }

这个结合上面对这个方法的解释,应该很好理解了,就不多做阐述了。


好了,这里可以看到,解决这种滑动冲突的方法很简单,最根本的还是得充分了解Touch事件的传递机制,只有这样,才能明白该在哪里做什么事情。
当然,横竖滑动的冲突很好理解,但同一方向的滑动冲突情况就有点复杂了,下次再说。



文/IAM四十二(简书作者)
原文链接:http://www.jianshu.com/p/8bc0765dffc9
著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。



之前的一遍学习笔记主要就Android滑动冲突中,在不同方向的滑动所造成冲突进行了了解,这种冲突很容易理解,当然也很容易解决。今天,就同方向的滑动所造成的冲突进行一下了解,这里就先以垂直方向的滑动冲突为背景,这也是日常开发中最常见的一种情况。

这里先看一张效果图


查看图文详情

由于GIF 图片大小的限制,截图效果不是很好

上图是在购物软件上常见的上拉查看图文详情,关于这中动画效果的实现,其实实现整体的效果,办法是有很多的,网上有很多相关的例子,但是对某些细节的处理不是很清晰,比如,下拉之后显示的部分(例如底部的图文详情)又是一个类ScrollView的控件(比如WebView)的话,又会产生新的问题。这里就以下拉查看图文详情为背景做一下同方向滑动冲突的分析。

整体思路

这里看下图


多个ScrollView嵌套示意图

首先,关于这张图做一些设定:

  • 黑色的框代表手机屏幕
  • 绿色的框代表一个外层的ScrollView,这个应该是实际应用中可见view的总长度
  • 两个红色的框代表嵌套在里面的两个类ScrollView控件,这里我们就暂时简称为 SUp,SDown

好了,接下来就分析一下实现整个流程的过程。

这里必须明确的一点,无论何时,SUp和SDown可见的部分始终是手机屏幕的高度。知道了这一点,我们就可以按以下步骤展开

首先,我们确保外部的ScrollView不拦截滑动事件,这样SUp必然获得此次事件,然后根据其Action_Move事件,当其为向上滑动自身滑动距离+屏幕高度=其自身高度 时,即可认为SUp滑动到了底部,此时外部ScrollView可拦截滑动事件,从而保证整个视图能够继续向上滑动,这个时候底部SDown就显示出来了。

同理,这时候不允许外部ScrollView拦截滑动事件,由SDown处理,根据其Action_move事件,当其为向下滑动,且自身可滑动距离为0时,就说明SDown已经滑动到了顶部,这时外部ScrollView又可以获得拦截滑动事件的权利,从而保证整个视图能够向上继续滑动,此时SUp再次显示,又开始新一轮循环拦截。

这样整体的一个流程就可以实现动图中的效果。好了,说完原理,还是看代码。

代码实现

SUp实现

public class UpScrollView extends ScrollView {    /**     * 屏幕高度     */    private int mScreenHeight;    /**     * 上一次的坐标     */    private float mLastY;    /**     * 当前View滑动距离     */    private int mScrollY;    /**     * 当前View内子控件高度     */    private int mChildH;    public UpScrollView(Context context) {        super(context);        init(context);    }    public UpScrollView(Context context, AttributeSet attrs) {        super(context, attrs);        init(context);    }    public UpScrollView(Context context, AttributeSet attrs, int defStyleAttr) {        super(context, attrs, defStyleAttr);        init(context);    }    private void init(Context context) {        WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);        DisplayMetrics dm = new DisplayMetrics();        wm.getDefaultDisplay().getMetrics(dm);        mScreenHeight = dm.heightPixels;    }    @Override    public boolean onTouchEvent(MotionEvent ev) {        //默认设定顶层View不拦截        getParent().getParent().requestDisallowInterceptTouchEvent(true);        switch (ev.getAction()) {            case MotionEvent.ACTION_DOWN:                mLastY = (int) ev.getY();                break;            case MotionEvent.ACTION_MOVE:                float y = ev.getY();                float deltaY = y - mLastY;                mChildH = this.getChildAt(0).getMeasuredHeight();                int translateY = mChildH - mScrollY;                //向上滑动时,如果translateY等于屏幕高度时,即表明滑动到底部,可由顶层View控制滑动                if (deltaY < 0 && translateY == mScreenHeight) {                    getParent().getParent().requestDisallowInterceptTouchEvent(false);                }                break;            default:                break;        }        return super.onTouchEvent(ev);    }    @Override    protected void onScrollChanged(int l, int t, int oldl, int oldt) {        super.onScrollChanged(l, t, oldl, oldt);        mScrollY = t;    }}

这里在ACTION_MOVE里做了减法,其实道理是一样的。

onScrollChanged 是在View类中实现,查看其API可以看到其第二个参数t解释

  • @param t Current vertical scroll origin.

即为当前View此次滑动的距离

SDown实现

public class MyWebView extends WebView {    public float oldY;    private int t;    public MyWebView(Context context) {        super(context);        init();    }    public MyWebView(Context context, AttributeSet attrs) {        super(context, attrs);        init();    }    public MyWebView(Context context, AttributeSet attrs, int defStyleAttr) {        super(context, attrs, defStyleAttr);        init();    }    private void init() {        WebSettings settings = getSettings();        settings.setJavaScriptEnabled(true);        setWebViewClient(new WebViewClient() {            @Override            public boolean shouldOverrideUrlLoading(WebView view, String url) {                view.loadUrl(url);                return true;            }        });    }    @Override    public boolean onTouchEvent(MotionEvent ev) {        getParent().getParent().requestDisallowInterceptTouchEvent(true);        switch (ev.getAction()) {            case MotionEvent.ACTION_DOWN:                oldY = ev.getY();                break;            case MotionEvent.ACTION_MOVE:                float Y = ev.getY();                float Ys = Y - oldY;                if (Ys > 0 && t == 0) {                    getParent().getParent().requestDisallowInterceptTouchEvent(false);                }                break;        }        return super.onTouchEvent(ev);    }    @Override    protected void onScrollChanged(int l, int t, int oldl, int oldt) {        this.t = t;        super.onScrollChanged(l, t, oldl, oldt);    }}

看以看到,这里底部的View并没有继承ScrollView,而是选择继承了WebView,这里只是为了方便,当然继承ScrollView也是没有问题。这里只是需要按实际情况考虑,因为底部图文详情的内容就是一个WebView加载数据。

这个类的实现,按照之前说的原理应该很好理解。

外部ScrollView

public class CustomerScrollViews extends ScrollView {    /**     * 屏幕高度     */    private int mScreenHeight;    private UpScrollView upScrollView;    private MyWebView myWebView;    private boolean init = false;    private float fator = 0.2f;    private int factorHeight;    private boolean upShow = true;    public CustomerScrollViews(Context context) {        super(context);        init(context);    }    public CustomerScrollViews(Context context, AttributeSet attrs) {        super(context, attrs);        init(context);    }    public CustomerScrollViews(Context context, AttributeSet attrs, int defStyleAttr) {        super(context, attrs, defStyleAttr);        init(context);    }    private void init(Context context) {        WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);        DisplayMetrics dm = new DisplayMetrics();        wm.getDefaultDisplay().getMetrics(dm);        mScreenHeight = dm.heightPixels;        factorHeight = (int) (mScreenHeight * fator);    }    @Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        if (!init) {            LinearLayout parentView = (LinearLayout) getChildAt(0);            //获得内部的两个子view            upScrollView = (UpScrollView) parentView.getChildAt(0);            myWebView = (MyWebView) parentView.getChildAt(2);//            //并设定其高度为屏幕高度            upScrollView.getLayoutParams().height = mScreenHeight;            myWebView.getLayoutParams().height = mScreenHeight;            init = true;        }        super.onMeasure(widthMeasureSpec, heightMeasureSpec);    }    @Override    protected void onLayout(boolean changed, int l, int t, int r, int b) {        super.onLayout(changed, l, t, r, b);        if (changed) {            scrollTo(0, 0);        }    }    @Override    public boolean onTouchEvent(MotionEvent ev) {        switch (ev.getAction()) {            case MotionEvent.ACTION_UP:                int scrollY = getScrollY();                if (upShow) {                    if (scrollY <= factorHeight) {                        smoothScrollTo(0, 0);                    } else {                        smoothScrollTo(0, mScreenHeight);                        upShow = false;                    }                } else {                    int scrollpadding = mScreenHeight - scrollY;                    if (scrollpadding >= factorHeight) {                        this.smoothScrollTo(0, 0);                        upShow = true;                    } else {                        this.smoothScrollTo(0, mScreenHeight);                    }                }                return true;        }        return super.onTouchEvent(ev);    }}

这个类的实现,就很灵活了,在onMeasure方法中初始化完内部的View之后,在OnTouch方法中就可以根据实际需求完成不同的逻辑实现,这里只是为了仿照查看图文详情的效果,对整个视图通过ScrollView的smoothScrollTo方法进行位移变化,这个逻辑很简单。

这里重点说一下一个地方:

upScrollView = (UpScrollView) parentView.getChildAt(0);myWebView = (MyWebView) parentView.getChildAt(2);

你可能会奇怪中间的child(1)去了哪里?这里还要从MainActivity的布局文件说起

dual_scrollview_activity_layout1.xml

<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent"    android:orientation="vertical">    <com.example.dreamwork.activity.superscrollview.CustomerScrollViews        android:layout_width="match_parent"        android:layout_height="match_parent">        <LinearLayout            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:orientation="vertical">            <com.example.dreamwork.activity.superscrollview.UpScrollView                android:layout_width="match_parent"                android:layout_height="wrap_content"                android:scrollbars="vertical">                <LinearLayout                    android:layout_width="match_parent"                    android:layout_height="wrap_content"                    android:orientation="vertical">                    <ImageView                        android:layout_width="match_parent"                        android:layout_height="wrap_content"                        android:scaleType="fitXY"                        android:src="@drawable/taobao" />                    <ImageView                        android:layout_width="match_parent"                        android:layout_height="wrap_content"                        android:scaleType="fitXY"                        android:src="@drawable/taobao" />                    <TextView                        android:textSize="20sp"                        android:padding="10dp"                        android:gravity="center"                        android:layout_marginTop="20dp"                        android:layout_marginBottom="60dp"                        android:text="查看图文详情"                        android:layout_width="match_parent"                        android:layout_height="wrap_content" />                </LinearLayout>            </com.example.dreamwork.activity.superscrollview.UpScrollView>            <include layout="@layout/selector_tab_items" />            <com.example.dreamwork.activity.superscrollview.MyWebView                android:id="@+id/web"                android:layout_width="match_parent"                android:layout_height="match_parent"/>        </LinearLayout>    </com.example.dreamwork.activity.superscrollview.CustomerScrollViews></LinearLayout>

整个布局文件可以看出,我们在CustomerScrollViews这个最外层的自定义ScrollView内部又放置了两个自定义的ScrollView(就如我们看到的原理图那样),只不过在这两个ScrollView类控件的中间通过layout又放置一个LinearLayout,里面的内容就是在动图中看到的那个中间的写着qq,baidu字样的用于切换WebView内容的一个View。这里就不贴代码了。

这样,你就可以理解之前的child(1)为什么被跳过了吧。

使用

public class DualScrollViewActivity1 extends Activity implements View.OnClickListener {    private MyWebView webView;    private TextView sinaTv, qqTv, baiduTv;    private View line1, line2, line3;    private final String BAIDU = "http://www.baidu.com";    private final String QQ = "http://www.qq.com";    private final String SINA = "http://sina.cn";    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        requestWindowFeature(Window.FEATURE_NO_TITLE);        InitView();        sinaTv.performClick();    }    private void InitView() {        setContentView(R.layout.dual_scrollview_activity_layout1);        webView = V.f(this, R.id.web);        sinaTv = V.f(this, R.id.one);        sinaTv.setOnClickListener(this);        qqTv = V.f(this, R.id.two);        qqTv.setOnClickListener(this);        baiduTv = V.f(this, R.id.three);        baiduTv.setOnClickListener(this);        line1 = V.f(this, R.id.line1);        line2 = V.f(this, R.id.line2);        line3 = V.f(this, R.id.line3);    }    @Override    public void onClick(View v) {        reset();        String url = "";        switch (v.getId()) {            case R.id.one:                line1.setVisibility(View.VISIBLE);                url = SINA;                break;            case R.id.two:                line2.setVisibility(View.VISIBLE);                url = QQ;                break;            case R.id.three:                line3.setVisibility(View.VISIBLE);                url = BAIDU;                break;            default:                break;        }        webView.loadUrl(url);    }    private void reset(){        line1.setVisibility(View.GONE);        line2.setVisibility(View.GONE);        line3.setVisibility(View.GONE);    }}

关于底部View内容更新,WebView 通过加载不同URL实现不同视图效果,只是作为Demo测试,实际中应考虑通过fragment切换实现。

这里对滑动冲突的解决方法,是由内而外的展开,默认使顶层View失去拦截能力,在由底部View的滑动距离,做出不同逻辑判断控制了顶层的拦截与否;这也是比较容易理解和实现的思路。当然,对于此类滑动冲突,有很多不同思路,这里只是列举其一。

实际开发开发中,这种带有同方向滑动特性的控件嵌套时,产生的问题不只是滑动冲突,有时候会有内容显示不全或不显示的情况。

最常见如ScrollView嵌套ListView,这种情况只需自定义ListView使其高度计算为一个很大的值,某种意义上让其失去了滑动的特性,但是这样也让ListView貌似失去了视图回收机制,这种时候如果加载很多很多很多图片,效果就会很不理想。对于这种情况,通过对ListView添加headView及footView也是一种解决的办法,但也得实际UI情况允许。

ScrollView嵌套RecyclerView时稍微麻烦一点,需要自定义ScrollView,还需要自定义实现LinearLayoutManager。

由此可见,在Android中这种带有类似特性的控件嵌套后,问题还是很多的。对此,Google出了一个叫做NestedScrolling的东西,接下来准备研究一下,看看其在解决滑动冲突方面的优势。



文/IAM四十二(简书作者)
原文链接:http://www.jianshu.com/p/3f5cb15c70f5
著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。


0 0
原创粉丝点击