赞
踩
先看下今天要实现的demo的效果图
大体分解下主要要如下需求:
1、聊天列表有个会伸缩的头部,伸缩过程中列表不可滑动
2、列表项可以向左滑动拖出删除功能,并且点击删除该列表项,滑动列表时有处于删除功能态的列表想需先重置列表状态
3、列表项右侧有代表未读条数的小圈,拖动可删除。
列完需求之后,我们再来看如何一步步实现。先看第一个需求点,大致看需要一个头部View加ListView构成。
public class HeaderScrollView extends LinearLayout implements IPersonalScrollView{
...
public HeaderScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mContext = context;
init();
}
private void init(){
setOrientation(VERTICAL);
if(null == mHeaderView){
mHeaderView = new TextView(mContext);
((TextView)mHeaderView).setText("This is header view");
((TextView)mHeaderView).setTextSize(35);
}
addView(mHeaderView, 0);
mListView = new ScrollItemListView(mContext);
addView(mListView);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mHeaderView == null) {
return;
}
measureChild(mHeaderView, widthMeasureSpec, heightMeasureSpec);
if(0 == mOriginalHeaderHeight) {
mOriginalHeaderHeight = mHeaderView.getMeasuredHeight();
mHeaderHeight = mOriginalHeaderHeight;
}
}
...
}
上面是初始化该View的部分,比较简单,可以看到添加了两个view,同时在onMeasure里测量了头部View的原始高度,为后面拖动时的测量做数据准备。下面具体看看拦截事件如何处理
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// Log.d(TAG, "onInterceptTouchEvent ev:" + ev.getAction());
//ListView有Item被滑动到最左边,需要滑动回正常位置
if(mDisallowedIntercept){
return false;
}
mNeedScrollToNormal = mListView.getNeedScrollToNormal(ev);
if(mNeedScrollToNormal){
return true;
}
int x = (int) ev.getX();
int y = (int) ev.getY();
boolean intercept = false;
if(ev.getAction() == MotionEvent.ACTION_DOWN){
mLastInterceptX = (int) ev.getX();
mLastInterceptY = (int) ev.getY();
mDownHeaderHeight = mHeaderView.getMeasuredHeight();
}
else if(ev.getAction() == MotionEvent.ACTION_MOVE){
int deltaX = x - mLastInterceptX;
int deltaY = y - mLastInterceptY;
if(Math.abs(deltaX) < Math.abs(deltaY)){
if(getHeaderHeight() > 0){
intercept = true;
}
else if(getHeaderHeight() == 0 && deltaY >=0 && isFirstListItemTotallySeen()){
intercept = true;
}
}
}
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
// Log.d(TAG, "onTouchEvent ev:" + ev.getAction());
if(mDisallowedIntercept){
return false;
}
int x = (int) ev.getX();
int y = (int) ev.getY();
if(mNeedScrollToNormal){
mListView.scrollToNormal(ev);
return true;
}
if(ev.getAction() == MotionEvent.ACTION_MOVE) {
int deltaX = x - mLastInterceptX;
int deltaY = y - mLastInterceptY;
if (Math.abs(deltaX) < Math.abs(deltaY) && getHeaderHeight() >= 0) {
setHeaderHeight(mDownHeaderHeight, deltaY);
return true;
}
}
else if(ev.getAction() == MotionEvent.ACTION_UP){
boolean up = getHeaderHeight() < mOriginalHeaderHeight * 0.5;
new ScrollToEndTask(up?ScrollToEndTask.SCROLL_UP:ScrollToEndTask.SCROLL_DOWN).execute();
}
return super.onTouchEvent(ev);
}
上面代码的第5行和第8行的拦截判断是用在拖拽未读消息数和删除列表项上,这里先不做解释,看第8行之后的拦截判断。当头部View的可见时,或者不可见但ListView已经不能继续下来用户依然有下拉动作的时候都应该将此时的手势事件拦截而不是传递给ListView处理。第24行的就是判断头部View可见,第27行的判断是针对后一种需要拦截的状态。其中用来判断ListView是否已经不可再下拉的方法如下
private boolean isFirstListItemTotallySeen(){
View view = mListView.getChildAt(0);
return view.getTop() >= 0;
}
当拦截之后,在onTouchEvent里就是具体的滑动操作,在第47行的ACTION_MOVE事件里,根据手指刚触碰屏幕的头部View高度和此时的竖直方向滑动偏移量来设置高度
private void setHeaderHeight(int startHeight, int deltaY){
mHeaderHeight = startHeight + deltaY;
if(mHeaderHeight < 0){
mHeaderHeight = 0;
}
else if(mHeaderHeight > mOriginalHeaderHeight){
mHeaderHeight = mOriginalHeaderHeight;
}
ViewGroup.LayoutParams params = mHeaderView.getLayoutParams();
params.height = mHeaderHeight;
mHeaderView.setLayoutParams(params);
}
当手指离开的时候,根据此时头部view的高度若在原始高度的一半以内就自动滑动到0,否则滑动到初始高度
private class ScrollToEndTask extends AsyncTask<Void, Integer, Void> {
public static final int SCROLL_UP = 1;
public static final int SCROLL_DOWN = 2;
int startHeight = getHeaderHeight();
int direction = 0;
public ScrollToEndTask(int direction) {
super();
this.direction = direction;
}
@Override
protected Void doInBackground(Void... voids) {
int deltaY = direction == SCROLL_UP? -5: 5;
try {
while (getHeaderHeight() > 0 && getHeaderHeight() < mOriginalHeaderHeight){
publishProgress(deltaY);
Thread.sleep(10);
deltaY+=deltaY;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onProgressUpdate(Integer... values) {
super.onProgressUpdate(values);
setHeaderHeight(startHeight, values[0]);
}
}
最外层的View的整理逻辑功能差不多是这样的,第一个需求点实现完了。
现在看第二个需求点,为列表项增加滑动可以删除的功能
public class ScrollItemListView extends ListView implements IPersonalScrollView{
...
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// Log.d(TAG, "onInterceptTouchEvent ev:" + ev.getAction());
if(mDisallowedIntercept){
return false;
}
int x = (int) ev.getX();
int y = (int) ev.getY();
int pos = pointToPosition(x, y);
boolean intercept = false;
if(ev.getAction() == MotionEvent.ACTION_DOWN){
mLastInterceptX = (int) ev.getX();
mLastInterceptY = (int) ev.getY();
getScrollData(pos);
}
else if(ev.getAction() == MotionEvent.ACTION_MOVE){
int deltaX = x - mLastInterceptX;
int deltaY = y - mLastInterceptY;
if(Math.abs(deltaX) > Math.abs(deltaY)){
intercept = true;
}
}
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
// Log.d(TAG, "onTouchEvent ev:" + ev.getAction());
if(mDisallowedIntercept){
return false;
}
int x = (int) ev.getX();
int y = (int) ev.getY();
int pos = pointToPosition(x, y);
if(ev.getAction() == MotionEvent.ACTION_DOWN){
Log.d(TAG, "mDownPos = " + pos);
mDownPos = pos;
}
else if(ev.getAction() == MotionEvent.ACTION_MOVE) {
int deltaX = x - mLastInterceptX;
int deltaY = y - mLastInterceptY;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
dragItem(x, pos);
return true;
}
}
else if(ev.getAction() == MotionEvent.ACTION_UP){
int nowLeftMargin = mDownLeftMargin + x - mLastInterceptX;
scrollToEnd(nowLeftMargin);
if(nowLeftMargin < mMaxLeftMargin*0.5){
Log.d(TAG, "set down pos:" + mDownPos);
setNeedScrollToNormal(true, mDownPos);
}
}
return super.onTouchEvent(ev);
}
...
}
先看手势事件部分的代码,拦截的条件很简单,横向滑动距离大于纵向滑动距离就需要拦截做相应处理,同时需要获取下当前触碰位置的一些基本信息,第16行getScrollData
/**
* 获取滑动所需的数据
* @param pos
*/
private void getScrollData(int pos){
if(pos > -1){
View view = getChildAt(pos - getFirstVisiblePosition());
mScrollView = view.findViewWithTag(NOR_DELETE_TAG);
View delete = view.findViewWithTag(DELETE_TAG);
if(null != delete) {
mMaxLeftMargin = delete.getWidth() * (-1);
}
if(null != mScrollView) {
MarginLayoutParams params = (MarginLayoutParams)mScrollView.getLayoutParams();
//将宽度从WRAP_CONTENT改为固定值
params.width = mScrollView.getWidth();
mDownLeftMargin = params.leftMargin;
}
}
}
上面代码有两个TAG,NOR_DELETE_TAG和DELETE_TAG,这两个TAG需要在为ListView设置Adapter里的getView方法设置进入
public static final String DELETE_TAG = "delete";
public static final String NOR_DELETE_TAG = "nor_delete";
ChatActivity中:
@Override
public View getView(final int i, View convertView, final ViewGroup viewGroup) {
ViewHolder viewHolder = null;
if(null == convertView){
convertView = LayoutInflater.from(ChatActivity.this).inflate(R.layout.list_item, null);
viewHolder = new ViewHolder();
viewHolder.rlName = (RelativeLayout) convertView.findViewById(R.id.rl_name);
viewHolder.tvName = (TextView) convertView.findViewById(R.id.tvName);
viewHolder.btnDelete = (Button) convertView.findViewById(R.id.btnDelete);
viewHolder.viewNotify = (CircleNotifyView) convertView.findViewById(R.id.notify_view);
viewHolder.rlName.setTag(ScrollItemListView.NOR_DELETE_TAG);
viewHolder.btnDelete.setTag(ScrollItemListView.DELETE_TAG);
convertView.setTag(viewHolder);
}
else{
viewHolder = (ViewHolder)convertView.getTag();
}
.....
return convertView;
}
Item布局如下
<?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="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
>
<RelativeLayout
android:id="@+id/rl_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
>
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:gravity="center_vertical"
/>
<com.jackchan.qquidemo.ui.CircleNotifyView
android:id="@+id/notify_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginTop="15dp"
android:layout_marginRight="10dp"
/>
</RelativeLayout>
<Button
android:id="@+id/btnDelete"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="@string/delete"
android:padding="10dp"
android:background="#dddddd"
/>
</LinearLayout>
这样一看就知道NOR_DELETE_TAG就是对应非删除按钮部分,DELETE_TAG就是对应删除按钮,通过TAG的方式在view里面拿到我们要改变位置的item view。
继续回到手势事件部分45行的dragItem和51行的scrollToEnd方法就是具体拖动item项的方法,原理依然是根据拖动的偏移量设置margin数据,跟HeaderScrollView里的方法类似,当手指离开屏幕时,item偏移量超过最大偏移量的一半是自动让它滑动到最大偏移量,反正则滑动到正常没有便宜的状态。这里不贴代码了,大家可以直接通过文章结尾的源码地址看看具体源码。
这个ScrollItemListView要重点讲下52行的判断以及里面的setNeedScrollToNormal,这个判断是用来判断手指离开屏幕时,item是否要滑动到最大偏移状态。是的话需要记录下状态以及当前处于偏移位置的item的数据,当下次用户滑动列表的时候,我们需要将处于偏移位置的item回归正常位置。
为什么要这样做呢,因为我们大家都知道ListView的item选项是复用的,如果再次滑动不复位已经偏移的item,就会导致被复用的item选项也处于偏移状态,我看了下qq聊天列表滑动列表时,如果有处于偏移状态的item也是会让它回归正常位置。
这方面的判断就是我们最开始HeaderScrollView里onInterceptTouchEvent方法里的判断
mNeedScrollToNormal = mListView.getNeedScrollToNormal(ev);
if(mNeedScrollToNormal){
return true;
}
当需要回归正常位置时我们就拦截一切手势事件。
public boolean getNeedScrollToNormal(MotionEvent ev){
int x = (int) ev.getX();
int y = (int) ev.getY();
int pos = this.pointToPosition(x, y - this.getTop());
// Log.d(TAG, "ev:" + ev.getAction() + " pos:" + pos + " mHasScrollItemPos:" + mHasScrollItemPos);
if(ev.getAction() == MotionEvent.ACTION_DOWN){
mLastInterceptX = x;
mLastInterceptY = y - this.getTop();
//点击在删除按钮的部分不需要拦截
if(mLastScrollView != null && mLastInterceptX > mLastScrollView.getRight() && pos == mHasScrollItemPos){
setNeedScrollToNormal(false, -1);
}
return false;
}
else if(ev.getAction() == MotionEvent.ACTION_MOVE &&
mNeedScrollToNormal && !mIsScrollingToNormal
&& pos == mHasScrollItemPos){
int delaX = x - mLastInterceptX;
int delaY = y - mLastInterceptY;
if(Math.abs(delaY) < Math.abs(delaX)) {
setNeedScrollToNormal(false, -1);
}
}
return mNeedScrollToNormal;
}
不需要拦截的条件如下:
1、点击操作是为了处理删除操作(第6到第14行判断)
2、用户正在滑动处于偏移位置的item(第15到第22行判断)
不需要拦截时就需要调用setNeedScrollToNormal方法设置mNeedScrollToNormal
private void setNeedScrollToNormal(boolean flag, int pos){
mNeedScrollToNormal = flag;
mHasScrollItemPos = pos;
}
而mNeedScrollToNormal在什么状态下设置为true呢,就在上面onTouchEvent的54行处。
一旦知道需要将偏移item复位就简单多了HeaderScrollView onTouchEvent一开头代码就进行了这项操作
if(mNeedScrollToNormal){
mListView.scrollToNormal(ev);
return true;
}
public void scrollToNormal(MotionEvent ev){
if(ev.getAction() == MotionEvent.ACTION_MOVE){
if(!mIsScrollingToNormal){
new ScrollToEndTask(mLastScrollView, ScrollToEndTask.SCROLL_RIGHT).execute();
mIsScrollingToNormal = true;
}
}
else if(ev.getAction() == MotionEvent.ACTION_UP){
if(mNeedScrollToNormal) {
setNeedScrollToNormal(false, -1);
}
}
}
第二个需求点也完成了,现在我们再来看看第三个需求点如何实现,思路大概整理下
首先需要显示条数的小红圈
当触碰到小红圈时屏蔽父层所有view的拦截事件
拖动过程中,手指挪动的地方也有一个一样的小红圈,并且跟原始的小红圈有细线连接
手指离开屏幕,根据一定条件判断是否让原始小红圈消失。
解析来一步步实现
先自定义一个放置于item右下角的小红圈
public class CircleNotifyView extends View {
...
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int width = getWidthSize(widthSpecMode, widthSpecSize);
int height = getHeightSize(heightSpecMode, heightSpecSize);
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width, height)/2;
mPaint.setColor(Color.RED);
canvas.drawCircle(width / 2, height / 2, radius, mPaint);
mPaint.setColor(Color.WHITE);
String content = Integer.toString(mNumber);
float x = (width - mPaint.measureText(content)) / 2;
float y = (height + mPaint.measureText(content, 0, 1))/ 2;
canvas.drawText(content, x, y, mPaint);
}
...
}
上面代码为基本的测量绘制代码,就不细说了,应该都看得懂,getWidthSize和getHeightSize代码不贴了,详情看源码。
具体看下手势事件
@Override
public boolean onTouchEvent(MotionEvent event) {
// Log.d(TAG, "rawX:" + event.getRawX() + " rawY:" + event.getRawY());
// Log.d(TAG, "mExtraHeight:" + mExtraHeight);
if(event.getAction() == MotionEvent.ACTION_DOWN){
mExtraHeight = getExtraHeight();
if(mDecorView.getChildCount() == 1){
setScrollParentDisallowIntercept(true);
final int[] location = new int[2];
this.getLocationOnScreen(location);
float x = location[0] + getWidth() / 2;
float y =location[1] + getHeight() / 2 - mExtraHeight;
addNotify(x, y);
}
}
else if(event.getAction() == MotionEvent.ACTION_MOVE){
updateNotify(event.getRawX(), event.getRawY() - mExtraHeight);
}
else if(event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL){
setScrollParentDisallowIntercept(false);
removeNotify();
Log.d(TAG, "getRatio:" + mDragView.getRatio());
if(null != mDragView && mDragView.getRatio() <= 0){
iCircleNotifyCallback.onDismissByTag((String)this.getTag());
}
}
return true;
}
第7行的mDecorView是什么呢?如果了解Activity setContentView方法的应该都知道,我们setContentView就是把view设置在一个id为android.R.id.content的view内部,因此我们可以给这个view增加子view来作为滑动过程中的小红圈。
再来看看如何屏蔽父层view的拦截事件
private void setScrollParentDisallowIntercept(boolean disallowIntercept){
View target = this;
while (true) {
View parent;
try {
parent = (View) target.getParent();
} catch (Exception e) {
return;
}
if (parent == null)
return;
if (parent instanceof ListView || parent instanceof ScrollView) {
((ViewGroup)parent).requestDisallowInterceptTouchEvent(disallowIntercept);
}
else if(parent instanceof IPersonalScrollView){
((IPersonalScrollView)parent).disallowedIntercept(disallowIntercept);
}
target = parent;
}
}
对应系统提供的ListView,ScrollView等带拖动效果的view设置其requestDisallowInterceptTouchEvent,但对于我们自己定义的可拖动类型的view,我们需要让他们继承IPersonalScrollView接口(ScrollViewListView和HeaderScrollView都实现了),同时在他们的onInterceptTouchEvent里进行处理
if(mDisallowedIntercept){
return false;
}
现在算是解释清楚了ScrollViewListView和HeaderScrollView onInterceptTouchEvent里的所有拦截条件逻辑。
拦截之后就需要展示可拖动的小红圈view,onTouchEvent里第8到13行根据屏幕坐标位置增加小红圈,并在拖动过程中更新位置,当手指离开的时候,先恢复父层控件的拦截方法然后隐藏拖动的小红圈并根据一定条件(这里我让拖动的距离超过屏幕宽度四分之三就通知回调),在回调里可以根据自己的业务做相应处理(这里将列表item的小红圈一并去掉)
@Override
public void onDismissByTag(String tag) {
mList.remove(new Item(tag));
mMyAdapter.notifyDataSetChanged();
}
整个代码的逻辑功能就差不多分析到这,可能会有些冗长,但不是全部代码都贴出来了,可以结合这源码一起看,源码位置在Github 上。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。