欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

Android自定义控件仿QQ抽屉效果

程序员文章站 2022-10-14 15:22:24
其实网上类似的实现已经很多了,原理也并不难,只是网上各种demo运行下来,多少都有一些问题。折腾了半天,决定自己实现一个。 首先我们看看实现效果: 对比...

其实网上类似的实现已经很多了,原理也并不难,只是网上各种demo运行下来,多少都有一些问题。折腾了半天,决定自己实现一个。

首先我们看看实现效果:

Android自定义控件仿QQ抽屉效果

对比网上各类demo,这次要实现的主要表现在以下几点:

1.侧滑显示抽屉view
2.侧滑抽屉隐藏view控件点击事件
3.单击任意item隐藏显示的抽屉view
4.滑动list隐藏显示的抽屉view
5.增加swipelayout点击事件和swipe touch事件判断处理
6.优化快速划开多个抽屉隐藏view时多个swipelayout滑动状态判断处理,仅显示最后一个滑动的抽屉隐藏view,隐藏前面所有打开的抽屉view(快速滑动时,可能存在多个抽屉view打开情况,网上找的几个demo主要问题都集中在这一块)

实现原理

其实单就一个swipelayout的实现原理来讲的话,还是很简单的,实际上单个swipelayout隐藏抽屉状态时,应该是这样的:

Android自定义控件仿QQ抽屉效果

也就是说,最初的隐藏状态,实际上是将hide view区域layout到conten view的右边,达到隐藏效果,而后显示则是根据拖拽的x值变化来动态的layout 2个view,从而达到一个滑动抽屉效果。
当然,直接重写view的ontouchevent来动态的layout 2个view是可以实现我们需要的效果的,但是有更好的方法来实现,就是同过viewdraghelper。
viewdraghelper是google官方提供的一个专门用于手势分析处理的类,关于viewdraghelper的基本使用,网上有一大堆的资源。具体的viewdraghelper介绍以及基本使用方法,本文就不重复造*了,此处推荐鸿洋大神的一篇微博:android viewdraghelper完全解析 自定义viewgroup神器

具体实现

下面我们开始具体的实现。
布局比较简单,这里就不贴代码了,最后会贴上本demo的完整代码地址。

首先我们实现一个继承framelayout的自定义swipelauout,重写onfinishinflate方法:
这里我们只允许swipelayout设置2个子view,contentlayout是继承linearlayout的自定义layout,后面会讲到这个,此处先略过;

 @override
 protected void onfinishinflate() {
  super.onfinishinflate();
  if (getchildcount() != 2) {
   throw new illegalstateexception("must 2 views in swipelayout");
  }
  contentview = getchildat(0);
  hideview = getchildat(1);
  if (contentview instanceof contentlayout)
   ((contentlayout) contentview).setswipelayout(this);
  else {
   throw new illegalstateexception("content view must be an instanceof frontlayout");
  }
 }

接着重写onsizechanged,onlayout,onintercepttouchevent方法:

 @override
 protected void onsizechanged(int w, int h, int oldw, int oldh) {
  super.onsizechanged(w, h, oldw, oldh);
  hideviewheight = hideview.getmeasuredheight();
  hideviewwidth = hideview.getmeasuredwidth();
  contentwidth = contentview.getmeasuredwidth();
 }

 @override
 protected void onlayout(boolean changed, int left, int top, int right,
       int bottom) {
  // super.onlayout(changed, left, top, right, bottom);
  contentview.layout(0, 0, contentwidth, hideviewheight);
  hideview.layout(contentview.getright(), 0, contentview.getright()
    + hideviewwidth, hideviewheight);
 }

 @override
 public boolean onintercepttouchevent(motionevent ev) {
  boolean result = viewdraghelper.shouldintercepttouchevent(ev);
  //  log.e("swipelayout", "-----onintercepttouchevent-----");
  return result;
 }

然后是比较关键的,重写ontouchevent方法以及viewdraghelper.callback回调,我们定了一个enum来判断swipelayout的三种状态。在onviewpositionchanged中,有2种方法实现content view和hide view的伴随移动,一种是直接offset view的横向变化量,还有一种就是直接通过layout的方式,两种方式都可以。

 public enum swipestate {
  open, swiping, close;
 }
 @override
 public boolean ontouchevent(motionevent event) {
  //  log.e("swipelayout", "-----ontouchevent-----");
  switch (event.getaction()) {
   case motionevent.action_down:
    downx = event.getx();
    downy = event.gety();
    break;
   case motionevent.action_move:
    // 1.获取x和y方向移动的距离
    float movex = event.getx();
    float movey = event.gety();
    float delatx = movex - downx;// x方向移动的距离
    float delaty = movey - downy;// y方向移动的距离

    if (math.abs(delatx) > math.abs(delaty)) {
     // 表示移动是偏向于水平方向,那么应该swipelayout应该处理,请求listview不要拦截
     this.requestdisallowintercepttouchevent(true);
    }

    // 更新downx,downy
    downx = movex;
    downy = movey;
    break;
   case motionevent.action_up:

    break;
  }
  viewdraghelper.processtouchevent(event);
  return true;
 }

 private viewdraghelper.callback callback = new viewdraghelper.callback() {
  @override
  public boolean trycaptureview(view child, int pointerid) {
   return child == contentview || child == hideview;
  }

  @override
  public int getviewhorizontaldragrange(view child) {
   return hideviewwidth;
  }

  @override
  public int clampviewpositionhorizontal(view child, int left, int dx) {
   if (child == contentview) {
    if (left > 0)
     left = 0;
    if (left < -hideviewwidth)
     left = -hideviewwidth;
   } else if (child == hideview) {
    if (left > contentwidth)
     left = contentwidth;
    if (left < (contentwidth - hideviewwidth))
     left = contentwidth - hideviewwidth;
   }
   return left;
  }

  @override
  public void onviewpositionchanged(view changedview, int left, int top,
           int dx, int dy) {
   super.onviewpositionchanged(changedview, left, top, dx, dy);
   if (changedview == contentview) {
    // 如果手指滑动deleteview,那么也要讲横向变化量dx设置给contentview
    hideview.offsetleftandright(dx);
   } else if (changedview == hideview) {
    // 如果手指滑动contentview,那么也要讲横向变化量dx设置给deleteview
    contentview.offsetleftandright(dx);
   }

   //   if (changedview == contentview) {
   //    // 手动移动deleteview
   //    hideview.layout(hideview.getleft() + dx,
   //      hideview.gettop() + dy, hideview.getright() + dx,
   //      hideview.getbottom() + dy);
   //   } else if (hideview == changedview) {
   //    // 手动移动contentview
   //    contentview.layout(contentview.getleft() + dx,
   //      contentview.gettop() + dy, contentview.getright() + dx,
   //      contentview.getbottom() + dy);
   //   }
   //实时更新当前状态
   updateswipestates();
   invalidate();
  }

  @override
  public void onviewreleased(view releasedchild, float xvel, float yvel) {
   super.onviewreleased(releasedchild, xvel, yvel);
   //根据用户滑动速度处理开关
   //xvel: x方向滑动速度
   //yvel: y方向滑动速度
   //   log.e("tag", "currentstate = " + currentstate);
   //   log.e("tag", "xvel = " + xvel);
   if (xvel < -200 && currentstate != swipestate.open) {
    open();
    return;
   } else if (xvel > 200 && currentstate != swipestate.close) {
    close();
    return;
   }

   if (contentview.getleft() < -hideviewwidth / 2) {
    // 打开
    open();
   } else {
    // 关闭
    close();
   }
  }
 };

open(),close()实现

 public void open() {
  open(true);
 }

 public void close() {
  close(true);
 }

 /**
  * 打开的方法
  *
  * @param issmooth 是否通过缓冲动画的形式设定view的位置
  */
 public void open(boolean issmooth) {
  if (issmooth) {
   viewdraghelper.smoothslideviewto(contentview, -hideviewwidth,
     contentview.gettop());
   viewcompat.postinvalidateonanimation(swipelayout.this);
  } else {
   contentview.offsetleftandright(-hideviewwidth);//直接偏移view的位置
   hideview.offsetleftandright(-hideviewwidth);//直接偏移view的位置
   //   contentview.layout(-hideviewwidth, 0, contentwidth - hideviewwidth, hideviewheight);//直接通过坐标摆放
   //   hideview.layout(contentview.getright(), 0, hideviewwidth, hideviewheight);//直接通过坐标摆放
   invalidate();
  }
 }

 /**
  * 关闭的方法
  *
  * @param issmooth true:通过缓冲动画的形式设定view的位置
  *     false:直接设定view的位置
  */
 public void close(boolean issmooth) {
  if (issmooth) {
   viewdraghelper.smoothslideviewto(contentview, 0, contentview.gettop());
   viewcompat.postinvalidateonanimation(swipelayout.this);
  } else {
   contentview.offsetleftandright(hideviewwidth);
   hideview.offsetleftandright(hideviewwidth);
   invalidate();
   //contentview.layout(0, 0, contentwidth, hideviewheight);//直接通过坐标摆放
   //hideview.layout(contentview.getright(), 0, hideviewwidth, hideviewheight);//直接通过坐标摆放
  }
 }

此上基本实现了单个swipelayout的抽屉滑动效果,但是将此swipelayout作为一个item布局设置给一个listview的时候,还需要做许多的判断。

由于listview的重用机制,我们这里并未针对listview做任何处理,所以一旦有一个item的swipelayout的状态是打开状态,不可避免的其它也必然有几个是打开状态,所以我们这里需要根据检测listview的滑动,当listview滑动时,关闭swipelayout。既然需要在外部控制swipelayout的开关,我们先定义一个swipelayoutmanager用于管理swipelayout的控制。

public class swipelayoutmanager {
 //记录打开的swipelayout集合
 private hashset<swipelayout> munclosedswipelayouts = new hashset<swipelayout>();

 private swipelayoutmanager() {
 }

 private static swipelayoutmanager minstance = new swipelayoutmanager();

 public static swipelayoutmanager getinstance() {
  return minstance;
 }

 /**
  * 将一个没有关闭的swipelayout加入集合
  * @param layout
  */
 public void add(swipelayout layout) {
  munclosedswipelayouts.add(layout);
 }

 /**
  * 将一个没有关闭的swipelayout移出集合
  * @param layout
  */
 public void remove(swipelayout layout){
  munclosedswipelayouts.remove(layout);
 }

 /**
  * 关闭已经打开的swipelayout
  */
 public void closeuncloseswipelayout() {
  if(munclosedswipelayouts.size() == 0){
   return;
  }

  for(swipelayout l : munclosedswipelayouts){
   l.close(true);
  }
  munclosedswipelayouts.clear();
 }

 /**
  * 关闭已经打开的swipelayout
  */
 public void closeuncloseswipelayout(boolean issmooth) {
  if(munclosedswipelayouts.size() == 0){
   return;
  }

  for(swipelayout l : munclosedswipelayouts){
   l.close(issmooth);
  }
  munclosedswipelayouts.clear();
 }
}

这样就可以监听listview的滑动,然后在listview滑动的时候,关闭所有的抽屉view。

 listview.setonscrolllistener(new onscrolllistener() {
   @override
   public void onscrollstatechanged(abslistview view, int scrollstate) {
    swipelayoutmanager.closeuncloseswipelayout();
   }

   @override
   public void onscroll(abslistview view, int firstvisibleitem, int visibleitemcount, int totalitemcount) {
   }
  });

考虑到大多数时候,在我们打开抽屉view和关闭抽屉view的时候,外部需要知道swipelayout的状态值,所以我们需要在swipelayout中增加几个接口,告诉外部当前swipelayout的状态值:

swipelayout.java

----------------
 private void updateswipestates() {
  swipestate lastswipestate = currentstate;
  swipestate swipestate = getcurrentstate();

  if (listener == null) {
   try {
    throw new exception("please setonswipestatechangelistener first!");
   } catch (exception e) {
    e.printstacktrace();
   }
   return;
  }

  if (swipestate != currentstate) {
   currentstate = swipestate;
   if (currentstate == swipestate.open) {
    listener.onopen(this);
    // 当前的swipelayout已经打开,需要让manager记录
    swipelayoutmanager.add(this);
   } else if (currentstate == swipestate.close) {
    listener.onclose(this);
    // 说明当前的swipelayout已经关闭,需要让manager移除
    swipelayoutmanager.remove(this);
   } else if (currentstate == swipestate.swiping) {
    if (lastswipestate == swipestate.open) {
     listener.onstartclose(this);
    } else if (lastswipestate == swipestate.close) {
     listener.onstartopen(this);
     //hideview准备显示之前,先将之前打开的的swipelayout全部关闭
     swipelayoutmanager.closeuncloseswipelayout();
     swipelayoutmanager.add(this);
    }
   }
  } else {
   currentstate = swipestate;
  }
 }

 /**
  * 获取当前控件状态
  *
  * @return
  */
 public swipestate getcurrentstate() {
  int left = contentview.getleft();
  //  log.e("tag", "contentview.getleft() = " + left);
  //  log.e("tag", "hideviewwidth = " + hideviewwidth);
  if (left == 0) {
   return swipestate.close;
  }

  if (left == -hideviewwidth) {
   return swipestate.open;
  }
  return swipestate.swiping;
 }
 private onswipestatechangelistener listener;

 public void setonswipestatechangelistener(
   onswipestatechangelistener listener) {
  this.listener = listener;
 }

 public view getcontentview() {
  return contentview;
 }

 public interface onswipestatechangelistener {
  void onopen(swipelayout swipelayout);

  void onclose(swipelayout swipelayout);

  void onstartopen(swipelayout swipelayout);

  void onstartclose(swipelayout swipelayout);
 }

然后接下来是写一个为listview设置的swipeadapter

swipeadapter.java

------------
public class swipeadapter extends baseadapter implements onswipestatechangelistener {
 private context mcontext;
 private list<string> list;
 private myclicklistener myclicklistener;
 private swipelayoutmanager swipelayoutmanager;

 public swipeadapter(context mcontext) {
  super();
  this.mcontext = mcontext;
  init();
 }

 private void init() {
  myclicklistener = new myclicklistener();
  swipelayoutmanager = swipelayoutmanager.getinstance();
 }

 public void setlist(list<string> list){
  this.list = list;
  notifydatasetchanged();
 }

 @override
 public int getcount() {
  return list.size();
 }

 @override
 public object getitem(int position) {
  return list.get(position);
 }

 @override
 public long getitemid(int position) {
  return position;
 }

 @override
 public view getview(final int position, view convertview, viewgroup parent) {
  if (convertview == null) {
   convertview = uiutils.inflate(r.layout.list_item_swipe);
  }
  viewholder holder = viewholder.getholder(convertview);

  holder.tv_content.settext(list.get(position));
  holder.tv_overhead.setonclicklistener(myclicklistener);
  holder.tv_overhead.settag(position);
  holder.tv_delete.setonclicklistener(myclicklistener);
  holder.tv_delete.settag(position);
  holder.sv_layout.setonswipestatechangelistener(this);
  holder.sv_layout.settag(position);

  holder.sv_layout.getcontentview().setonclicklistener(new view.onclicklistener() {
   @override
   public void onclick(view v) {
    toastutils.showtoast("item click : " + position);
    swipelayoutmanager.closeuncloseswipelayout();
   }
  });

  return convertview;
 }

 static class viewholder {
  textview tv_content, tv_overhead, tv_delete;
  swipelayout sv_layout;

  public viewholder(view convertview) {
   tv_content = (textview) convertview.findviewbyid(r.id.tv_content);
   tv_overhead = (textview) convertview.findviewbyid(r.id.tv_overhead);
   tv_delete = (textview) convertview.findviewbyid(r.id.tv_delete);
   sv_layout = (swipelayout) convertview.findviewbyid(r.id.sv_layout);
  }

  public static viewholder getholder(view convertview) {
   viewholder holder = (viewholder) convertview.gettag();
   if (holder == null) {
    holder = new viewholder(convertview);
    convertview.settag(holder);
   }
   return holder;
  }
 }

 class myclicklistener implements view.onclicklistener {
  @override
  public void onclick(view v) {
   integer position = (integer) v.gettag();
   switch (v.getid()) {
    case r.id.tv_overhead:
     //toastutils.showtoast("position : " + position + " overhead is clicked.");
     }
     break;
    case r.id.tv_delete:
     //toastutils.showtoast("position : " + position + " delete is clicked.");
     }
     break;
    default:
     break;
   }
  }
 }

 @override
 public void onopen(swipelayout swipelayout) {
  //toastutils.showtoast(swipelayout.gettag() + "onopen.");
 }

 @override
 public void onclose(swipelayout swipelayout) {
  //toastutils.showtoast(swipelayout.gettag() + "onclose.");
 }

 @override
 public void onstartopen(swipelayout swipelayout) {
  //   toastutils.showtoast("onstartopen.");
 }

 @override
 public void onstartclose(swipelayout swipelayout) {
  //   toastutils.showtoast("onstartclose.");
 }
}

此时已经基本实现了我们需要的大部分功能了,但是当我们滑动的时候,又发现新的问题,我们的swipelayout和listview滑动判断有问题。由于前面我们仅仅是将touch拦截事件简简单单的丢给了viewdraghelper.shouldintercepttouchevent(ev)来处理,导致swipelayout和listview拦截touch事件时的处理存在一定的问题,这里我们要提到一个知识点:android view事件的传递。
(1)首先由activity分发,分发给根view,也就是decorview(decorview为整个window界面的最顶层view)
(2)然后由根view分发到子的view

view事件拦截如下图所示:

Android自定义控件仿QQ抽屉效果

view事件的消费如下图所示:

Android自定义控件仿QQ抽屉效果

注:以上2张图借鉴网上总结的比较经典的图

所以这里我们就要谈到一开始出现的contentlayout,主要重写了onintercepttouchevent和ontouchevent。

public class contentlayout extends linearlayout {
 swipelayoutinterface miswipelayout;

 public contentlayout(context context) {
  super(context);
 }

 public contentlayout(context context, attributeset attrs) {
  super(context, attrs);
 }

 public contentlayout(context context, attributeset attrs, int defstyleattr) {
  super(context, attrs, defstyleattr);
 }

 public void setswipelayout(swipelayoutinterface iswipelayout) {
  this.miswipelayout = iswipelayout;
 }

 @override
 public boolean onintercepttouchevent(motionevent ev) {
//  log.e("contentlayout", "-----onintercepttouchevent-----");
  if (miswipelayout.getcurrentstate() == swipestate.close) {
   return super.onintercepttouchevent(ev);
  } else {
   return true;
  }
 }

 @override
 public boolean ontouchevent(motionevent ev) {
//  log.e("contentlayout", "-----ontouchevent-----");
  if (miswipelayout.getcurrentstate() == swipestate.close) {
   return super.ontouchevent(ev);
  } else {
   if (ev.getactionmasked() == motionevent.action_up) {
    miswipelayout.close();
   }
   return true;
  }
 }
}

另外由于在contentlayout中需要拿到父view swipelayout的开关状态以及控制swipelayout的关闭,因此在再写一个接口,用于contentlayout获取swipelayout的开关状态以及更新swipelayout。

public interface swipelayoutinterface {

 swipestate getcurrentstate();

 void open();

 void close();
}

然后接着的是完善swipelayout的onintercepttouchevent,我们在这里增加一个gesturedetectorcompat处理手势识别:

 private void init(context context) {
  viewdraghelper = viewdraghelper.create(this, callback);
  mgesturedetector = new gesturedetectorcompat(context, mongesturelistener);
  swipelayoutmanager = swipelayoutmanager.getinstance();
 }

 private simpleongesturelistener mongesturelistener = new simpleongesturelistener() {
  @override
  public boolean onscroll(motionevent e1, motionevent e2, float distancex, float distancey) {
   // 当横向移动距离大于等于纵向时,返回true
   return math.abs(distancex) >= math.abs(distancey);
  }
 };
  @override
 public boolean onintercepttouchevent(motionevent ev) {
  boolean result = viewdraghelper.shouldintercepttouchevent(ev) & mgesturedetector.ontouchevent(ev);
  //  log.e("swipelayout", "-----onintercepttouchevent-----");
  return result;
 }

如此下来,整个view不管是上下拖动,还是swipelayout的开关滑动,都已经实现完成了。最后增加对应overhead,delete以及item的点击事件,此处完善swipeadapter的代码之后如下。

 class myclicklistener implements view.onclicklistener {
  @override
  public void onclick(view v) {
   integer position = (integer) v.gettag();
   switch (v.getid()) {
    case r.id.tv_overhead:
     //toastutils.showtoast("position : " + position + " overhead is clicked.");
     swipelayoutmanager.closeuncloseswipelayout(false);
     if(onswipecontrollistener != null){
      onswipecontrollistener.onoverhead(position, list.get(position));
     }
     break;
    case r.id.tv_delete:
     //toastutils.showtoast("position : " + position + " delete is clicked.");
     swipelayoutmanager.closeuncloseswipelayout(false);
     if(onswipecontrollistener != null){
      onswipecontrollistener.ondelete(position, list.get(position));
     }
     break;
    default:
     break;
   }
  }
 }

 private onswipecontrollistener onswipecontrollistener;

 public void setonswipecontrollistener(onswipecontrollistener onswipecontrollistener){
  this.onswipecontrollistener = onswipecontrollistener;
 }

 /**
  * overhead 和 delete点击事件接口
  */
 public interface onswipecontrollistener{
  void onoverhead(int position, string itemtitle);

  void ondelete(int position, string itemtitle);
 }

最后贴上mainactivity代码,此处通过onswipecontrollistener接口回调实现item的删除和置顶:

public class mainactivity extends activity implements onswipecontrollistener {
 private listview listview;
 private list<string> list = new arraylist<string>();
 private swipelayoutmanager swipelayoutmanager;
 private swipeadapter swipeadapter;

 protected void oncreate(bundle savedinstancestate) {
  super.oncreate(savedinstancestate);
  setcontentview(r.layout.activity_main);

  initdata();
  initview();
 }

 private void initdata() {
  for (int i = 0; i < 50; i++) {
   list.add("content - " + i);
  }
 }

 private void initview() {
  swipelayoutmanager = swipelayoutmanager.getinstance();
  swipeadapter = new swipeadapter(this);
  swipeadapter.setlist(list);

  listview = (listview) findviewbyid(r.id.list_view);

  listview.setadapter(swipeadapter);
  listview.setonscrolllistener(new onscrolllistener() {
   @override
   public void onscrollstatechanged(abslistview view, int scrollstate) {
    swipelayoutmanager.closeuncloseswipelayout();
   }

   @override
   public void onscroll(abslistview view, int firstvisibleitem, int visibleitemcount, int totalitemcount) {
   }
  });

  swipeadapter.setonswipecontrollistener(this);
 }

 @override
 public void onoverhead(int position, string itemtitle) {
  setitemoverhead(position, itemtitle);
 }

 @override
 public void ondelete(int position, string itemtitle) {
  removeitem(position, itemtitle);
 }

 /**
  * 设置item置顶
  *
  * @param position
  * @param itemtitle
  */
 private void setitemoverhead(int position, string itemtitle) {
  // toastutils.showtoast("position : " + position + " overhead.");
  toastutils.showtoast("overhead ---" + itemtitle + "--- success.");
  string newtitle = itemtitle;
  list.remove(position);//删除要置顶的item
  list.add(0, newtitle);//根据adapter传来的title数据在list 0位置插入title字符串,达到置顶效果
  swipeadapter.setlist(list);//重新给adapter设置list数据并更新
  uiutils.runonuithread(new runnable() {
   @override
   public void run() {
    listview.setselection(0);//listview选中第0项item
   }
  });
 }

 /**
  * 删除item
  *
  * @param position
  * @param itemtitle
  */

 private void removeitem(int position, string itemtitle) {
  //  toastutils.showtoast("position : " + position + " delete.");
  toastutils.showtoast("delete ---" + itemtitle + "--- success.");
  list.remove(position);
  swipeadapter.setlist(list);//重新给adapter设置list数据并更新
 }
}

至此整个demo基本完成,本次完成的功能基本能够直接放到项目中使用。其实最麻烦的地方就在于view的touch事件拦截和处理,不过将本demo的log打开看一下对比之后,也就能够理解整个传递过程了。

完整demo地址:https://github.com/horrarndoo/swipelayout

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。