- View是是Android中所有控件的基类,界面层控件控件的一种抽象,它代表的是一个控件。
- View是一个控件,多个View组成用户界面(User Interface)。体现视觉上的美观,交互过程中的便捷。
- 自定义View有三种选择,自绘控件、组合控件、以及继承控件。
重点方法介绍
onMeasure
控件申请大小的模式。AT_MOST、EXACTLY、UNSPECIFIED。出现情况简单测试了下。因为此方法会多次调用,自至完成:UNSPECIFIED-> AT_MOST-> EXACTLY 过程,所以之探讨第一次调用的情况。
- match_parent
- 上级是什么就是什么
- fill_parent
- 上级是什么就是什么
- wrap_content
- 上级不能确定大小,就是UNSPECIFIED
- 其他情况,就是AT_MOST
- weight (0dp)
- UNSPECIFIED。会在此调用onMeasure()方法直到测量出值
- 精确值5dp、5px
- EXACTLY。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 获取的宽高mode和size
int modeW = MeasureSpec.getMode(widthMeasureSpec);
switch (modeW) {
// wrap_content;fill_parent(父控件大小不确定,父使用了wrap_content,weight(0dp)之类的)
case MeasureSpec.AT_MOST:
Log.d("xxx", "AT_MOST");
break;
// match_parent;精确值5dp、5px;fill_parent(父控件大小确定)
case MeasureSpec.EXACTLY:
Log.d("xxx", "EXACTLY");
break;
// 暂时没有测试出来
case MeasureSpec.UNSPECIFIED:
Log.d("xxx", "UNSPECIFIED");
break;
}
// 根据需要计算自己所需要的大小
int sizeW = ....;
int sizeH= ....;
// 请求需要的宽度、高度大小
setMeasuredDimension(sizeW, sizeH);
}
onSizeChanged
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// w、h是分配给View的宽、高尺寸
// 在此进行View所用参数的计算
}
onLayout
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// ViewGroup 用来置放子 View 的方法
// 使用 onSizeChanged 中计算好的参数,调用 View.layout(...)来实现View的置放
}
自定义View流程
- 1.准备工作,获取资源、解析XML,做些初始化工作
- 2.*onMeasure* 确认View想要的大小。考虑padding等属性的影响
- *onSizeChanged*根据View实际使用的大小进行缩放比例、位置的计算
- *onDraw* View的绘制,使用*onSizeChanged*确认好的参数进行绘制
- 完善View,添加行为、动作
下面两个也是按照
时钟 仿milter的文章
- 考虑 padding
- 考虑 wrap_content match_parent 和 固定值
public class ClockView extends View {
private Context mContext;
private Drawable mDial, mHourHand, mMinuteHand;
private boolean mAttached;//是否显示在View上
//时间属性
private GregorianCalendar mCalendar;
private float mHourNum, mMinuteNum;
//用于尺寸没发生变化时的绘制属性,每次变化是会重新赋值
private float scaleNum;
//绘制图形选定的表盘中心位置,用于缩放和指针旋转的变化的参数
private int dialCenterX, dialCenterY;
public ClockView(Context context) {
this(context, null);
}
public ClockView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ClockView(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public ClockView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
mContext = context;
initData();
}
/**
* ##############################
* 对外方法
* ##############################
*/
public void refreshClock() {
onTimeChanged();
invalidate();
}
/**
* ##############################
* 准备工作
* ##############################
*/
private void initData() {
mDial = mContext.getDrawable(R.mipmap.clock_dial);
mHourHand = mContext.getDrawable(R.mipmap.clock_hand_hour);
mMinuteHand = mContext.getDrawable(R.mipmap.clock_hand_minute);
}
/**
* #############################
* 确认 View “想要”的大小
* #############################
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//表盘图片的本身宽高(作为时钟的整体宽高)
int intrinsicWidth = mDial.getIntrinsicWidth();
int intrinsicHeight = mDial.getIntrinsicHeight();
//获取分配的宽高
int modeW = MeasureSpec.getMode(widthMeasureSpec);
int sizeW = MeasureSpec.getSize(widthMeasureSpec);
int modeH = MeasureSpec.getMode(heightMeasureSpec);
int sizeH = MeasureSpec.getSize(widthMeasureSpec);
/** 第一次计算,计算缩放比例,确定View请求大小。(请求大小 不代表 具体显示大小)
* 请求的大小发生改变改变触发 {@link ClockView#onSizeChanged(int, int, int, int)} 方法*/
float wScale = 1.0f;
float hScale = 1.0f;
if (modeW != MeasureSpec.UNSPECIFIED && sizeW < intrinsicHeight) {
wScale = (float) sizeW / intrinsicWidth;
}
if (modeH != MeasureSpec.UNSPECIFIED && sizeH < intrinsicHeight) {
hScale = (float) sizeH / intrinsicHeight;
}
float scale = Math.min(wScale, hScale);
/**
* getDefaultSize()方法:AT_MOST、EXACTLY效果一样(实际使用) -- 只对Matc和具体值适配
* resolveSizeAndState()方法:AT_MOST(太大加标记,表示会考虑)、EXACTLY(实际使用)
*
* 由 {@link android.view.ViewRootImpl#getRootMeasureSpec(int, int)} 克制
* 解析的 Mode :wrap_content-->AT_MOST 、match_parent、(具体值)-->EXACTLY
*
* 这句代码:result = specSize | MEASURED_STATE_TOO_SMALL
* 当控件索取的空间大于实际使用的数值时,添加的一个标记
*
* 请求宽高时 -- 加入Padding值
*/
int paddingX = getPaddingLeft() + getPaddingRight();
int paddingY = getPaddingTop() + getPaddingBottom();
setMeasuredDimension(resolveSizeAndState((int) (scale * intrinsicWidth) + paddingX, widthMeasureSpec, 0)
, resolveSizeAndState((int) (scale * intrinsicHeight) + paddingY, heightMeasureSpec, 0));
}
/**
* ########################
* 根据实际使用大小 计算View中个部件的大小、位置
* ########################
*
* @param w 经过考虑后显示的 宽(实际)
* @param h 经过考虑后显示的 高(实际)
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
//表盘图片的大小 需求宽高
int intrinsicWidth = mDial.getIntrinsicWidth();
int intrinsicHeight = mDial.getIntrinsicHeight();
//View尺寸设置,根据 “表盘” 缩放比例设置
//注意Padding值的印象
//第二次计算,View尺寸改变。计算缩放比例,并重新设置 Drawable 的显示区域
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
scaleNum = Math.min((float) (w - paddingLeft - paddingRight) / intrinsicWidth,
(float) (h - paddingTop - paddingBottom) / intrinsicHeight);//缩放比例
//View位置设定
//表盘Drawable 位置设置
mDial.setBounds(paddingLeft, paddingTop, paddingLeft + intrinsicWidth, paddingTop + intrinsicHeight);
//因为 时钟指针和分钟指针 资源图片就是以中心点绘制的,这里以表盘绘制中心点设置
// 时间改变尺寸不变时,只需要改变旋转角度就可以了(在onDraw()方法中执行)
dialCenterX = paddingLeft + intrinsicWidth / 2;
dialCenterY = paddingTop + intrinsicHeight / 2;
//时钟Drawable 位置设置
intrinsicWidth = mHourHand.getIntrinsicWidth();
intrinsicHeight = mHourHand.getIntrinsicHeight();
mHourHand.setBounds(dialCenterX - intrinsicWidth / 2, dialCenterY - intrinsicHeight / 2,
dialCenterX + intrinsicWidth / 2, dialCenterY + intrinsicHeight / 2);
//分钟Drawable 位置设置
intrinsicWidth = mMinuteHand.getIntrinsicWidth();
intrinsicHeight = mMinuteHand.getIntrinsicHeight();
mMinuteHand.setBounds(dialCenterX - intrinsicWidth / 2, dialCenterY - intrinsicHeight / 2,
dialCenterX + intrinsicWidth / 2, dialCenterY + intrinsicHeight / 2);
}
/**
* ##############################
* 设置View的显示位置, 此方法 ParentView 调用
* 当ParentView 发生变化激活其 {@link android.view.ViewGroup#onLayout(boolean, int, int, int, int)}方法
* 在其中进行子 View的位置重新计算,并条用此方法将子View(本View)设置到指定的地点去显示
* ##############################
*/
@Override
public void layout(int l, int t, int r, int b) {
super.layout(l, t, r, b);
}
/**
* #######################
* 绘制View并确认View中图形的位置
* <p/>
* save():用来保存Canvas的状态。save之后,可以调用Canvas的平移、放缩、旋转、错切、裁剪等操作
* restore:用来恢复Canvas之前保存的状态。防止save后对Canvas执行的操作对后续的绘制有影响。
* 使用时:restore调用次数比save多,会引发Error
* <p/>
* {@link ClockView#onDraw(Canvas)} -- 方法多次调用
* <p/>
* 最开始时父试图中是没有子试图的,当你从xml文件中加载子试图或者在java代码中添加子试图时,父试图的状态会发生变化
* 这个变化会引起 {@link ClockView#onLayout(boolean, int, int, int, int)} 甚至是 {@link ClockView#onMeasure(int, int)} 方法
* <p/>
* #######################
*/
@Override
protected void onDraw(Canvas canvas) {
//使用计算好了的尺寸数据进行view的绘制
canvas.save();
canvas.scale(scaleNum, scaleNum, dialCenterX, dialCenterY);
//表盘的绘制
canvas.save();
mDial.draw(canvas);
canvas.restore();
//时钟Drawable 角度设置
canvas.save();
canvas.rotate(mHourNum / 12 * 360, dialCenterX, dialCenterY);
mHourHand.draw(canvas);
canvas.restore();
//分钟Drawable 角度设置
canvas.save();
canvas.rotate(mMinuteNum / 60 * 360, dialCenterX, dialCenterY);
mMinuteHand.draw(canvas);
canvas.restore();
//恢复缩放操作之前的状态
canvas.restore();
}
/**
* ###########################
* 行为动作设置 -- 时间广播接收器
* ###########################
*/
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
//时区更改
if (intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) {
String tz = intent.getStringExtra("time-zone");
mCalendar.setTimeZone(TimeZone.getTimeZone(tz));
}
onTimeChanged();
invalidate();
}
};
private void onTimeChanged() {
mCalendar.setTimeInMillis(System.currentTimeMillis());
int hour12 = mCalendar.get(Calendar.HOUR);
int minute = mCalendar.get(Calendar.MINUTE);
int second = mCalendar.get(Calendar.SECOND);
//Calendar的可以是Linient模式,此模式下,second和minute是可能超过60和24的,具体这里就不展开了
mHourNum = hour12 + minute / 60f;
mMinuteNum = minute + second / 60f;
}
/**
* 添加到Windows时调用
*/
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!mAttached) {
mAttached = true;
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_TIME_TICK);//每分钟一次
filter.addAction(Intent.ACTION_TIME_CHANGED);
filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);
mContext.registerReceiver(mBroadcastReceiver, filter);
}
mCalendar = new GregorianCalendar();
onTimeChanged();
}
/**
* 从Windows分离时调用
*/
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mAttached) {
mAttached = false;
mContext.unregisterReceiver(mBroadcastReceiver);
}
}
}
环形进度条
- 考虑 padding
- 考虑 wrap_content match_parent 和 固定值
- 考虑 数据保存和恢复
package com.elife.mobile.view;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Parcelable;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import com.cy_life.mobile.R;
/**
* 环形进度显示
* @author huangjf
*/
public class CircleProgressView extends View{
private static final String INSTANCE_STATUS = "instance_status";
private static final String END_TIME = "end_time_millis";
// TODO 如有需要转到attr中设置
private final int progressBgColor = R.color.theme_main_blue;
private final int prgressSolidColor = R.color.c_0296f3;
private final int mRingWidth = 8;// 圆环宽度
private Paint mPaint; // 画笔
private RectF mRectF; // 扇形位置信息,用于话圆环
private int mDiameterMax;// 最大圆直径
private int mTextHeight;// 就是 TextSize
// 倒计时相关
private Handler mHandler;
private long endTimeMillis = -1, timeLeftMillis, durationMillis;
private OnProgressListener callBack;
private Runnable timingRunnable = new Runnable() {
@Override
public void run() {
timeLeftMillis = endTimeMillis - SystemClock.elapsedRealtime();
Log.d("hjf", "TimeLeft:" + timeLeftMillis);
if (timeLeftMillis <= 0) {
onEnd();
}else {
long delay = timeLeftMillis % 1000;
mHandler.postDelayed(this, delay);
}
invalidate();
}
};
public CircleProgressView(Context context) {
this(context, null);
}
public CircleProgressView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CircleProgressView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
// 画笔
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setStrokeWidth(mRingWidth);
// 扇形位置信息,用于画圆环
mRectF = new RectF();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// 支持 padding 属性
int showWidth = w - getPaddingLeft() - getPaddingRight();
int showHeight = h - getPaddingTop() -getPaddingBottom();
mDiameterMax = Math.min(showWidth, showHeight);
// 规划扇形区域
mRectF = new RectF();
mRectF.left = mRingWidth / 2;
mRectF.top = mRingWidth / 2;
mRectF.right = mDiameterMax - mRingWidth / 2;
mRectF.bottom = mDiameterMax - mRingWidth / 2;
// 中间文字的 TextSize 值
mTextHeight = mDiameterMax / 4 ;
}
@Override
protected void onDraw(Canvas canvas) {
// 画圆环背景
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(getResources().getColor(progressBgColor));
canvas.drawArc(mRectF, -90, 360, false, mPaint);
// 画扇形圆环进度
mPaint.setColor(getResources().getColor(prgressSolidColor));
float rate = 0;
if (durationMillis != 0) {
rate = 1 - timeLeftMillis * 1f / durationMillis;
}
canvas.drawArc(mRectF, -90, 360 * rate, false, mPaint);
// 写字
mPaint.setStyle(Paint.Style.FILL);
mPaint.setTextSize(mTextHeight);
String text = String.valueOf((int) Math.ceil(timeLeftMillis * 1f / 1000));
float textWidth = mPaint.measureText(text, 0 , text.length());
canvas.drawText(text, (mDiameterMax - textWidth) / 2 , (mDiameterMax + mTextHeight) / 2, mPaint);
}
/**
* 开始倒计时
* @param durationMillis 倒计时的持续时间
*/
public void startTiming(long durationMillis){
if (this.mHandler != null) {
return;
}
this.endTimeMillis = SystemClock.elapsedRealtime() + durationMillis;
this.durationMillis = durationMillis;
this.mHandler = new Handler(Looper.getMainLooper());
this.mHandler.post(this.timingRunnable);
}
// 1. 保存
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putLong(END_TIME, endTimeMillis);
bundle.putParcelable(INSTANCE_STATUS, super.onSaveInstanceState());
return bundle;
}
// 2. 分离
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
stopTiming();
}
// 3. 恢复
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof Bundle)) {
super.onRestoreInstanceState(state);
return;
}
Bundle bundle = (Bundle) state;
endTimeMillis = bundle.getLong(END_TIME);
super.onRestoreInstanceState(bundle.getParcelable(INSTANCE_STATUS));
}
// 4. 关联
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
continueTiming();
}
/**
* 恢复View时调用,例如屏幕旋转
*/
private void continueTiming(){
if (this.endTimeMillis == -1) {
return;
}
if (this.mHandler == null) {
this.mHandler = new Handler(Looper.getMainLooper());
this.mHandler.post(this.timingRunnable);
}
}
// 因外力等因素暂停倒计时,非倒计时结束自动结束
public void stopTiming(){
if (this.mHandler == null) {
return;
}
this.mHandler.removeCallbacks(this.timingRunnable);
this.mHandler = null;
}
// 倒计时结束后的操作
private void onEnd() {
timeLeftMillis = 0;
this.endTimeMillis = -1;
if (this.callBack != null) {
this.callBack.onEnd();
}
}
public void setProgressListener(OnProgressListener progressCallBack){
this.callBack = progressCallBack;
}
public static interface OnProgressListener{
void onEnd();
}
}
attr属性
<declare-styleable name="CircleProgress">
<attr name="innerCircleRadius" format="dimension" />
<attr name="innerCircleColor" format="color" />
<attr name="outRingColorBG" format="color" />
<attr name="outRingColor" format="color" />
<attr name="outRingWidth" format="dimension" />
</declare-styleable>
自定义ViewGroup
- onMeasure中 计量本所需要的尺寸
- onLayout中 计算子View显示的位置,并调用子View的layout(…)进行位置设置
左->右 上->下 的ViewGroup
public class SequenceViewGroup extends ViewGroup {
public SequenceViewGroup(Context context) {
this(context, null);
}
public SequenceViewGroup(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SequenceViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public SequenceViewGroup(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
/**
* #######################################
* 计量本 ViewGroup 所需要的尺寸
* #######################################
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int childCount = getChildCount();
//本ViewGroup需要的大小
int groupViewWidth = MeasureSpec.getSize(widthMeasureSpec);
int groupViewHeight = 0;
//当前View在X轴插入的点
int inputPointX = getPaddingLeft() + getPaddingRight();
//当前行子View中高度最大值
int childViewMaxHeight = 0;
//遍历所有子View 计算本ViewGroup的高度
for (int i = 0; i < childCount; i++) {
//获取当前子View
View childView = getChildAt(i);
//GONE 忽略不算
if (childView.getVisibility() == View.GONE) continue;
//遍历测量子View宽高
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
//计算将当前子View加入当前行后,此时所使用的宽度
int childWidth = childView.getMeasuredWidth();
int childHeight = childView.getMeasuredHeight();
//换行显示:计算当前子View加入后,当前行的显示所需宽度超过 X轴界线; 否则记录所在行View高度最大值
if (inputPointX + childWidth > groupViewWidth) {
//重置 X轴View插入坐标
inputPointX = 0;
groupViewHeight += childViewMaxHeight;
//换行后将最高高度重置 -- 当前View的高度(连续换行,每行只有一个View的情况)
childViewMaxHeight = childHeight;
} else {
//没有超过时,记录本行最高子 View的高度
childViewMaxHeight = Math.max(childViewMaxHeight, childHeight);
}
//跟新 X轴View插入坐标
inputPointX += childWidth;
}
//加上最后一个View的高度
groupViewHeight += childViewMaxHeight;
//padding 影响
groupViewHeight += getPaddingTop() + getPaddingBottom();
//这里将宽度和高度与Google为我们设定的建议最低宽高对比,确保我们要求的尺寸不低于建议的最低宽高。
groupViewWidth = Math.max(groupViewWidth, getSuggestedMinimumWidth());
groupViewHeight = Math.max(groupViewHeight, getSuggestedMinimumHeight());
//请求宽高
setMeasuredDimension(resolveSizeAndState(groupViewWidth, widthMeasureSpec, 0),
resolveSizeAndState(groupViewHeight, heightMeasureSpec, 0));
}
/**
* #################################
* 计算子View显示的位置 并位置属性设置给子 View
* #################################
* 通过调用子View的 {@link View#layout(int, int, int, int)} 方法实现对子View位置的控制
* <p/>
* ViewGroup 的父控件调用这个方法 {@link ViewGroup#layout(int, int, int, int)} 给本 ViewGroup 确认位置
* 此方法在 ViewGroup类 用 “ final ” 字段修饰了,我们就无需考虑了
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//计算布局可用空间的距离:可添加View空间起始 X坐标,Y坐标,X轴最大值坐标,添加的View的显示不能超过此 X轴
int useableSpaceLeft = getPaddingLeft();
int useableSpaceTop = getPaddingTop();
int useableSpaceRight = r - getPaddingRight() - l;
//添加过程中的数据记录:当前View添加位置 X坐标,Y坐标,当前行View高度的最大值
int inputPointX = useableSpaceLeft;
int inputPointY = useableSpaceTop;
int childViewMaxHeight = 0;
//遍历所有
for (int i = 0; i < getChildCount(); i++) {
//获取子View 及其宽高
View childView = getChildAt(i);
int childWidth = childView.getMeasuredWidth();
int childHeight = childView.getMeasuredHeight();
//换行显示:计算当前子View加入后,当前行的显示所需宽度超过 X轴界线
if (inputPointX + childWidth > useableSpaceRight) {
//跟新 X、Y轴 View插入坐标
inputPointX = useableSpaceLeft;
inputPointY += childViewMaxHeight;
//换行后将最高高度重置 -- 当前View的高度(连续换行,每行只有一个View的情况)
childViewMaxHeight = childHeight;
} else {
childViewMaxHeight = Math.max(childViewMaxHeight, childHeight);
}
//设置子View的显示区域坐标
childView.layout(inputPointX, inputPointY, inputPointX + childWidth, inputPointY + childHeight);
//跟新 X轴View插入坐标
inputPointX += childWidth;
}
}
}
组合控件就是使用系统给的View封装成的一个View,比如常见的Title栏封装。
继承控件可以看这个例子 水滴刷新动画的RecyclerView 的封装。