一. 自定义普通View
这个小节我们来看看如何自定义一个继承自View的控件。
目标:自定义一个圆形控件:
public class MyCircleView extends View { private int mColor = Color.GREEN; private Paint mPaint; public MyCircleView(Context context) { super(context); init(); } public MyCircleView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } public MyCircleView(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); } }
这里我们直接调用
canvas.drawCircle
,它的源码如下:/** * Draw the specified circle using the specified paint. If radius is <= 0, then nothing will be * drawn. The circle will be filled or framed based on the Style in the paint. * * @param cx The x-coordinate of the center of the cirle to be drawn * @param cy The y-coordinate of the center of the cirle to be drawn * @param radius The radius of the cirle to be drawn * @param paint The paint used to draw the circle */ public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) { super.drawCircle(cx, cy, radius, paint); }
注意
drawCircle
的前两个参数是中点坐标。使用自定义的圆形控件:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout 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:orientation="vertical" tools:context=".MainActivity"> <com.dou.vieweventdemo.MyCircleView android:layout_width="100dp" android:layout_height="100dp" /> </LinearLayout>
运行结果如下:
尝试加入margin、backgroud属性, 再次运行:
android:layout_margin="10dp" android:background="#909090"
显然,新添加的属性都正常生效,现在我们来加入padding,并使用
wrap_content
:<com.dou.vieweventdemo.MyCircleView android:layout_width="wrap_content" android:layout_height="100dp" android:background="#909090" android:padding="20dp" />
然而padding并没有生效,而且
wrap_content
的效果与match_parent
一样:我们在测试中可以发现两个问题:padding和wrap_content失效。
wrap_content
问题,我们前篇有提过,自定义View,如果不在测试过程中对wrap_content
做额外处理,就会有此问题。我们重写onMeasure
方法以解决这个问题:@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int defaultWidth = 200; int defaultHeight = 200; int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(defaultWidth, defaultHeight); } else if (widthMode == MeasureSpec.AT_MOST) { setMeasuredDimension(defaultWidth, heightSpecSize); } else if (heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSpecSize, defaultHeight); } }
padding
失效就是绘制问题了,在onDraw
中修改即可:@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); 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); }
最终运行结果,padding和
wrap_content
问题解决:
添加自定义属性
在values目录下面创建自定义属性的XML:
attrs_circle.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="MyCircleView"> <attr name="circle_color" format="color"/> </declare-styleable> </resources>
我们声明了一个自定义属性集合,名为”MyCircleView”,并添加了一个
color
类型的属性:”circle_color”。除此之外,还有其它自定义属性类型,比如”refrence”,”dimension”,”string”,”integer”等等。在
MyCircleView
的构造方法中解析自定义属性,并做相应处理:public MyCircleView(Context context, @Nullable AttributeSet attrs) { //注意不再调用super构造方法,而是下面修改过的构造方法 this(context, attrs, 0); init(); } public MyCircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyCircleView); mColor = typedArray.getColor(R.styleable.MyCircleView_circle_color, Color.GREEN); init(); }
使用这个自定义属性:
在布局文件中添加schemas声明:
xmlns:app="http://schemas.android.com/apk/res-auto"
然后为View添加自定义属性:
<com.dou.vieweventdemo.MyCircleView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#909090" app:circle_color="@color/blue" android:padding="20dp" />
此外,我们自定义了color,在colors.xml添加:
<color name="blue">#3366EE</color>
- 最后来看看运行结果:
此时这个自定义控件就比较规范了。
二. 自定义ViewGroup
自定义一个容器MorisonScrollView, 子元素排列成水平方向,并支持水平滑动。如果向其中添加多个可以纵向滑动的子View,比如ListView,还需要解决滑动冲突。
继承ViewGroup,必须实现
onLayout
:// 你必须实现onLayout方法,因为ViewGroup压根就没有实现它。 @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft = 0; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View childView = getChildAt(i); if (childView.getVisibility() != View.GONE) { int childWidth = childView.getMeasuredWidth(); mChildWidth = childWidth; childView.layout(childLeft, 0, childLeft + mChildWidth, childView.getMeasuredHeight()); childLeft += childWidth; } } mChildrenSize = childCount; }
这个
onLayout
方法还不规范:在layout过程中,没有考虑到自身的padding,以及子元素的margin。覆盖
onMesasure
:@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int measureWidth = 0; int measureHeight = 0; final int childCount = getChildCount(); measureChildren(widthMeasureSpec, heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); if (childCount == 0) { setMeasuredDimension(0, 0); } else if (widthSpecMode == MeasureSpec.AT_MOST && heightMeasureSpec == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measureWidth = childView.getMeasuredWidth() * childCount; measureHeight = childView.getMeasuredHeight(); setMeasuredDimension(measureWidth,measureHeight); } else if (widthSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measureWidth = childView.getMeasuredWidth() * childCount; setMeasuredDimension(measureWidth, heightSpecSize); } else if (heightSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measureHeight = childView.getMeasuredHeight(); setMeasuredDimension(widthSpecSize, measureHeight); } }
主要仍是处理
wrap_content
的情况,- 如果有子元素,就需要通过子元素的尺寸和数量来计算默认的宽和高。
- 这里我们默认高度是第一个子元素的高度,而且子元素宽度是相同的。
- 如果没有子元素,默认宽高就是0。
这个方法的不规范之处:
- 没有子元素时,其实不应该默认宽高为0,而是应该根据LayoutParams中设定的宽高来处理。
- 没有考虑自身的padding,以及子元素的margin。
完整的代码如下:
import android.content.Context; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewGroup; import android.widget.Scroller; public class MorisonScrollView extends ViewGroup { private static final String TAG = "MorisonScrollView"; private int mChildWidth; private Scroller mScroller; private VelocityTracker mVelocityTracker; private int mLastYIntercept; private int mLastXIntercept; private int mLastX; private int mLastY; private int mChildIndex; private int mChildrenSize; public MorisonScrollView(Context context) { super(context); init(); } public MorisonScrollView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public MorisonScrollView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { if (mScroller == null) { mScroller = new Scroller(getContext()); mVelocityTracker = VelocityTracker.obtain(); } } // 你必须实现onLayout方法,因为ViewGroup压根就没有实现它。 @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft = 0; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View childView = getChildAt(i); if (childView.getVisibility() != View.GONE) { int childWidth = childView.getMeasuredWidth(); mChildWidth = childWidth; childView.layout(childLeft, 0, childLeft + mChildWidth, childView.getMeasuredHeight()); childLeft += childWidth; } } mChildrenSize = childCount; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int measureWidth = 0; int measureHeight = 0; final int childCount = getChildCount(); measureChildren(widthMeasureSpec, heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); if (childCount == 0) { setMeasuredDimension(0, 0); } else if (widthSpecMode == MeasureSpec.AT_MOST && heightMeasureSpec == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measureWidth = childView.getMeasuredWidth() * childCount; measureHeight = childView.getMeasuredHeight(); setMeasuredDimension(measureWidth,measureHeight); } else if (widthSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measureWidth = childView.getMeasuredWidth() * childCount; setMeasuredDimension(measureWidth, heightSpecSize); } else if (heightSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measureHeight = childView.getMeasuredHeight(); setMeasuredDimension(widthSpecSize, measureHeight); } } @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; // 横向移动 intercepted = (Math.abs(deltaX) > Math.abs(deltaY)); break; case MotionEvent.ACTION_UP: intercepted = false; break; default: break; } 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); break; case MotionEvent.ACTION_UP: int scrollX = getScrollX(); 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 - scrollX; Log.d(TAG, "mChildIndex:"+mChildIndex+" xVelocity:"+xVelocity); smoothScrollBy(dx, 0); mVelocityTracker.clear(); break; default: break; } mLastX = x; mLastY = y; return super.onTouchEvent(event); } 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(); } }
测试Activity:
public class HorizonTestActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_horizon_test); initViews(); } private void initViews() { MorisonScrollView morisonScrollView = findViewById(R.id.morisonView); DisplayMetrics dm = getResources().getDisplayMetrics(); int screenWidth = dm.widthPixels; int screenHeight = dm.heightPixels; LayoutInflater layoutInflater = getLayoutInflater(); for (int i = 0; i < 3; i++) { ViewGroup layout = (ViewGroup) layoutInflater.inflate(R.layout.content_layout, morisonScrollView,false); TextView titleTV = layout.findViewById(R.id.titleTV); titleTV.setText("page:" + i); ViewGroup.LayoutParams layoutParams = layout.getLayoutParams(); layoutParams.width = screenWidth; layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),255/(i+1))); createList(layout); morisonScrollView.addView(layout); } } private void createList(ViewGroup layout) { ListView listView = layout.findViewById(R.id.listview); List<String> list = new LinkedList<>(); for (int i = 0; i < 50; i++) { list.add("name: " + i); } ArrayAdapter<String> adapter = new ArrayAdapter<>(this, R.layout.content_list_item, list); listView.setAdapter(adapter); } }
activity_horizon_test.xml
:<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".HorizonTestActivity"> <com.dou.vieweventdemo.MorisonScrollView android:id="@+id/morisonView" android:layout_width="match_parent" android:layout_height="match_parent"> </com.dou.vieweventdemo.MorisonScrollView> </LinearLayout>
content_layout.xml
:<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/titleTV" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <ListView android:id="@+id/listview" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout>
content_list_item.xml
:<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/titleTV" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <ListView android:id="@+id/listview" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout>
请运行之。
View工作原理系列博客
View的工作原理(一):MeasureSpec
View的工作原理(二):measure
View的工作原理(三):layout与draw
View的工作原理(四):自定義View