Android View深入学习——实现QQ滑动显示隐藏按钮ListView

学Android也有一段时间了,一直都是用开源的控件,没有自己写过自定义的控件。最近在复习View的一些知识,感觉还是上手写点代码比较实在。在写自定义View之前大概要了解以下知识
  • View的测量,布局与绘制
  • View的事件处理
  • View的滑动实现
  • 滑动冲突的解决
     只是了解点理论知识是不够的,必须亲手写个控件才有感觉。为了能够将View的这些理论知识都用上,我决定写一个能够滑动显示隐藏按键的ListView,类似于QQ中显示聊天记录的列表。我把这个功能的实现细化为以下几步
  1. 实现一个可以滑动显示隐藏按钮的布局SwipeItemView,隐藏部分包含两个按钮。
  2. 实现一个SwipeMenuListAdapter生成ListView的每一个条目,其中确定了每个条目中隐藏部分的样式。
  3. 实现一个SwipeMenuListView,能够监听隐藏按钮的点击事件。并且该ListView非隐藏部分可以由其他类型的adapter生成,如系统的ArrayAdapter等。

先看下实现好的SwipeMenuListView是如何使用的吧。
package com.example.joeyongzhu.demo;

import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.Toast;

public class MainActivity extends Activity {

    private  String[] data = {"Apple", "Banana", "Orange", "WaterMelon", "Pear","Grape", "Pineapple", "Strawberry", "Cherry",
                                    "Mango"};

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SwipeMenuListView listView = (SwipeMenuListView) findViewById(R.id.list);
        ArrayAdapter<String> adapter = new ArrayAdapter<String>(MainActivity.this, android.R.layout.simple_list_item_1, data);
        listView.setAdapter(adapter);
        listView.setOnMenuClickedListener(new onSwipeMenuClickedListener() {
            @Override
            public void onSwipeMenuClicked(int position, int type) {
                Toast.makeText(MainActivity.this, "position: " + position + " type: " + type, Toast.LENGTH_SHORT).show();
            }
        });
    }

}
     SwipeMenuListView的使用和普通的ListView是一样的,只需要构造一个Adapter,然后由该Adapter负责每个条目的显示。另外还可以设置一个监听器,当隐藏的按钮被点击时回调。
     接着我们看看SwipeMenuListView是如何实现的。

package com.example.joeyongzhu.demo;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.widget.AdapterView;
import android.widget.ListAdapter;
import android.widget.ListView;

/**
* Created by joeyongzhu on 2016/7/10.
*/

public class SwipeMenuListView extends ListView implements onSwipeMenuOpenListener{

    Context mContext;
    MyListAdapter mAdapter;
    private int lastX;
    private int lastY;
    //记录已经打开的条目的位置,-1表示没有Item被打开
    private int openItemPostion = -1;
    int mTouchSlop;

    public SwipeMenuListView(Context context){
        super(context, null);
    }

    public  SwipeMenuListView(Context context, AttributeSet attrs){
        super(context, attrs);
        mContext = context;
         //注解1
        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    }

     //注解2    
    @Override
    public void setAdapter(ListAdapter adapter){
        mAdapter = new SwipeMenuListAdapter(mContext, adapter);
        super.setAdapter(mAdapter);
        mAdapter.setOnSwipeMenuOpenListener(this);
    }

    public void setOnMenuClickedListener(onSwipeMenuClickedListener listener){
        if(listener != null)
          mAdapter.setOnMenuClickedListener(listener);
    }

     //注解3
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                lastX = (int) event.getX();
                lastY = (int) event.getY();
                int slidePosition = pointToPosition(lastX, lastY);

                // 无效的position, 不做任何处理
                if (slidePosition == AdapterView.INVALID_POSITION) {
                    return super.onInterceptTouchEvent(event);
                }
                else
                 //注解4
                //当有条目是打开的时候,点击其他条目会关闭上一个打开的条目
                if(openItemPostion != -1 && slidePosition != openItemPostion)
                    ((SwipeItemView)getChildAt(openItemPostion - getFirstVisiblePosition)).closeSwipeMenu();
                return super.onInterceptTouchEvent(event);
            }
            case MotionEvent.ACTION_MOVE: {
                int tempX = x - lastX;
                int tempY = y - lastY;
                 //注解1
                // 是否是水平滑动
                if(Math.abs(tempX) > mTouchSlop || Math.abs(tempY) > mTouchSlop) {
                    if (Math.abs(tempX) > Math.abs(tempY)) {
                        intercepted = false;
                    } else {
                        intercepted = true;
                    }
                    lastX = x;
                    lastY = y;
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                intercepted = false;    
                break;
            }
            default:
                break;
        }
        return intercepted;
    }

     //有条目打开或关闭时回调,记录打开条目的位置
    @Override
    public void onSwipeOpen(boolean isOpen, int position){
        if(isOpen)
            openItemPostion = position;
        else
            openItemPostion = -1;
    }
}
下面一一讲解代码中红字标注的各处注解。
  • 注解1:此处获得系统判断为滑动动作的最小距离,在之后判断是否是水平滑动时用到。当没有这个最小滑动距离的限制,水平滑动时会有停顿感,因为连续两个Move事件的距离很小,根据这个距离判断滑动方向不准确,导致ListView会对一些Move事件的拦截,ItemView收不到事件,会有卡顿感。
  • 注解2:重写ListView的setAdapter方法,其内部实际上构造了一个SwipeMenuListAdapter,并将用户的adapter作为参数传到构造函数中,SwipeMenuListAdapter是对外部设置adapter的封装,其内使用外部的adapter生成ItemView非隐藏部分的视图,并作为整个ItemView视图的一部分。这样将ListView中隐藏部分的显示和非隐藏部分的显示分开,用户可以修改每个List条目的隐藏样式,而不需要动调用的代码。
  • 注解3:拦截事件的逻辑,避免滑动冲突。由于ListView本身可以上下滑动,每个条目又可以左右滑动,如果不做处理会产生滑动冲突,在使用时会感到十分的卡顿并且滚动方向错乱。由于ListView本身只需要上下滑动,所以当上下滑动时拦截事件,水平滑动时则不拦截,由ItemView处理。
  • 注解4:每次只允许一个条目是打开(显示隐藏按钮)的。当Down事件时,判断当前点击的是哪个条目,如果该条目不是打开的,则关闭已经打开的条目。注意ListView在使用getViewChildAt(position)时,position的范围只能是屏幕上显示的条目数目,所以position=当前条目position-第一个显示的条目的position(即getFirstVisiblePosition的返回结果)

SwipeMenuListView中的每一项条目由SwipeMenuListAdapter生成,下面是代码。

package com.example.joeyongzhu.demo;

import android.content.Context;
import android.database.DataSetObserver;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ListAdapter;
import android.widget.WrapperListAdapter;

/**
* Created by joeyongzhu on 2016/7/10.
*/

public class SwipeMenuListAdapter implements WrapperListAdapter, onSwipeMenuOpenListener {

    public static final int MENU_TYPE_DELETE = 0;
    public static final int MENU_TYPE_FAVOUR = 1;
    private LayoutInflater mInflater;
    private ListAdapter mAdapter;
    private onSwipeMenuClickedListener mListener;
    private onSwipeMenuOpenListener mOpenListener;
    private ButtonClickedListener buttonListener;

    //注解1
    private class ButtonClickedListener implements View.OnClickListener {
        @Override
        public void onClick(View view){
            if(mListener != null) {
                int position = (int) view.getTag(R.string.tag_position);
                int type = (int) view.getTag(R.string.tag_type);
                mListener.onSwipeMenuClicked(position, type);
            }
        }
    }

    @Override
    public void onSwipeOpen(boolean isOpen, int position){
        if(mOpenListener != null)
            mOpenListener.onSwipeOpen(isOpen, position);
    }

    public  SwipeMenuListAdapter(Context context, ListAdapter adapter){
        mAdapter = adapter;
        mInflater = LayoutInflater.from(context);
        buttonListener = new ButtonClickedListener();
    }

    public void setOnMenuClickedListener(onSwipeMenuClickedListener listener){
        if(listener != null)
            mListener = listener;
    }

    public void setOnSwipeMenuOpenListener(onSwipeMenuOpenListener listener){
        if(listener != null)
            mOpenListener = listener;
    }

    @Override
    public Object getItem(int position){
        return mAdapter.getItem(position);
    }

    @Override
    public long getItemId(int position){
        return  mAdapter.getItemId(position);
    }

    @Override
    public int getCount(){
        return  mAdapter.getCount();
    }

    //注解2
    @Override
    public View getView(int position, View convertView, ViewGroup parent){
        if(convertView == null){
            //由外部设置的adapter获得隐藏部分的视图
            View contentView = mAdapter.getView(position, convertView, parent);
            //convertView为空时,构造一个SwipeMenu
            convertView = mInflater.inflate(R.layout.activity_main_my_viewgroup, parent, false);
            //将contentView放入FrameLayout中
            FrameLayout contentContainer = (FrameLayout) convertView.findViewById(R.id.content_view);
            contentContainer.addView(contentView);
            //为每个ItemView中的按钮添加监听器
            Button btn1 = (Button) convertView.findViewById(R.id.button1);
            btn1.setTag(R.string.tag_position, position);
            btn1.setTag(R.string.tag_type, MENU_TYPE_FAVOUR);
            btn1.setOnClickListener(buttonListener);
            Button btn2 = (Button) convertView.findViewById(R.id.button2);
            btn2.setTag(R.string.tag_position, position);
            btn2.setTag(R.string.tag_type, MENU_TYPE_DELETE);
            btn2.setOnClickListener(buttonListener);
            ((SwipeItemView)convertView).mPosition = position;
            ((SwipeItemView)convertView).setOnSwipeMenuOpenListener(this);
            //保存contentContaier,重用时直接取出即可
            convertView.setTag(contentContainer);
        }
        else{
            //itemView重用时,直接取出contentContainer,并替换其中的视图
            FrameLayout contentContainer = (FrameLayout) convertView.getTag();
            View contentView = mAdapter.getView(position, contentContainer.getChildAt(0), parent);
            contentContainer.removeAllViews();
            contentContainer.addView(contentView);
        }
        return convertView;
    }

    @Override
    public void registerDataSetObserver(DataSetObserver observer) {
        mAdapter.registerDataSetObserver(observer);
    }

    @Override
    public void unregisterDataSetObserver(DataSetObserver observer) {
        mAdapter.unregisterDataSetObserver(observer);
    }

    @Override
    public boolean areAllItemsEnabled() {
        return mAdapter.areAllItemsEnabled();
    }

    @Override
    public boolean isEnabled(int position) {
        return mAdapter.isEnabled(position);
    }

    @Override
    public boolean hasStableIds() {
        return mAdapter.hasStableIds();
    }

    @Override
    public int getItemViewType(int position) {
        return mAdapter.getItemViewType(position);
    }

    @Override
    public int getViewTypeCount() {
        return mAdapter.getViewTypeCount();
    }

    @Override
    public boolean isEmpty() {
        return mAdapter.isEmpty();
    }

    @Override
    public ListAdapter getWrappedAdapter(){
        return mAdapter;
    }

}
SwipeMenuListAdapter在构造时,会传入一个adapter,其中大部分函数有该adapter实现。getView中会去加载一个布局,该布局文件如下所示
<?xml version="1.0" encoding="utf-8"?>
<com.example.joeyongzhu.demo.SwipeItemView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:splitMotionEvents="false">

    <FrameLayout
        android:id="@+id/content_view"
        android:layout_height="match_parent"
        android:layout_width="match_parent" />

    <Button
        android:id="@+id/button1"
        android:layout_width="100dp"
        android:layout_height="match_parent"
        android:text="button"
        android:background="@android:color/darker_gray"/>

    <Button
        android:id="@+id/button2"
        android:layout_width="100dp"
        android:layout_height="match_parent"
        android:text="button"
        android:background="@android:color/holo_red_light"/>
</com.example.joeyongzhu.demo.SwipeItemView>
  • 注解1:由于ListView中包含很多条目,为每个条目中的Button都添加一个监听器显然没有必要,所以在adapter中定义一个监听器,该监听器回调时会获得Button中保存的position和type,在调用外部设置的监听器来处理,即通过ListView将外部监听器设置到adapter中。这样做有个困惑,adapter复用时会不会造成内存泄漏?希望有高手解答。
  • 注解2:上述布局中的FrameLayout作为每个条目中非隐藏部分视图的容器,该容器中存放外部adapter生成的View。其他部分为隐藏部分的视图。SwipeMenuListAdapter的getView会生成每个条目的SwipeItemView,并将外部adapter生成的View放到上述布局的FrameLayout中。这样做的好处,主要是将ListView中每个条目的隐藏部分和非隐藏部分视图的定义分开,这样在改动非隐藏视图的样式时,不需要改动调用方法。

     接下来只剩下SwipeItemView需要实现,该类实现了一个布局,水平滑动时会显示隐藏的两个按钮,并且具有回弹的功能。
  • 当ItemView是关闭(不显示隐藏部分)时,滑开时拖动的距离小于按钮的宽度,松开手指会自动弹回
  • 当ItemView是打开(显示隐藏部分)时,滑动关闭时拖动距离小于按钮的宽度时,松开手指自动弹回
     首先说下我的实现思想,自定义个ViewGroup,在Layout时,将隐藏的按钮放到可视区域之外,重写onTouchEvent,实现拖动和回弹效果,回弹由Scroller实现,网上有很多类似效果的实现。虽然思想很简单,但实现时却遇到很多小问题,再此一并分享。先贴上代码。

package com.example.joeyongzhu.demo;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.Scroller;
import android.widget.TextView;

/**
* Created by joeyongzhu on 2016/7/13.
*/

//注解一
public class SwipeItemView extends FrameLayout {

    private int mLastX;
    private Scroller mScroller;
    private int offScreenViewTotalWidth;
    private int scrollBackDistance;
    private onSwipeMenuOpenListener menuOpenListener;
    private boolean isMove = false;
    public int mPosition;

    public SwipeItemView(Context context){
        super(context);
        mScroller = new Scroller(context);
    }

    public SwipeItemView(Context context, AttributeSet attrs){
        super(context, attrs);
        mScroller = new Scroller(context);
    }

    public void setOnSwipeMenuOpenListener(onSwipeMenuOpenListener listener){
        if(listener != null)
            menuOpenListener = listener;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event){

        int currentX = (int) event.getX();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                //如果滑动动画没有结束,终止动画
                if(!mScroller.isFinished())
                    mScroller.abortAnimation();
                mLastX = currentX;
                isMove = false;
                return true;
             //注解2
            case MotionEvent.ACTION_MOVE:
                int dy = mLastX - currentX;
                 //注解3,条目随手指的滑动
                if(getScrollX() + dy <= 0)
                    dy = -getScrollX();
                if(getScrollX() + dy > offScreenViewTotalWidth){
                    dy = offScreenViewTotalWidth - getScrollX();
                }
                scrollBy(dy, 0);
                mLastX = currentX;
                isMove = true;
                return true;
             //注解4,回弹效果的实现
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if(getScrollX() > 0 && getScrollX() < scrollBackDistance) {
                    closeSwipeMenu();
                }
                else
                if(getScrollX() >  (offScreenViewTotalWidth - scrollBackDistance) && getScrollX() < offScreenViewTotalWidth) {
                    openSwipeMenu();
                }
                 //注解5,判断itemView是否打开或者关闭
                if(isMove && getScrollX() == 0)
                    menuOpenListener.onSwipeOpen(false, mPosition);
                if(getScrollX() == offScreenViewTotalWidth)
                    menuOpenListener.onSwipeOpen(true, mPosition);
                break;
        }
        return super.onTouchEvent(event);
    }

    //重新放置三个视图的位置
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b){
        View child1 = getChildAt(0);
        View child2 = getChildAt(1);
        View child3 = getChildAt(2);
        child1.layout(0, 0, child1.getMeasuredWidth(), child2.getMeasuredHeight());
        child2.layout(child1.getMeasuredWidth(), 0, child1.getMeasuredWidth() + child2.getMeasuredWidth(), child2.getMeasuredHeight());
        child3.layout(child1.getMeasuredWidth() + child2.getMeasuredWidth(), 0, child1.getMeasuredWidth() + child2.getMeasuredWidth() + child3.getMeasuredWidth(), child3.getMeasuredHeight());
        //判断回弹的距离为按钮的宽度
        scrollBackDistance = child2.getWidth();
        //隐藏部分视图的总宽度
        offScreenViewTotalWidth = child2.getWidth() + child3.getWidth();
    }

    //参考Sroller的用法
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
        super.computeScroll();
    }

    //滑开显示隐藏布局
    public void openSwipeMenu(){
        if(!mScroller.isFinished())
            mScroller.abortAnimation();
        mScroller.startScroll(getScrollX(), 0, offScreenViewTotalWidth - getScrollX(), 0);
        postInvalidate();
        //通知监听器条目滑开
        if(menuOpenListener != null)
            menuOpenListener.onSwipeOpen(true, mPosition);
    }

    //滑动关上隐藏布局
    public void closeSwipeMenu(){
        if(!mScroller.isFinished())
            mScroller.abortAnimation();
        mScroller.startScroll(getScrollX(), 0, -getScrollX(), 0);
        postInvalidate();
        if(menuOpenListener != null)
            menuOpenListener.onSwipeOpen(false, mPosition);
    }
}
  • 注解1:选择继承FrameLayout,实际上也可以继承ViewGroup或者LinearLayout。如果继承ViewGroup需要多实现个onMeasure方法(实际很简单,所以直接继承ViewGroup也是个不错的选择)。选择继承LinearLayout时有一点需要注意,LinearLayout和FrameLayout的measure过程实际上有区别的。LinearLayout在measure过程中会逐渐减少可用的布局空间,比如上述布局的content_view的宽度为match_parent,若该LinearLayout是水平方向的,则剩下的两个Button的宽度都为0。而FrameLayout则不会,它只会将视图重叠在一起,之前的视图会被后面的覆盖掉。所以使用FrameLayout时只需要重写onLayout方法,而LinearLayout需要重写onMeasure和onLayout方法。
  • 注解2: 接着便是重点,重写onTouchEvent方法,处理点击事件,思想很简单。Down事件时返回true,这样之后的事件才能收到,Move时让ItemView随着手指左右滑动,手指离开时需要判断回弹的方向。ItemView随着手指左右滑动时,如果已是关闭状态时,只能向左滑动,打开状态时,只能向右滑动。这边涉及两个重要的函数,getScrollX和scrollBy。getScrollX获得的是View相对于初始位置的X方向的偏移,向左偏移为正,向右偏移为负(这边让我困惑了很久,怎么和坐标体系是反的)。scrollBy为相对于上次位置的滑动距离,同样是往左为正,往右为负,由于是和坐标体系相反的,所以在计算偏移量dx是使用上次位置减去目的位置。实际上scrollBy和scrollTo都不会改变View本身在的位置(getLeft等函数的值没有变化),只是改变了可视区域的位置,这样的话,就不难理解View的移动方向和坐标体系是反的,因为可视区域向右移动时,看上去View是向做移动的,终于不再困惑了:)
  • 注解3:此处是坑,我开始的代码是这样写的
     if(dy < 0 && getScrollX() = 0)
          dy = 0;
     if(dy > 0 && getScrollX() > offScreenViewTotalWidth){
          dy = 0;
     }
        看上去没有什么问题,当起始位置向右滑动或者打开状态时向左滑动是不允许的,但实际测试时发现,如果快速左右滑动时,经常出现条目           的位置不对。会卡在比起始位置向右的位置或是打开状态向左的位置,不是有判断语句吗,为什么还会出现这种情况。通过打断点发现,当           手指移动很快时,相邻的两个Move事件的坐标可能相隔较大,之前的getScrollX满足条件,但是滑动后就会出现错误位置。所以正确的方           式是,判断下一个移动到的位置是否超过范围,如果超过范围需要对移动的距离进行修正,这样就能保证位置的正确。
  • 注解4:这边有两点需要注意,第一必须加上Cancel事件的判断,因为不仅手指松开时需要回弹,当手指滑出View的范围时也需要回弹。回弹使用Scroller,Scroller的使用网上有很多文章,不再详述,不过需要强调的是这边的坐标也是指可视区域的位置,Sroller内部也是调用scrollBy来实现的。
  • 注解5:这边不能少了,因为当条目打开或是关闭时有可能是手指拖动造成的,其中判断条件的isMove是为了防止点击操作被误判。


     以上便是滑动显示隐藏按钮ListView的全部代码。总结几处容易出错的地方:
  1. 判断水平和竖直滑动时,一定要添加touchSlop最小滑动距离的限制
  2. ListView的getChildAt使用需注意参数的范围
  3. 注意scrollBy,scrollTo,getScrollX,Scroller中坐标的含义
  4. LinearLayout的mearsure过程会逐步减少可用的布局空间,继承LinearLayout时注意
  5. 滑动时,连续两个Move事件间的距离不是均匀的,到快速滑动时,这个距离可能很大,需要注意此时程序的正确性。


参考:
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值