ViewDragHelper---自定义ViewGroup神器
自定义ViewGroup的时候,子view和用户进行交互是常有的事,即用户拖动某个子view(eg:侧滑菜单)。针对具体需求重写onInterceptTouchEvent和onTouchEvent不是件容易的事。
好在官方在V4包中提供了一个辅助类ViewDragHelper,在我们自定义ViewGroup的时候帮助我们处理子view拖动等事件。简单看一下官方解释:
ViewDragHelper is a utility class for writing custom ViewGroups. It offers a number of useful operations and state tracking for allowing a user to drag and reposition views within their parent ViewGroup.本档案库中包含3个module,分别是Demo1,Demo2,MyDrawerLayout;前两个用来演示ViewDragHelper的简单用法,最后一个利用ViewDragHelper实现DrawerLayout。
Demo1简单演示一下ViewDragHelper用法:
- ViewDragHelper实例的创建
- 触摸相关方法的调用
- ViewDragHelper.Callback内部类的编写
自定义ViewGroup:
public class VDHLayout extends LinearLayout
{
private ViewDragHelper mDragger;
public VDHLayout(Context context, AttributeSet attrs)
{
super(context, attrs);
//创建ViewDragHelper实例
mDragger = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback()
{
@Override
public boolean tryCaptureView(View child, int pointerId)
{
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx)
{
//边界控制,横向移动控制在viewgroup内部
int paddingLeft = getPaddingLeft();
int rightBound = getWidth() - getPaddingRight() - child.getWidth();
//控制左边
left = left < paddingLeft ? paddingLeft : left;
//控制右边
left = left > rightBound ?
rightBound : left;
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy)
{
return top;
}
});
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev)
{
return mDragger.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event)
{
mDragger.processTouchEvent(event);
return true;
}
}分析一下,使用ViewDragHelper有3个步骤:
- 1.创建实例
mDragger = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback()
{
//...
});需要三个参数,第一个就是ViewGroup;第二个sesitivity,主要用于设置touchslop,1.0f是常用值;第三个是Callback,在用户回调过程中会调用相关方法。
- 2.将MotionEvent交给ViewDragHelper
@Override
public boolean onInterceptTouchEvent(MotionEvent ev)
{
return mDragger.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event)
{
mDragger.processTouchEvent(event);
return true;
}OK,没什么好说的;通过mDragger.shouldInterceptTouchEvent(ev)来决定我们是否应该拦截当前事件;mDragger.processTouchEvent(event);来处理事件。
- 3.ViewDragHelper.Callback中相关方法
@Override
public boolean tryCaptureView(View child, int pointerId)
{
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx)
{
//边界控制,横向移动控制在viewgroup内部
int paddingLeft = getPaddingLeft();
int rightBound = getWidth() - getPaddingRight() - child.getWidth();
//控制左边
left = left < paddingLeft ? paddingLeft : left;
//控制右边
left = left > rightBound ?
rightBound : left;
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy)
{
return top;
}ViewDragHelper中拦截和处理事件时,会调用Callback中的很多方法决定一些事:比如哪些子view可以移动,移动的范围是什么,子view的边界控制等。
下面说明一下这三个方法:
public boolean tryCaptureView(View child, int pointerId)返回true,则表示可以捕获该view。第一个参数就是捕获的view。
public int clampViewPositionHorizontal(View child, int left, int dx);
public int clampViewPositionVertical(View child, int top, int dy);这两个方法用来锁定子view移动后的位置。上面方法中对水平移动范围进行的控制,即只能在ViewGroup中移动。而垂直移动范围没有限制,直接return top;
OK,下面贴一下布局文件:
<?xml version="1.0" encoding="utf-8"?>
<cn.hzh.vdh.view.VDHLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin">
<TextView
android:text="Hello World!"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_margin="10dp"
android:background="#44ff0000"
android:layout_gravity="center"
android:gravity="center"/>
<TextView
android:text="Hello World!"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_margin="10dp"
android:background="#44ff0000"
android:layout_gravity="center"
android:gravity="center"/>
<TextView
android:text="Hello World!"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_margin="10dp"
android:background="#44ff0000"
android:layout_gravity="center"
android:gravity="center"/>
</cn.hzh.vdh.view.VDHLayout>Demo1效果图:
对ViewDragHelper有了直观认识之后,我们继续学习ViewDragHelper其他功能。
- edge检测(eg: DrawerLayout是边界触发拉出菜单)
- 回调Drag release(手指抬起的时候,菜单自动展开/收缩)
- 移动到指定位置(eg:点击button,可以自动展开/收缩菜单)
我们在Demo1的基础上添加上面三个操作;
首先看一下效果图:
- 第一个子view和Demo1一样
- 第二个子view,除了移动之外,松手就回到原本的位置
- 第三个子view,在边界移动时才操作该子view
由于代码比较长,下面分段贴上相应的代码:
@Override
public boolean tryCaptureView(View child, int pointerId)
{
//mEdgeTrackerView禁止直接移动
return child == mDragView || child == mAutoBackView;
}这里我们对第三个子view禁止直接移动;即,当用户touch第三个子view上时,tryCaptureView()方法return false; 那么这个方法return false的直接后果就是不会执行dragTo()方法,该方法是ViewDragHelper内部的私有方法,实现对capturedView进行移动。
/**
* 手指释放时的回调以及相关方法
*/
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel)
{
if(releasedChild == mAutoBackView)
{
mDragger.settleCapturedViewAt(mAutoBackOriginPos.x, mAutoBackOriginPos.y);
invalidate();
}
}
@Override
public void computeScroll()
{
if(mDragger.continueSettling(true))
{
invalidate();
}
}这里调用了ViewDragHelper中的settleCapturedViewAt()方法,让CapturedView移动到指定位置。内部使用scroller对象实现移动的。所以需要调用invalidate(),并配合computeScroll()方法实现移动。
//边界拖动时的回调
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId)
{
mDragger.captureChildView(mEdgeTrackerView, pointerId);
}
//enable EdgeTracking
mDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);Callback.onEdgeDragStarted()方法在用户touch屏幕edge的时候触发,在里面我们调用ViewDragHelper的captureChildView()方法设置第三个子view为capturedView;当然还需要手动enable EdgeTracking。
下面贴一下其他方法
@Override
protected void onFinishInflate()
{
super.onFinishInflate();
mDragView = getChildAt(0);
mAutoBackView = getChildAt(1);
mEdgeTrackerView = getChildAt(2);
}
@Override
//初始化,第二个子view的初始位置
protected void onLayout(boolean changed, int l, int t, int r, int b)
{
super.onLayout(changed, l, t, r, b);
mAutoBackOriginPos.x = mAutoBackView.getLeft();
mAutoBackOriginPos.y = mAutoBackView.getTop();
}最后,还有一点需要说明。如果把三个子view的clickable设置为true,即让子view能够消耗事件。你会发现原先能拖拽的子view现在已经无法移动。
解释一下这种情况:
- 如果子view不能消耗事件,那么事件(从DOWN到UP)最终都进ViewGroup的onTouchEvent()方法, 里面调用mDragger.processTouchEvent(event);这样ViewDragHelper的processTouchEvent()方法能正常通过dragTo()方法移动子view
- 如果子view消耗了事件,那么就得根据mDragger.shouldInterceptTouchEvent(ev)函数的返回值,查看源码你发现你需要重写如下两个方法才能让mDragger.shouldInterceptTouchEvent返回true
@Override
public int getViewHorizontalDragRange(View child)
{
return getMeasuredWidth() - child.getMeasuredWidth();
}
@Override
public int getViewVerticalDragRange(View child)
{
return getMeasuredHeight() - child.getMeasuredHeight();
}上面方法默认返回0。如果返回0,mDragger.shouldInterceptTouchEvent()就返回false。只有getViewHorizontalDragRange和getViewVerticalDragRange返回大于0,才可以对事件进行捕获。
OK,至此Demo2分析完毕。
打算按如下步骤实现MyDrawerLayout类的编写
- 通过自定义ViewGroup实现,重写onMeasure,onLayout实现对自身以及子view测量和布局
- 引入ViewDragHelper实现对LeftMenu的拖拽
- 编写activity和fragment对MyDrawerLayout类进行测试
先贴一下布局文件
<cn.hzh.mydrawerlayout.MyDrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/id_drawer_layout"
tools:showIn="@layout/activity_main"
tools:context=".MainActivity">
<RelativeLayout
android:clickable="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#44ff0000">
<TextView
android:id="@+id/id_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_centerInParent="true"
android:text="hello world"
android:textSize="22sp"/>
</RelativeLayout>
<FrameLayout
android:id="@+id/id_container_menu"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#4400ff00">
</FrameLayout>
</cn.hzh.mydrawerlayout.MyDrawerLayout>第一个子view显示内容;第二个子view显示左侧菜单;
OK,下面分析代码
- 1.首先对子view测量和布局
public class MyDrawerLayout extends ViewGroup
{
private static final int MIN_Drawer_MARGIN_RIGHT = 64; //dp
//MenuView完全展开时,离容器的右边距:64dp
private int mMinDrawerRightMargin;
//控制LeftMenu显示宽度的比例值: 0.0f ~ 1.0f
private float mMenuWidthRatio = 0.0f;
private View mContentView;
private View mMenuView;
public MyDrawerLayout(Context context, AttributeSet attrs)
{
super(context, attrs);
//在160dpi的屏幕上,density=1
float density = getResources().getDisplayMetrics().density;
//64dp对应的像素值
mMinDrawerRightMargin = (int) (MIN_Drawer_MARGIN_RIGHT*density + 0.5f);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(width, height);
//measure child
View leftMenuView = getChildAt(1);
MarginLayoutParams lp = (MarginLayoutParams) leftMenuView.getLayoutParams();
int drawerWidthSpec = getChildMeasureSpec(widthMeasureSpec,
mMinDrawerRightMargin + lp.leftMargin+lp.rightMargin, lp.width);
int drawerHeightSpec = getChildMeasureSpec(heightMeasureSpec,
lp.topMargin + lp.bottomMargin, lp.height);
leftMenuView.measure(drawerWidthSpec, drawerHeightSpec);
//measure child
View contentView = getChildAt(0);
lp = (MarginLayoutParams) contentView.getLayoutParams();
int contentWidthSpec = MeasureSpec.makeMeasureSpec(width - lp.leftMargin - lp.rightMargin,
MeasureSpec.EXACTLY);
int contentHeightSpec = MeasureSpec.makeMeasureSpec(height - lp.topMargin - lp.bottomMargin,
MeasureSpec.EXACTLY);
contentView.measure(contentWidthSpec, contentHeightSpec);
mMenuView = leftMenuView;
mContentView = contentView;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b)
{
View menuView = mMenuView;
View contentView = mContentView;
//摆放contentView
MarginLayoutParams lp = (MarginLayoutParams) contentView.getLayoutParams();
contentView.layout(lp.leftMargin, lp.topMargin,
lp.leftMargin+contentView.getMeasuredWidth(), lp.topMargin+contentView.getMeasuredHeight());
//摆放menuView
lp = (MarginLayoutParams) menuView.getLayoutParams();
int menuWidth = menuView.getMeasuredWidth();
//mMenuWidthRatio: 0~1; left: -menuWidth~0
int left = -menuWidth + (int)(menuWidth * mMenuWidthRatio);
menuView.layout(left, lp.topMargin,
left+menuWidth, lp.topMargin + menuView.getMeasuredHeight());
}
@Override
protected LayoutParams generateDefaultLayoutParams()
{
return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
}
@Override
protected LayoutParams generateLayoutParams(LayoutParams p)
{
return new MarginLayoutParams(p);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs)
{
return new MarginLayoutParams(getContext(), attrs);
}
}常量MIN_Drawer_MARGIN_RIGHT用于定义当menu展开的时候,距离ViewGroup右侧的margin,这里写死为64dp。 在构造函数中通过
float density = getResources().getDisplayMetrics().density;
mMinDrawerRightMargin = (int) (MIN_Drawer_MARGIN_RIGHT*density + 0.5f);这两行代码,把64dp转换成设备对应的像素值大小。用变量mMinDrawerRightMargin记录。
在onMeasure方法中确定了mMenuView和mContentView的宽度高度,mMenuView通过ViewGroup的getChildMeasureSpec (int spec, int padding, int childDimension) 方法确定自己的MeasureSpec;mContentView通过MeasureSpec.makeMeasureSpec确定自己的MeasureSpec; 最后调用measure方法进行测量;注意,这里使用MarginLayoutParams,所以要复写相关方法。
接下来在在onLayout方法中进行布局子view。摆放contentView很简单,就不多说了。在摆放menuView使用了比例mMenuWidthRatio 变量来确定menuView的left值。这里使用这种方法确定left值得原因是:下面需要根据用户的touch动态改变mMenuWidthRatio比例值。
下面看效果图:
mMenuWidthRatio = 0.0f
mMenuWidthRatio = 1.0f
- 2.引入ViewDragHelper实现对LeftMenu的拖拽 初始化ViewDragHelper
mDrag = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback()
{
@Override
public boolean tryCaptureView(View child, int pointerId)
{
return child == mMenuView;
}
//将left锁定在-menuWidth ~ 0
@Override
public int clampViewPositionHorizontal(View child, int left, int dx)
{
int width = child.getWidth();
left = left < -width ? -width : left;
left = left > 0 ? 0 : left;
return left;
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel)
{
int width = releasedChild.getWidth();
//offset : 0 ~ 1 展开时为1,隐藏时为0
float offset = (width + releasedChild.getLeft()) * 1.0f / width;
int finalLeft = xvel > 0 || xvel == 0 && offset > 0.5f ? 0 : -width;
int finalTop = releasedChild.getTop();
mDrag.settleCapturedViewAt(finalLeft, finalTop);
invalidate();
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy)
{
int width = changedView.getWidth();
//offset : 0 ~ 1 展开时为1,隐藏时为0
float offset = (width + changedView.getLeft()) * 1.0f / width;
mMenuWidthRatio = offset;
changedView.setVisibility(offset == 0 ? INVISIBLE : VISIBLE);
invalidate();
}
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId)
{
mDrag.captureChildView(mMenuView, pointerId);
}
@Override
public int getViewHorizontalDragRange(View child)
{
return child == mMenuView ? child.getWidth() : 0;
}
});
mDrag.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
mDrag.setMinVelocity(minVel);下面把重写的方法解释一下
- tryCaptureView: 我们是edge触发拖动,为什么还要复写这个方法?因为当leftMenu展开的时候,我们仍需要直接拖动mMenuView
- clampViewPositionHorizontal: 锁定leftMenu的水平移动位置;从代码中可见left范围:-menuWidth ~ 0
- onViewReleased: 释放子view的时候触发;通过计算当前子view显示的百分比以及加速度来决定是否显示mMenuView, 这里注意一点,xvel的值只有大于我们设置的minvel才会大于0,如果小于我们设置的minVel则一直是0
- onViewPositionChanged: 这个回调主要功能就是更新mMenuWidthRatio,以及强制view重绘(invalidate)
- onEdgeDragStarted: 当用户touch屏幕的edge的时候触发,这里手动把mMenuView设置为capturedView
- getViewHorizontalDragRange: 这里上面解释过了,主要第一、设置子view的移动范围;第二、防止子view消耗事件
别忘了把MotionEvent交给ViewDragHelper
@Override
public boolean onInterceptTouchEvent(MotionEvent ev)
{
return mDrag.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event)
{
mDrag.processTouchEvent(event);
return true;
}由于在onViewReleased里面使用了mDrag.settleCapturedViewAt(finalLeft, finalTop);所以别忘了复写computeScroll方法
@Override
public void computeScroll()
{
if(mDrag.continueSettling(true))
{
invalidate();
}
}OK,最后对外公布几个API
public void openDrawer()
{
View menuView = mMenuView;
mMenuWidthRatio = 1.0f;
mDrag.smoothSlideViewTo(menuView, 0, menuView.getTop());
invalidate();
}
public void closeDrawer()
{
View menuView = mMenuView;
mMenuWidthRatio = 0.0f;
mDrag.smoothSlideViewTo(menuView, -menuView.getWidth(), menuView.getTop());
invalidate();
}
public void toggle()
{
if(mMenuWidthRatio == 0.0f)
{
openDrawer();
}else if(mMenuWidthRatio == 1.0f)
{
closeDrawer();
}
}那么,MyDrawerLayout类就分析结束了
- 3.编写Activity和Fragment进行测试
Activity代码:
public class MainActivity extends AppCompatActivity
{
private MyDrawerLayout mDrawerLayout;
private TextView mContent;
private LeftMenuFragment mLeftMenuFragment;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
mDrawerLayout = (MyDrawerLayout) findViewById(R.id.id_drawer_layout);
mContent = (TextView) findViewById(R.id.id_content);
FragmentManager fm = getSupportFragmentManager();
mLeftMenuFragment = (LeftMenuFragment) fm.findFragmentById(R.id.id_container_menu);
if(mLeftMenuFragment == null)
{
mLeftMenuFragment = new LeftMenuFragment();
fm.beginTransaction().add(R.id.id_container_menu, mLeftMenuFragment).commit();
}
mLeftMenuFragment.setOnMenuItemSelectedListener(new LeftMenuFragment.OnMenuItemSelectedListener()
{
@Override
public void onItemSelected(String text)
{
mDrawerLayout.closeDrawer();
mContent.setText(text);
}
});
}
}很简单,就new了一个LeftMenuFragment,然后通过fm添加到activity中去;
LeftMenuFragment代码
public class LeftMenuFragment extends ListFragment
{
private MenuAdapter mAdatper;
String[] strs = new String[]
{"播放列表", "我的乐库", "立即播放"};
MenuItem mDatas[] = new MenuItem[strs.length];
public interface OnMenuItemSelectedListener
{
void onItemSelected(String text);
}
private OnMenuItemSelectedListener mListener;
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
MenuItem item = null;
for(int i = 0; i < mDatas.length; i++)
{
item = new MenuItem(strs[i],
R.mipmap.music_36px, R.mipmap.music_36px_light);
mDatas[i] = item;
}
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState)
{
super.onViewCreated(view, savedInstanceState);
view.setBackgroundColor(0xffffffff);
mAdatper = new MenuAdapter(getActivity(), mDatas);
setListAdapter(mAdatper);
}
@Override
public void onListItemClick(ListView l, View v, int position, long id)
{
super.onListItemClick(l, v, position, id);
if(mListener != null)
{
MenuAdapter adapter = (MenuAdapter) getListAdapter();
mListener.onItemSelected(adapter.getItem(position).text);
}
mAdatper.setSelected(position);
}
public void setOnMenuItemSelectedListener(OnMenuItemSelectedListener listener)
{
this.mListener = listener;
}
public class MenuAdapter extends ArrayAdapter<MenuItem>
{
private LayoutInflater mInflater;
//这个变量实现 选中后状态改变
private int mSelected;
private Context mContext;
private int mIconSize;
public MenuAdapter(Context context, MenuItem[] mDatas)
{
super(context, -1, mDatas);
mContext = context;
mInflater = LayoutInflater.from(context);
//24dp
mIconSize = mContext.getResources().getDimensionPixelSize(R.dimen.drawer_icon_size);
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
if(convertView == null)
{
convertView = mInflater.inflate(R.layout.drawer_menu_item, parent, false);
}
TextView tv = (TextView) convertView;
tv.setBackgroundColor(Color.TRANSPARENT);
tv.setText(getItem(position).text);
Drawable icon = mContext.getResources().getDrawable(getItem(position).icon);
icon.setBounds(0,0,mIconSize, mIconSize);
TextViewCompat.setCompoundDrawablesRelative(tv, icon, null, null, null);
if(position == mSelected)
{
tv.setBackgroundColor(0x4400ff00);
icon = mContext.getResources().getDrawable(getItem(position).iconSelected);
icon.setBounds(0,0,mIconSize, mIconSize);
TextViewCompat.setCompoundDrawablesRelative(tv, icon, null, null, null);
}
return convertView;
}
public void setSelected(int position)
{
this.mSelected = position;
notifyDataSetChanged();
}
}
public class MenuItem
{
String text;
int icon;
int iconSelected;
public MenuItem(String text, int icon, int iconSelected)
{
this.text = text;
this.icon = icon;
this.iconSelected = iconSelected;
}
}
}- MenuItem以及adapter都已内部类的形式写里面了。
- 在MenuAdapter中对外公布了setSelected方法,这个方法的作用就是: 外面选中某一个Item的时候设置选中后的Item的状态。
下面贴上drawer_menu_item.xml布局文件
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeightSmall"
android:drawablePadding="32dp"
android:gravity="center_vertical|start"
android:maxLines="1"
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
android:paddingRight="?attr/listPreferredItemPaddingRight"
android:textAppearance="?attr/textAppearanceListItem"
android:textColor="?android:attr/textColorPrimary"/>OK,最后看一下效果:
<img src="final.gif", width="320px"/>



