- 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 的封裝。