学Android也有一段时间了,一直都是用开源的控件,没有自己写过自定义的控件。最近在复习View的一些知识,感觉还是上手写点代码比较实在。在写自定义View之前大概要了解以下知识
- View的测量,布局与绘制
- View的事件处理
- View的滑动实现
- 滑动冲突的解决
只是了解点理论知识是不够的,必须亲手写个控件才有感觉。为了能够将View的这些理论知识都用上,我决定写一个能够滑动显示隐藏按键的ListView,类似于QQ中显示聊天记录的列表。我把这个功能的实现细化为以下几步
- 实现一个可以滑动显示隐藏按钮的布局SwipeItemView,隐藏部分包含两个按钮。
- 实现一个SwipeMenuListAdapter生成ListView的每一个条目,其中确定了每个条目中隐藏部分的样式。
- 实现一个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的全部代码。总结几处容易出错的地方:
- 判断水平和竖直滑动时,一定要添加touchSlop最小滑动距离的限制
- ListView的getChildAt使用需注意参数的范围
- 注意scrollBy,scrollTo,getScrollX,Scroller中坐标的含义
- LinearLayout的mearsure过程会逐步减少可用的布局空间,继承LinearLayout时注意
- 滑动时,连续两个Move事件间的距离不是均匀的,到快速滑动时,这个距离可能很大,需要注意此时程序的正确性。
参考: