1.1.继承 View
这种方法主要用于实现一些不规则的效果(不方便通过布局的组合方式来实现),比如静态或动态地显示一些不规则的图形(因此需要重写onDraw方法)。值得注意的是,继承View的自定义View需要自己制定 wrap_content 的尺寸,并且需要自己处理padding属性。
1.2.继承 ViewGroup
这种方法主要用于实现自定义布局,当某种效果看起来很像几种View组合在一起的时候,可以采用这种方法来实现。值得注意的是,继承ViewGroup的自定义布局需要妥善处理measure、layout过程,同时处理好子元素的measure、layout过程。
1.3.继承特定的 View(比如TextView)
这种方法比较常见,通常用于扩展某个已知View的功能。这种方法的自定义View不需要设置 wrap_content 的尺寸以及处理padding属性。
1.4.继承特定的 ViewGroup(比如LinearLayout)
其实第四种方法和第二种方法都是用来实现自定义布局的,而且实现的效果也很相近。通常而言,方法2能实现的效果方法4也都能实现,两者的主要区别在于方法2更接近于View的底层。方法4使用起来更简单,因为不需要自己处理ViewGroup的measure、layout过程。
2.1.让View支持 wrap_content 属性
其实也就是制定自定义控件 wrap_content 时的默认尺寸。这是因为直接继承View或者ViewGroup的控件,如果不在onMeasure中指定 wrap_content 时的默认尺寸,那么在使用该属性的时候会和match_parent效果一样。具体原因可以参考我之前的博文《measure过程分析》。
2.2.如果使用到了padding属性,需要自己处理它以使属性生效
这是因为直接继承 View 的控件,如果不在 draw 方法中处理 padding,那么 padding 属性是无法起作用的。另外直接继承自ViewGroup 的控件需要在 onMeasure 和 onLayout 中考虑 padding 和子元素的 margin 对其造成的影响,不然将导致自己的 padding 属性和子元素的 margin 属性失效。
2.3.尽量不要在View中使用Handler
这是因为 View 内部本身就提供了 post 系列的方法, 完全可以替代 Handler 的作用,当然除非我们很明确的要使用 Handler 来发送消息。
2.4.View中如果有线程或者动画,需要及时停止,参考View#onDetachedFromWindow
如果有线程或者动画需要停止时,那么 onDetachedFromWindow 是一个很好的时机。当包含此View的Activity退出或者当前View被remove时,View的 onDetachedFromWindow 方法会被调用,和此方法对应的是 onAttachedToWindow,当包含此 View 的Activity 启动时,View的 onAttachedToWindow方法会被调用。同时,当View变得不可见时我们也需要停止线程和动画,以免内存泄漏。
2.5.当自定义控件带有滑动嵌套时,需要处理滑动冲突
如果自定义控件中存在滑动嵌套的情况,如果不加以处理,那么就会严重影响View的效果。
3.1.自定义View的初步实现:
public class CustomView extends View {
private int mColor = Color.BLUE;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public CustomView(Context context) {
super(context);
init();
}
public CustomView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs, 0);
init();
}
public CustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init(){
mPaint.setColor(mColor);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width,height)/2;
canvas.drawCircle(width/2,height/2,radius,mPaint);
}
}
上述代码设置好画笔颜色之后,会根据View的宽高,取较小值为直径画一个圆。然而这样绘制的自定义View,padding属性以及wrap_content是不会生效的。如下:
3.2.使自定义View支持padding属性以及wrap_content:
不难看出,margin属性虽然生效了,但是padding属性没有效果,而且wrap_content设置的效果和match_parent效果一样。因此为了让自定义View更加完整我们应该对之前的代码进行修改,如下:
public class CustomView extends View {
private int mColor = Color.BLUE;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private int mWidth = 200;
private int mHeight = 200;
......
//记得要处理好padding属性
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddingBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddingBottom;
int radius = Math.min(width,height)/2;
canvas.drawCircle(width/2 + paddingLeft,height/2 + paddingTop,radius,mPaint);
}
@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);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(mWidth,mHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(mWidth,heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(widthSpecSize,mHeight);
}
}
}
这里给自定义View设置了wrap_content的默认宽高,这么一来就不会把可用空间全部占用了。
3.3.为自定义View添加自定义属性:
1.在values目录下创建自定义属性的XML文件,比如attr.xml。文件内容如下:
在上面的XML中声明了一个自定义属性集合“CustomView”,在这个集合里面可以有很多自定义属性,这里只定义了一个格式为“color”的属性“circle_color”。除了颜色格式,自定义属性还有其他格式,比如 reference 是指资源id;dimension 是指尺寸;而像string、integer、boolean是指基本数据类型,等等。
2.在View的构造方法中解析自定义属性的值并做相应处理。
public CustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.CustomView);
mColor = array.getColor(R.styleable.CustomView_circle_color,mColor);
array.recycle();
init();
}
首先加载自定义属性集合CustomView,接着解析CustomView属性集合中的circle_color属性,它的id为R.styleable.CustomView_circle_color。后面那个参数是默认颜色值。然后别忘了读取xml文件调用的是两个参数的那个构造器,因此要做如下修改:
public CustomView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
init();
}
3.最后在xml文件中使用自定义属性
4.1.自定义布局的场景:
该自定义布局是一个可横向滑动的布局,测试的时候向其内部添加了三个纵向滑动的ListView,以模拟滑动冲突的场景。除了要解决滑动冲突的问题之外,和自定义View一样还要处理自身的padding,以及子元素的margin。下面是测试用的Activity代码:
public class MainActivity extends AppCompatActivity {
private HorizontalScrollView mListContainer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.demo_1);
initView();
}
private void initView(){
mListContainer = findViewById(R.id.container);
LayoutInflater inflater = getLayoutInflater();
for(int i=0;i<3;i++){
ViewGroup layout = (ViewGroup) inflater.inflate(R.layout.content_layout,mListContainer,false);
TextView textView = layout.findViewById(R.id.title);
textView.setText("page "+(i+1));
layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),0));
createList(layout);
mListContainer.addView(layout);
}
}
private void createList(ViewGroup layout){
ListView listView = layout.findViewById(R.id.list);
ArrayList datas = new ArrayList<>();
for(int i=0;i<50;i++){
datas.add("name "+i);
}
ArrayAdapter adapter = new ArrayAdapter(this,R.layout.content_list_item,R.id.name,datas);
listView.setAdapter(adapter);
}
}
4.2.先要重写onMeasure方法,以得出该自定义布局的尺寸
具体的处理思路为:先遍历所有子元素并调用它们的measure方法,然后再结合子元素的宽高测量出自己(自定义布局)的宽高值。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
final int childCount = getChildCount();
int measureWidth = 0;
int measureHeight = 0;
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddingBottom = getPaddingBottom();
ViewGroup.LayoutParams lp = getLayoutParams();
final View childView = getChildAt(0);
MarginLayoutParams clp = (MarginLayoutParams) childView.getLayoutParams();
mChildLp = clp;
measureChildren(MeasureSpec.makeMeasureSpec(widthSpecSize - clp.leftMargin - clp.rightMargin, widthSpecMode),
MeasureSpec.makeMeasureSpec(heightSpecSize - clp.topMargin - clp.bottomMargin, heightSpecMode));
if (childCount == 0) {
if (lp.width >= 0) {
measureWidth = lp.width;
} else if (lp.width == LayoutParams.MATCH_PARENT) {
measureWidth = widthSpecSize;
} else if (lp.width == LayoutParams.WRAP_CONTENT) {
measureWidth = 0;
}
if (lp.height >= 0) {
measureHeight = lp.height;
} else if (lp.height == LayoutParams.MATCH_PARENT) {
measureHeight = heightSpecSize;
} else if (lp.height == LayoutParams.WRAP_CONTENT) {
measureHeight = 0;
}
setMeasuredDimension(measureWidth, measureHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
measureWidth = (childView.getMeasuredWidth() + clp.leftMargin + clp.rightMargin) * childCount + paddingLeft + paddingRight;
measureHeight = childView.getMeasuredHeight() + clp.topMargin + clp.bottomMargin + paddingTop + paddingBottom;
setMeasuredDimension(measureWidth, measureHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
measureWidth = (childView.getMeasuredWidth() + clp.leftMargin + clp.rightMargin) * childCount + paddingLeft + paddingRight;
setMeasuredDimension(measureWidth, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
measureHeight = childView.getMeasuredHeight() + clp.topMargin + clp.bottomMargin + paddingTop + paddingBottom;
setMeasuredDimension(widthSpecSize, measureHeight);
}
}
在上述代码中值得注意的是,通过measureChildren方法就可以遍历所有子元素并调用它们的measure方法,但是这种方法并没有将子元素的margin属性考虑进去(父容器自身的padding属性有考虑进去),因此需要加以处理。但是直接将子元素的LayoutParams 转换为 MarginLayoutParams 是会报错的(类型转换异常)。因此还需要在自定义布局中,添加如下代码:
// 继承自margin,支持子视图android:layout_margin属性
public static class LayoutParams extends MarginLayoutParams {
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(ViewGroup.LayoutParams source) {
super(source);
}
public LayoutParams(ViewGroup.MarginLayoutParams source) {
super(source);
}
}
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p);
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
4.3.然后重写onLayout方法
具体的处理思路为:先遍历所有子元素,如果这个子元素不是处于GONE这个状态,那么就通过layout方法将其放置在合适的位置上。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childLeft = 0;
final int childCount = getChildCount();
mChildrenSize = childCount;
final int paddingLeft = getPaddingLeft();
final int paddingTop = getPaddingTop();
childLeft += paddingLeft;
for (int i = 0; i < childCount; i++) {
final View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {
MarginLayoutParams clp = (MarginLayoutParams) childView.getLayoutParams();
childLeft += clp.leftMargin;
final int childWidth = childView.getMeasuredWidth();
mChildWidth = childWidth;
final int childHeight = childView.getMeasuredHeight();
childView.layout(childLeft, paddingTop + clp.topMargin, childLeft + childWidth, paddingTop + clp.topMargin + childHeight);
childLeft += childWidth + clp.rightMargin;
}
}
}
4.4.妥善处理滑动冲突
首先分析当前的滑动冲突,外层横向滑动,内层竖向滑动。因此我这里采用的解决办法是根据滑动距离判断当前滑动属于哪一种滑动的外部拦截法。如果是横向滑动,则父容器拦截当前事件并加以处理(调用onTouchEvent方法);如果是竖向滑动,则父容器不予拦截,让事件继续向下传递。代码如下:
public class HorizontalScrollView extends ViewGroup {
private static final String TAG = "HorizontalScrollView";
private int mChildIndex;
private int mChildWidth;
private int mChildrenSize;
private MarginLayoutParams mChildLp;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
//记录上次滑动坐标(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
//记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
public HorizontalScrollView(Context context) {
super(context);
init();
}
public HorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
if (mScroller == null) {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {//如果上次滑动没有完成
mScroller.abortAnimation();//优化滑动体验
intercepted = true;//全交由父容器处理
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true;//如果是水平滑动,则父容器拦截
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
Log.d(TAG, "intercepted: " + intercepted);
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);//只水平滑动,注意右滑mScrollX为负,即传参为负内容向右滑动
break;
case MotionEvent.ACTION_UP://松手后的滑动动画处理
int scrollX = getScrollX();//注意这里拿到的mScrollX(scrollX),内容向右偏移的时候,mScrollX值为负
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {//快滑
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {//慢滑
mChildIndex = (scrollX + mChildWidth/2)/mChildWidth;
}
mChildIndex = Math.max(0,Math.min(mChildIndex,mChildrenSize-1));//不超边界
int dx = mChildIndex*(mChildWidth + mChildLp.leftMargin + mChildLp.rightMargin) - scrollX ;
smoothScrollBy(dx,0);//注意这里的滑动:传参为负时,内容向右滑动
mVelocityTracker.clear();
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
......
private void smoothScrollBy(int dx,int dy){
mScroller.startScroll(getScrollX(),0,dx,0,500);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}
上述代码中值得注意的是,为了避免在上一次水平滑动(父容器处理)过程结束前,用户快速的进行竖直滑动,导致界面在水平方向上无法滑动到终点从而处于一种中间状态的情况。在上一次滑动结束前,下一个点击事件序列仍然交由父容器处理:
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {//如果上次滑动没有完成
mScroller.abortAnimation();//优化滑动体验
intercepted = true;//全交由父容器处理
}
break;
其实继承特定的 ViewGroup 和继承 ViewGroup 实现的自定义布局思路都是一样的,相比之下继承现有的ViewGroup(比如LinearLayout)比直接继承ViewGroup 还要简单一些(不用再自己处理那些扰人的margin、padding)。我之所以还要再举个例子主要是想再现另外一种滑动冲突,并加以处理。
5.1.自定义布局的场景:
该自定义布局是一个可纵向滑动的布局,其内部只是简单的放置了一个TextView(Header),然后在它下面再放一个ListView(Content)。这么一来就是滑动冲突的第二个场景:外层纵向滑动,内层也是纵向滑动。
PS:此时的自定义布局继承LinearLayout,因此不用再自己处理margin、padding,也就不用再重写onMeasure、onLayout。
5.2.妥善处理滑动冲突
这里我同样采用外部拦截法来处理滑动冲突。只是处理逻辑不能再依照之前的滑动方向来判定了,具体的判定逻辑如下:
因此,重写 onInterceptTouchEvent 和 onTouchEvent 如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastXIntercept = x;
mLastYIntercept = y;
mLastX = x;
mLastY = y;
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (y <= getHeaderHeight()) {//当事件落在Header上面时,父容器不拦截该事件
intercepted = false;
} else if (Math.abs(deltaY) <= Math.abs(deltaX)) {//视为水平滑动时,不拦截该事件
intercepted = false;
} else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {//Header是展开状态时并且向上滑动,拦截该事件
intercepted = true;
} else if (mGiveUpTouchEventListener != null) {//当ListView滑动到顶部了并且向下滑动时,父容器拦截该事件
if (mGiveUpTouchEventListener.giveUpTouchEvent(ev) && deltaY >= mTouchSlop) {
intercepted = true;
}
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
mLastXIntercept = mLastYIntercept = 0;
break;
}
default:
break;
}
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
mHeaderHeight += deltaY;
setHeaderHeight(mHeaderHeight);
break;
}
case MotionEvent.ACTION_UP: {
// 这里做了下判断,当松开手的时候,会自动向两边滑动,具体向哪边滑,要看当前所处的位置
int destHeight = 0;
if (mHeaderHeight <= mOriginalHeaderHeight * 0.5) {
destHeight = 0;
mStatus = STATUS_COLLAPSED;
} else {
destHeight = mOriginalHeaderHeight;
mStatus = STATUS_EXPANDED;
}
// 慢慢滑向终点
this.smoothSetHeaderHeight(mHeaderHeight, destHeight, 500);
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
其中,getHeaderHeight()方法获取的是 mHeaderHeight 的值,它是Header在屏幕上显示出来的高度:
public int getHeaderHeight() {
return mHeaderHeight;
}
一开始是等于TextView的完整高度的:
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
if (hasWindowFocus ) {
mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
mHeader = findViewById(R.id.tv_title);
mOriginalHeaderHeight = mHeader.getMeasuredHeight();
mHeaderHeight = mOriginalHeaderHeight;
}
}
但是随着内容的滑动,其显示出来的高度会发生变化:
public void setHeaderHeight(int height) {
if (height <= 0) {//显示在屏幕上的header高度范围
height = 0;
} else if (height > mOriginalHeaderHeight) {
height = mOriginalHeaderHeight;
}
if (height == 0) {
mStatus = STATUS_COLLAPSED;
} else {
mStatus = STATUS_EXPANDED;
}
if (mHeader != null && mHeader.getLayoutParams() != null) {
mHeader.getLayoutParams().height = height;
mHeader.requestLayout();
mHeaderHeight = height;
}
}
最后值得一提的是,我这里是通过设置监听器来判断ListView是否滑动到顶部的:
public interface OnGiveUpTouchEventListener {
boolean giveUpTouchEvent(MotionEvent event);
}
public void setOnGiveUpTouchEventListener(OnGiveUpTouchEventListener l) {
mGiveUpTouchEventListener = l;
}
@Override
public boolean giveUpTouchEvent(MotionEvent event) {
if (listView.getFirstVisiblePosition() == 0) {
View view = listView.getChildAt(0);
if (view != null && view.getTop() >= 0) {
Log.d(TAG, "it is top");
return true;
}
}
return false;
}
PS:Demo源码:https://github.com/Ein3614/CustomViewTest