android自定义viewgroup
实现了一个有点意思的自定义布局,有点类似于android主页的app图标布局。主要效果可以归结为如下四点:
1、支持4个View2行2列排列,也可以支持一个View最大化显示。
2、在2x2排列时,可以进行拖动,交换2个View的位置。
3、可以支持拖动来删除(达到删除状态执行自定义的操作)。
4、在放大状态,能通过左右滑动进行前后View的切换。
要实现这样一种效果,会涉及到一些如自定义布局、view拖动、滑动等技术。在实现过程中也会碰到很多问题,在此对在开发中遇到的一些问题进行总结。先上效果图:


二、问题分解和细化
为了实现上述效果,主要有以下一些关键点:一是要自定义一种布局,可以显示1个View的布局,也可以显示4个View(2行2列)的布局;二是要实现View的拖动以及位置的交换;三是可以支持View左右滑动切换,类似于viewpager的功能。整体功能效果有点类似于android系统的应用图标管理,只不过这里拖动图标时是交换2个相距最近的图标的位置,而android系统拖动时是直接将拖动的图标定位到手放开时的位置。
三、自定义布局
布局的话,可以采用自定义布局,当显示4个view时,4个view按照2行2列进行排列,当切换到单个view布局时,将选定的view宽高调整为父view的宽高,其他3个view进行隐藏(后面考虑到要在处于单view时进行左右view切换,将其他3个view按顺序排列在被选中view的2侧或1侧)。
android自定义viewgroup需要先继承于ViewGroup。有2个方法需要进行重写onMeasure和onLayout。onMeasure方法中主要进行根据childView的测量模式对childView的大小进行测量,在综合viewGroup自身的测量模式确定自身的宽、高。
3.1 测量onMeasure
android中view有三种测量模式:
EXACTLY:表示设置了精确的值,一般当childView设置其宽、高为精确值、match_parent时,ViewGroup会将其设置为EXACTLY;
AT_MOST:表示子布局被限制在一个最大值内,一般当childView设置其宽、高为wrap_content时,ViewGroup会将其设置为AT_MOST;
UNSPECIFIED:表示子布局想要多大就多大,一般出现在AadapterView的item的heightMode中、ScrollView的childView的heightMode中;此种模式比较少见。
而在此场景中子view的宽、高都是根据其父view来所固定确定的。而父view则要根据所设定的测量模式来确定大小。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
/** 获得此ViewGroup上级容器为其推荐的宽和高,以及计算模式 */
super.onMeasure(widthMeasureSpec,heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
//将子view的长宽都置为父view的一半
childWidth = sizeWidth/ROW_NUM;
childHeight = sizeHeight/ROW_NUM;
// Log.e(TAG, "onMeasure: father width="+sizeWidth+" father height="+sizeHeight);
int childWidthSppec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY);
int childHeightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY);
//判断是否有子view要进行放大,要放大的子view将长度置为父view的长宽
if (zoomIndex == ZOOM_INDEX_NONE) {
measureChildren(childWidthSppec, childHeightSpec);
} else {
int count = getChildCount();
for (int i = 0; i < count; i++) {
View childView = getChildAt(i);
int index = (int) childView.getTag();
// if (index == zoomIndex) {
int zoomChildWidthSpec = MeasureSpec.makeMeasureSpec(sizeWidth, MeasureSpec.EXACTLY);
int zoomChildHeightSpec = MeasureSpec.makeMeasureSpec(sizeHeight, MeasureSpec.EXACTLY);
measureChild(childView, zoomChildWidthSpec, zoomChildHeightSpec);
// } else {
// measureChild(childView,childWidthSppec, childHeightSpec);
// }
}
}
}
关于viewgroup的测量,有些文章理论感觉写得很复杂。我感觉简单了讲大部分情形可以分为2个思考方向:1、这个viewgroup大小是不是主要由其childview确定,对应于该viewgroup在xml中宽高设的WRAP_CONTENT;2、这个viewgroup的大小由其父viewgroup确定,主要对应于xml中的属性设定为指定宽高或MATCH_PARENT。
了解下相关 onMeasure(int widthMeasureSpec, int heightMeasureSpec) 方法中宽、高都是int。MeasureSpec的getMode(int measureSpec)方法如下:
private static final int MODE_SHIFT = 30;
private static final int MODE_MASK = 0x3 << MODE_SHIFT;
/**
* Extracts the mode from the supplied measure specification.
*
* @param measureSpec the measure specification to extract the mode from
* @return {@link android.view.View.MeasureSpec#UNSPECIFIED},
* {@link android.view.View.MeasureSpec#AT_MOST} or
* {@link android.view.View.MeasureSpec#EXACTLY}
*/
@MeasureSpecMode
public static int getMode(int measureSpec) {
//noinspection ResourceType
return (measureSpec & MODE_MASK);
}
getMode方法是为了获取测量模式,其通过位运算,获取到高2位的mode(测量模式)。MODE_MASK = 0x3 << MODE_SHIFT,0x3就是十六进制的3,换成二进制就是11,左移30位,就是1100···0000,总共32位,前2位是1,后30位都是0。measureSpec & MODE_MASK就是进行位与运算。
/**
* Extracts the size from the supplied measure specification.
*
* @param measureSpec the measure specification to extract the size from
* @return the size in pixels defined in the supplied measure specification
*/
public static int getSize(int measureSpec) {
return (measureSpec & ~MODE_MASK);
}
getSize()方法则是通过位运算,拿到低30位的数值,高2位都是0。
makeMeasureSpec方法根据给定的大小和模式,创建测量格式。
public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
@MeasureSpecMode int mode) {
if (sUseBrokenMakeMeasureSpec) {
return size + mode;
} else {
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
}
看下else分支代码,又是位运算,有点巧妙哈。由前面分析可知,测量模式mode由int型的高2位表示,而具体的大小size由低30位表示,所以整个表达式即2个()中间的或(“|”)运算符的效果相当与就是拼接低30位和高2位,得到或者叫组成新的测量值。
3.2 布局onLayout
onLayout方法主要用于控制子view在父view中的显示位置。位置的参数的设置,通过调用子view的layout方法来进行设定。
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
int childWidth = sizeWidth / ROW_NUM;
int childHeight = sizeHeight / ROW_NUM;
int left = 0;
int top = 0;
for (int i = 0; i < count; i++) {
View childView = getChildAt(i);
int oriIndex = (int) childView.getTag();
//1、正常分割显示情况
if (zoomIndex == ZOOM_INDEX_NONE) {
left = (oriIndex % ROW_NUM) * childWidth;
top = (oriIndex / ROW_NUM) * childHeight;
childView.layout(left, top, left + childWidth, top + childHeight);
if (childView.getVisibility() == INVISIBLE || childView.getVisibility() == GONE) {
childView.setVisibility(VISIBLE);
}
} else { //2、选中的子view要放大(充满父view的情况)
if (zoomIndex == oriIndex) {
childView.layout(0, 0, sizeWidth, sizeHeight);
} else {
// Log.e(TAG, "onLayout: 放大 正常分割大小");
//适应scroll page的适配,将子view排成一排
int indexDis = oriIndex - zoomIndex;
left = indexDis * sizeWidth;
top = 0;
childView.layout(left, top, left + sizeWidth, top + sizeHeight);
}
}
}
}
layout方法总共需要传入4个参数,即通过left(左)、top(上)、right(右)、bottom(底/下),其实4个参数也就是确定了2个坐标点,左上角、右下角2个坐标位置。则这个view所占用的区域也就确定了。用一张网上找的view的坐标系图,看着更明了。

剩下的就是计算来产生这些坐标点,这里行数、列数一样,用ROW_NUM =2 来表示。目前总共4个view,按照2x2来排列,计算出每个view左上角坐标(left、top),其右下角坐标就是(left+childWidth,top+childHeight)。如此来进行排列。
四 使用ViewDragerHelper实现View的拖拽
实现View的拖拽一般思路可能会想自己监听手势滑动事件,重写onTouchEvent方法等。其实对于View的拖拽,Google官方提供了一个工具类ViewDragHelper。所以尝试下不同的方式,使用ViewDragHelper来实现拖拽。
ViewDragHelper是Google在v4包中增加的一个工具类,方便开发者实现View拖拽(ps:官方的DrawerLayout就是用此类实现)。ViewDragHelper的使用也不是很复杂。
private void initDragHelper(){
mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
Log.e(TAG, "tryCaptureView: ");
if (zoomIndex == ZOOM_INDEX_NONE) {
//当拖拽的view正在回弹时,不允许再进行拖拽
if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_SETTLING) {
return false;
} else {
child.bringToFront();
}
// child.requestLayout();
// invalidate();
return true;
} else {
return false;
}
}
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
super.onViewCaptured(capturedChild, activePointerId);
//将拖动的子view里面的surfaceview顺序置为最上层,解决surfaceview重叠的问题
((SingleRealPlayView)capturedChild).setSurfaceviewOrderOnTop();
mDragOriLeft = capturedChild.getLeft();
mDragOriTop = capturedChild.getTop();
Log.e(TAG, "onViewCaptured: left="+mDragOriLeft+" top="+mDragOriTop+" x="+capturedChild.getX()+" y="+capturedChild.getY());
if (callback != null) {
callback.prepareDeleteRealPlay();
}
isCanDelete = false;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
@Override
public int getViewHorizontalDragRange(View child) {
return getMeasuredWidth()-child.getMeasuredWidth();
}
@Override
public int getViewVerticalDragRange(View child) {
return getMeasuredHeight()-child.getMeasuredHeight();
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
//去除删除标志
if (callback != null) {
callback.cancelOrFinishDelete();
}
if (isCanDelete) { //判断是否处于删除状态
deleteSingleRealPlay(releasedChild);
}else if (findExchangeItemView(releasedChild)) {
//判断什么情况下view弹回,什么情况下进行两个子view位置交换
} else {
Log.e(TAG, "onViewReleased: go back");
mDragHelper.settleCapturedViewAt(mDragOriLeft, mDragOriTop);
invalidate();
}
isCanDelete = false;
}
@Override
public void onViewDragStateChanged(int state) {
super.onViewDragStateChanged(state);
Log.e(TAG, "onViewDragStateChanged: state="+state);
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
// Log.e(TAG, "onViewPositionChanged: left="+left+" top="+top+" dx="+dx+" dy="+dy);
// Log.e(TAG, "onViewPositionChanged: location on screen x="+location[0]+" y="+location[1]);
//当是拖动状态时,判断被拖动的子View与删除标志view的位置关系
if (deleteFlagView != null && mDragHelper.getViewDragState()== ViewDragHelper.STATE_DRAGGING) {
int[] location = new int[2];
changedView.getLocationOnScreen(location);
int[] locDeleteView = new int[2];
deleteFlagView.getLocationOnScreen(locDeleteView);
int delHeight = deleteFlagView.getHeight();
int delCenterHeight = locDeleteView[1]+delHeight/2;
int changeViewCenterHeight = location[1]+childHeight/2;
if (changeViewCenterHeight < delCenterHeight) {
Log.e(TAG, "onViewPositionChanged: 达到删除条件了,让删除view置为选中");
Log.e(TAG, "onViewPositionChanged: viewdrager 状态"+mDragHelper.getViewDragState());
if (callback != null) { //达到删除条件
callback.satisfyDeleteRealPlayCondition();
isCanDelete = true;
}
} else {
if (callback != null) {
callback.prepareDeleteRealPlay();
isCanDelete = false;
}
}
}
}
});
}
4.1 重写相关方法
ViewDragHelper里面有很多方法需要重写。
(一) 首先看下tryCaptureView方法,public abstract boolean tryCaptureView(View child, int pointerId);当用户想捕获这个childView时调用此方法,返回true表示用户想捕获这个childView,才会执行后面的拖拽。返回false代表不捕获这个childView。pointerId是代表多点触控的参数。
(二) 再看onViewCaptured方法,public void onViewCaptured(View capturedChild, int activePointerId)。当childView被捕获,准备进行拖拽前调用。
(三) onViewReleased方法,public void onViewReleased(View releasedChild, float xvel, float yvel)。手指拖拽放开时的回调。
(四)onViewPositionChanged方法,public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) 拖拽或放开时被拖拽的childView位置变动。
要想ViewDragHelper起作用,还需要拦截触摸事件,在view的触摸事件交给ViewDragHelper来处理。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
4.2 拖拽过程中的问题
先看tryCaptureView中的代码,拖拽过程中多个childview的重叠问题,重叠区域怎么显示。
child.bringToFront();
这个代码是什么意思呢,其效果是将当前拖拽的childView移到最上层,View的bringToFront方法最终调用的是ViewGroup的bringChildToFront。
@Override
public void bringChildToFront(View child) {
final int index = indexOfChild(child);
if (index >= 0) {
removeFromArray(index);
addInArray(child, mChildrenCount);
child.mParent = this;
requestLayout();
invalidate();
}
}
每个view是按照一定的先后顺序(索引)添加到ViewGroup中的,ViewGroup在绘制时也是按照这个索引顺序来先后进行绘制,所以如果各个子view有重叠的部分,则因为index大的子view是后面画的,所以重叠的部分就会显示index大的子view的内容。ViewGroup维持了一个View数组,而这个函数的作用其实就是将这个view移到数组的最末端。
但这会引入另一个问题,在前面ViewGroup的onLayout中进行子View的排列布局的时候,如果也是根据其索引index来排序,假设我当前拖动第一个view,未拖动前其索引为0(View1)、1(View2)、2(View3)、3(View4),一拖动因为调用了bringToFront方法,索引变成了3(View1)、0(View2)、1(View3)、2(View4),则还是以2x2布局排列时,原来是左上角的则会被排到右下角,所有View的位置都变了。所以在onLayout中,进行布局排列时所用的子view的索引由自己维护,当2个子view由于拖拽满足位置交换条件时,索引也进行交换。
4.3 View拖拽释放后的情况判断
如何判断被拖拽的view与周围其他view的位置关系。可以从这几个方面来考虑。当view被捕获的时候,记录其原始的位置,方便后面放开回弹到此位置,或跟其他view进行交换位置。view被拖拽后,最终操作结果可分为3种情况。
(一) 达到删除条件
当进行拖拽时,显示删除标志view,当被拖拽的view与删除标志view的位置关系满足一定条件时,即认为达到删除条件,可以执行删除操作。
这里判断被拖动的view的位置是否达到了deleteFlagView的中心高度以上。达到则认为达到了删除条件。
(二) 达到与其他view位置交换条件
如何判断是否达到与其他view位置交换的条件。可以简化为2个条件:


1 与其他view的距离越来越近,对比值为未拖动前的距离。
2 在满足条件1的情况下,从中找出最近的那个view。
因为view拖动时,被拖动的VIEW可能与其他VIEW距离越来越远,也可能与其中一部分VIEW变近,一部分距离变远。看下距离比对逻辑代码:
float childX = childView.getX();
float childY = childView.getY();
int childWidth = childView.getWidth();
int childHeight = childView.getHeight();
float desCenterX = childX + childWidth/2;
float desCenterY = childY + childHeight/2;
float oriCenterX = releasedChid.getX()+childWidth/2;
float oriCenterY = releasedChid.getY()+childHeight/2;
float distance = (float) Math.sqrt(Math.pow((desCenterX-oriCenterX),2)+ Math.pow((desCenterY-oriCenterY),2));
float distanceRefer = (float) Math.sqrt(Math.pow((desCenterX-(mDragOriLeft+childWidth/2)),2)+ Math.pow((desCenterY-(mDragOriTop+childHeight/2)),2));
//方法2 判断item位置时,水平、垂直方向差值都小于某个比值时,则认为找到了该item,否则认为没找到
// 移动后与移动前距离比值
float rate = distance / distanceRefer;
if (rate <= 0.3) {
if (!isFound) { //还没找到一个符合条件的item
isFound = true;
result = true;
selDis = distance;
selView = childView;
} else { //找到了多于一个符合条件的item,对距离进行对比,选择距离更小的那一个
if (distance < selDis) {
selDis = distance;
selView = childView;
}
}
}
而我们只取距离比未拖动前距离变小,而且只取最小的距离对应的VIEW进行位置交换。
distance计算出的就是当前拖拽时被拖拽view与其他一个view的距离,而distanceRefer则是未拖拽前2者之间的距离,当拖拽后距离小于拖拽前距离的30%以下时,认为达到了位置交换条件。30%只是个经验值,因为不能说拖拽时距离稍微一变小就交换位置,肯定得小到一定程度。
(三)其他情况,view进行位置回弹,什么都不做
其他情况就是除了删除,位置交换以外的其他情况。
五 Scroller实现类似viewpager滑动
Scroller本身并不能实现View的滑动,最终的滑动还是scrollTo/scrollBy实现的,而scrollTo/scrollBy的滑动使瞬间完成的。而Scroller的主要目的使为了使滑动更平滑,Scroller的作用是通过计算产生到目标距离的一些列中间值。然后View重写computeScroll方法,在此方法中调用scrollTo/scrollBy来达到真正使View平滑滑动的效果。
scrollTo/scrollBy滑动的使View中的内容。而我们要从View1通过滑动,滑动到View2,就需要调用其parent中的scrollTo/scrollBy方法。scrollTo是相对于初始位置来进行移动的,而scrollBy(int x ,int y)则是相对于上一次移动的距离来进行本次移动。
因为scrollTo/scrollBy方法移动的内容,所以跟直接移动View位置是不一样的。简单对比,将View通过移动位置,偏移量(10,10),则效果就是将View网右下角x轴 y轴方向都移动了10个像素。而通过scrollBy方法实现同样效果,则需要调用View的父View,scrollBy(-10,-10),将内容往左上角x轴 y轴滑动-10个像素。即将屏幕理解成一个相框,将相框往左上角移,来达到同样的视觉效果。
svRealPlay.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// Log.e(TAG, "onTouch: down");
lastX = (int) event.getX();
lastY = (int) event.getY();
if (isOnZoom == false) {
zoomIndex = getChildIndex();
}
break;
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
Log.e(TAG, "onTouch: move");
View viewGroupOri = (View) getParent();
int scrollX = viewGroupOri.getScrollX();
int scrollY = viewGroupOri.getScrollY();
Log.e(TAG, "onTouchEvent: scrollX=" + scrollX + " scrollY=" + scrollY + " offsetX=" + offsetX + " childWidth=" + getMeasuredWidth());
if (isOnZoom) {
((View) getParent()).scrollBy(-offsetX, 0);
}
break;
case MotionEvent.ACTION_UP:
Log.e(TAG, "onTouch: up");
// 手指离开时,执行滑动过程
View viewGroup = (View) getParent();
//进行判断,应该往左翻到前一个预览界面、往右翻到后一个预览界面、还是回弹
int childWidth = getMeasuredWidth();
int curScrollX = viewGroup.getScrollX();
int curScrollY = viewGroup.getScrollY();
int curChildIndex = getChildIndex();
int indexDes = curChildIndex - zoomIndex;
// 方法2 可以在4个view中任意进行左右滑动
//当前view显示出来,父view所应滚动的距离
if (isOnZoom) {
int curViewPostionX = (curChildIndex - zoomIndex) * childWidth;
int parentChildCount = ((ViewGroup) getParent()).getChildCount();
if (curScrollX != 0 && (curScrollX < curViewPostionX - childWidth / 2) && curChildIndex != 0) { //往左滑,切换到上一个画面
int disLeft = (curChildIndex - 1 - zoomIndex) * childWidth - curScrollX;
Log.e(TAG, "onTouch: curviewIndex=" + curChildIndex + " 滑动" + " disLeft=" + disLeft + " curScrollX=" + curScrollX + " curViewPostionX=" + curViewPostionX + " zoomIndex=" + zoomIndex);
mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), disLeft, 0);
invalidate();
} else if (curScrollX != 0 && (curScrollX > curViewPostionX + childWidth / 2) && curChildIndex != parentChildCount - 1) { //往右,切换到下一个画面
int disRight = (curChildIndex + 1 - zoomIndex) * childWidth - curScrollX;
Log.e(TAG, "onTouch: curviewIndex=" + curChildIndex + " 滑动" + " disRight=" + disRight + " curScrollX=" + curScrollX + " curViewPostionX=" + curViewPostionX + " zoomIndex=" + zoomIndex);
mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), disRight, 0);
invalidate();
} else {
int disOther = curViewPostionX - curScrollX;
Log.e(TAG, "onTouch: curviewIndex=" + curChildIndex + " 滑动" + " disOther=" + disOther + " curScrollX=" + curScrollX + " curViewPostionX=" + curViewPostionX + " zoomIndex=" + zoomIndex);
mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), disOther, 0);
invalidate();
}
}
break;
}
return false;
}
});
主要看ACTION_UP时的处理。处理的结果有3种:
1 手势向右滑,向左滑动切换到前一个view。
2 手势向左滑,向右滑动切换到后一个view。
3 手势滑动到距离不够,回弹到原来到位置。
zoomIndex是左上角刚好在其parent view的(0,0)位置处的view的索引,宽高也正好等于parent view的宽 高。所以Scroller滑动的距离是以zoomIndex所在View的位置为基准的。手势滑动的距离要大于View宽度的1/2,才会决定是否要进行view(页面)切换。所以简易版viewpager的原理也不过如此,完全可以自定义实现一个viewpager。