1.演示:
別的不談,先看下效果:

2.分析:
在做自定義控件之前,最重要的就是分析你要實現(xiàn)的控件的功能以及效果。將他們拆分成各個模塊,然后一一實現(xiàn)。這里我們分析一下這個SlideView。
- (1) 由一個圓角矩形背景以及一個圓形滑塊組成。
- (2) 圓形滑塊可以左右滑動,在滑動時,背景有一個漸變的效果。即圓形滑塊使用了平移動畫,背景使用了透明度動畫。
- (3) 圓形滑塊沒有緊貼背景的矩形,有一定的間隙。
3.實現(xiàn):
剖析完控件之后,我們就可以按步驟一步步來實現(xiàn)了。
控件測量與繪制
首先建立一個SlideView,繼承我們的View小哥~
public SlideView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public SlideView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SlideView(Context context) {
this(context, null);
}
在init方法中初始化我們的畫筆
// 繪制背景
private Paint bgPaint;
// 繪制圓形滑塊
private Paint circlePaint;
//關(guān)閉時默認背景顏色
public static final int CLOSE_PAINT_COLOR = 0x667f7f7f;
//打開時默認背景顏色
public static final int OPEN_PAINT_COLOR = 0xFF3378D4;
//打開時背景顏色
private int openColor = OPEN_PAINT_COLOR;
//關(guān)閉時背景顏色
private int closeColor = CLOSE_PAINT_COLOR;
private void init() {
bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
bgPaint.setStrokeCap(Cap.ROUND);
bgPaint.setColor(CLOSE_PAINT_COLOR);
circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
circlePaint.setStrokeCap(Cap.ROUND);
circlePaint.setStrokeJoin(Join.ROUND);
circlePaint.setColor(Color.WHITE);
}
初始化完畫筆之后,就可以繪制我們的背景了。要繪制就必須重寫onDraw方法。
@Override
protected void onDraw(Canvas canvas) {
//繪制背景
drawBg(canvas);
//繪制圓形滑塊
drawPoint(canvas);
}
private void drawBg(Canvas canvas) {
}
private void drawPoint(Canvas canvas) {
}
因為背景是圓角矩形,所以我們使用了
//Rectf是一個矩形對象,我們的背景就繪制在這個矩形中
//x,y代表在各自方向上圓角的半徑(直接理解為矩形四個角的弧度有多大)
canvas.drawRoundRect(Rectf rectf,float x,float y,Paint paint);
圓形小空間我們使用了
//這里用drawCircle也可以,看個人喜好。
//此方法是在一個矩形中繪制內(nèi)接圓,當(dāng)這個矩形為正方形時,繪制的是園,否則是橢圓。
canvas.drawOval(Rectf rectf,Paint paint)
所以在drawBg() 和 drawPoint()方法中,這樣實現(xiàn):
// 圓點半徑
private int mRadius;
// 圓形滑塊距離控件左端的偏移量(當(dāng)我們改變此偏移量的時候,滑塊便可以左右移動,初始為0在最左端)
private int leftOffset = 0;
// 空隙距離2dp
private int intervalWidth = dip2px(2);
// 圖形背景繪制區(qū)域
RectF bgRectf = new RectF();
// 圓點按鈕繪制區(qū)域
RectF pointRectF = new RectF();
private void drawBg(Canvas canvas) {
canvas.drawRoundRect(bgRectf, dip2px(15), dip2px(15), bgPaint);
}
private void drawPoint(Canvas canvas) {
pointRectF.set(intervalWidth + leftOffset, intervalWidth, intervalWidth + mRadius * 2 + leftOffset,
mRadius * 2 + intervalWidth);
canvas.drawOval(pointRectF, circlePaint);
}
//此方法是將dp值轉(zhuǎn)化為px值,方便適配
private int dip2px(float dpValue) {
final float scale = getContext().getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
很多同學(xué)一看到這立馬炸毛,你這些變量都在哪初始化的值?別著急,這里我選擇在onMeasure方法中初始化。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getMeasuredSize(widthMeasureSpec,60),
getMeasuredSize(heightMeasureSpec,25));
//此方法便是初始化各種默認屬性值
initDefaultSize();
}
getMeasuredSize()方法是為了在布局文件中設(shè)置了wrap_content屬性后,可以正常顯示,屬于一段模板代碼,自定義View時經(jīng)常要用到:
private int getMeasuredSize(int measureSpecValue,int defaultValue) {
int specMode = MeasureSpec.getMode(measureSpecValue);
int specSize = MeasureSpec.getSize(measureSpecValue);
int defaultSize = dip2px(defaultValue);
if (specMode == MeasureSpec.EXACTLY) {
defaultSize = specSize;
} else if (specMode == MeasureSpec.AT_MOST) {
defaultSize = Math.min(specSize, defaultSize);
}
return defaultSize;
}
private void initDefaultSize() {
// TODO Auto-generated method stub
//半徑為 (測量高度 /2) - 間隙
mRadius = getMeasuredHeight() / 2 - intervalWidth;
//背景的矩形 四個值 左上右下 左-0 上-0 右-控件的測量寬 下-控件的測量高
bgRectf.set(0, 0, getMeasuredWidth(), getMeasuredHeight());
//滑塊可滑動的最大寬度 = 背景的寬度 -2* 間隙 - 圓形滑塊的直徑(這個可以看圖理解)
mMaxWidth = bgRectf.right -2* intervalWidth - mRadius * 2;
}

這里理解上圖需要明確知道幾個變量的意思:
- intervalWidth ->圓形滑塊與背景之間的間隙,默認2dp
- mRadius->圓形滑塊的半徑,如圖可知等于控件高度一般減去間隙
- mMaxWidth ->圓形滑塊距左端最大距離。這個稍微不同一些,如圖所示,是從控件左端開始(也就是0)算起,這個mMaxWidth 最終要賦值給leftOffset,所以圓形滑塊據(jù)相對控件左端最大的距離為leftOffset+intervalWidth,如drawPoint()方法中所寫的那樣。
- mMinWidth ->圓形滑塊距左端最小距離,為0,因為其也是賦值給leftOffset。
- leftOffset ->真正控制圓形滑塊位置的變量,這里我們都是從控件左端(0)開始算的,因為最終leftOffset要加上intervalWidth。
如果懂了以上變量的意思,那我們就可以正式寫滑動邏輯了,肯定是重寫
onTouchEvent()事件:
// 手指按下時,起始X(這個x是距離屏幕左端的水平距離)
private float preX;
// 圓點在手指按下時,起始距離控件左端的偏移量
private float preLeftOffSet;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//手指按下,沒有滑動...
preX = event.getRawX();
//把間隙減掉,保證從最左端算起(0)
preLeftOffSet = pointRectF.left - intervalWidth;
break;
case MotionEvent.ACTION_MOVE:
//手指正在滑動...不斷記錄當(dāng)前x軸坐標(biāo)
float culX = event.getRawX();
//手指滑動距離(當(dāng)前手指所在x軸坐標(biāo)減去按下時x軸坐標(biāo))
float dx = culX - preX;
//當(dāng)手指滑動5個像素時,我們才認為是真正滑動了
if (Math.abs(dx) > 5) {
//當(dāng)前
leftOffset = (int) (dx + preLeftOffSet);
//leftOffset = fixLeftOffset(leftOffset);
invalidate();
}
break;
case MotionEvent.ACTION_UP:
//松開手指...
break;
}
//事件被這個控件消費啦 不回傳給父控件
return true;
}
加上以上代碼,我們的小滑塊就可以左右拖動了,但是有些無法無天,他可以被拖動到控件之外,這顯示不是我們想要的。所以必須要修正一下leftOffset。所以把注釋的代碼打開:
fixLeftOffset(leftOffset) ->修正leftOffset,返回一個在mMinWidth到mMaxWidth之間的值
private int fixLeftOffset(int leftOffset) {
leftOffset = (int) (leftOffset > mMaxWidth ? mMaxWidth : leftOffset);
leftOffset = (int) (leftOffset < mMinWidth ? mMinWidth : leftOffset);
return leftOffset;
}
加上上述代碼限制,我們的小滑塊就踏不出我們的手掌心了。但現(xiàn)在問題又出現(xiàn)了,就是當(dāng)松手時,我們希望滑塊自動滑動到左端或是右端,而不是停在中間,這個該怎么事件呢,其實很簡單,用ValueAnimator值動畫就可以快速實現(xiàn)。
控件的動畫(滑塊平移+背景漸變)
- 滑塊平移動畫
首先,我們要知道,滑塊是在手指松開時才產(chǎn)生動畫,這里分四種情況。
-
第一種:當(dāng)手指松開時,滑塊距控件左端的距離大于控件的一半,并且為close狀態(tài)。這時,讓滑塊滑動到右端,狀態(tài)置為open。
one.gif -
第二種:當(dāng)手指松開時,滑塊距控件左端的距離小于控件的一半,并且為close狀態(tài)。這時,滑塊滑回左端,狀態(tài)依然為close。
two.gif -
第三種:當(dāng)手指松開時,滑塊距控件左端的距離小于控件的一半,并且為open狀態(tài)。這時,讓滑塊滑動到左端,狀態(tài)置為close。
three.gif -
第四種:當(dāng)手指松開時,滑塊距控件左端的距離大于控件的一半,并且為open狀態(tài)。這時,滑塊滑回右端,狀態(tài)依然為open。
four.gif
理解了上面四種情況,我們現(xiàn)在就可以編碼實現(xiàn)啦~!
// 是否打開
private boolean checked = false;
在case MotionEvent.ACTION_UP添加以下代碼:
case MotionEvent.ACTION_UP:
//拿到滑塊的中心位置x軸坐標(biāo)
int pointCenterX = (int) pointRectF.centerX();
//用滑塊中心x軸坐標(biāo)和背景(即控件)x軸坐標(biāo)的一半作比較
if (pointCenterX >= bgRectf.right / 2 && !checked) {
changeState(checked);
} else if (pointCenterX < bgRectf.right / 2 && checked) {
changeState(checked);
}
//執(zhí)行平移動畫
releaseShowAnim();
break;
這時可以順便加上狀態(tài)監(jiān)聽接口,方便外部回調(diào),得知當(dāng)前控件狀態(tài):
public interface OnCheckedChangedListener {
void onCheckedChange(boolean isCheck);
}
private OnCheckedChangedListener onCheckedChangedListener;
public void setOnCheckedChangedListener(OnCheckedChangedListener onCheckedChangedListener) {
this.onCheckedChangedListener = onCheckedChangedListener;
}
//變更當(dāng)前狀態(tài)
private void changeState(boolean checked) {
this.checked = !checked;
//狀態(tài)監(jiān)聽接口
if (onCheckedChangedListener != null)
onCheckedChangedListener.onCheckedChange(this.checked);
}
釋放顯示動畫的代碼:
private void releaseShowAnim() {
//值動畫不難理解,下面這段代碼的意思其實就是給定一個值,到另一個值。
//在400毫秒的時間內(nèi),每隔一定時間,給你返回一個當(dāng)前動畫執(zhí)行的進度。
//動畫執(zhí)行的進度,是一個百分數(shù)(0~1),0沒執(zhí)行呢,1執(zhí)行完了。期間還能返回執(zhí)行了多少,是一個確定值。
//例如 1 ~ 100 執(zhí)行100秒,執(zhí)行進度30%(0.3),返回的是30(勻速運動前提下)
//pointRectF.left - intervalWidth滑塊距控件左端的距離,注意要把間隙減掉
//如果為check狀態(tài),則滑動到最右端,否則滑到最左端。
ValueAnimator valueAnimator = ValueAnimator.ofFloat(pointRectF.left - intervalWidth,
checked ? mMaxWidth : mMinWidth);
//該動畫執(zhí)行400毫秒
valueAnimator.setDuration(400);
//定義該運動為先加速再減速 (還有很多)
valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
//開啟動畫
valueAnimator.start();
//增加動畫執(zhí)行監(jiān)聽 這里就可以每次給你返回執(zhí)行進度和執(zhí)行值
valueAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//當(dāng)前動畫執(zhí)行了多少 也就是我們的偏移量
float offset = (Float) animation.getAnimatedValue();
//當(dāng)前動畫執(zhí)行進度,這個值是用來以后改變背景顏色的~
float fraction = animation.getAnimatedFraction();
//賦值給我們的leftOffset吧
leftOffset = (int) offset;
//重新繪制
invalidate();
}
});
}
好啦 ,加上如上代碼,部署到機器上,運行,是不是已經(jīng)有平移動畫了呢?
下面我們把點擊事件也加上,當(dāng)點擊控件兩端,我們也希望滑塊做相應(yīng)的滑動。這就要先判斷當(dāng)前手指是滑動還是點擊。所以引入isScroll變量,記錄手指是點擊還是滑動。還需要有一個變量記錄手指當(dāng)前點擊的位置距控件左端的距離clickLeftOffset,看代碼:
// 是否是滑動
private boolean isScroll = false;
// 手指點擊位置 距控件左端的偏移量
private float clickLeftOffset = 0;
在case MotionEvent.ACTION_DOWN添加以下代碼:
//getX()和getRawX()前者是獲取手指點擊位置距控件左端x軸坐標(biāo),后者是距屏幕左端
clickLeftOffset = event.getX();
//在手指按下時 把isScroll置為false
isScroll = false;
在case MotionEvent.ACTION_MOVE添加以下代碼:
if (Math.abs(dx) > 5) {
//...
isScroll = true;
}
在case MotionEvent.ACTION_UP添加以下代碼:
if (isScroll) {//滑動
int centerX = (int) pointRectF.centerX();
if (centerX >= bgRectf.right / 2 && !checked) {
changeState(checked);
} else if (centerX < bgRectf.right / 2 && checked) {
changeState(checked);
}
} else {//點擊
if (clickLeftOffset >= bgRectf.right / 2 && !checked) {
changeState(checked);
} else if (clickLeftOffset < bgRectf.right / 2 && checked) {
changeState(checked);
}
}
重新部署一下~是不是點擊事件也生效了呢?
- 背景顏色漸變
顏色漸變我采用了ArgbEvaluator,用法我會在后面介紹
顏色漸變也分為兩種情況:一種是在手指拖動的時候,另一種是在手指松開的時候(拖動到一半松開或者直接是點擊)
我們先來實現(xiàn)第一種,那么肯定要定位到ACTION_MOVE:
if (Math.abs(dx) > 5) {
//...
//通過當(dāng)前偏移量 / mMaxWidth , 計算出滑動的百分比(0~1)
float percent = leftOffset * 1.0f / mMaxWidth;
//將百分比,顏色變化的區(qū)間(close時的背景顏色-open時的背景顏色)
changeBgColor(percent, CLOSE_PAINT_COLOR, OPEN_PAINT_COLOR);
}
看changeBgColor方法:
//顏色插值器
ArgbEvaluator argbEvaluator= new ArgbEvaluator();
private void changeBgColor(float fraction, int startColor, int endColor) {
bgPaint.setColor((int)argbEvaluator.evaluate(fraction, startColor, endColor));
}
argbEvaluator.evaluate(fraction,startColor,endColor)方法接收三個參數(shù),第一個是百分比,后兩個參數(shù)是顏色區(qū)間。他會根據(jù)百分比計算出一個當(dāng)前處于區(qū)間范圍內(nèi)的一個值,返回給你。我們把這個值賦給背景的畫筆,再重繪界面,這樣我們的背景就會有一個漸變的效果。
再看松開手指的執(zhí)行漸變,這自然定位到我們的releaseShowAnim()方法。在其中找到動畫監(jiān)聽,在監(jiān)聽里,之前所寫的當(dāng)前動畫執(zhí)行的百分比就派上用場了,看代碼:
//獲取松開手指時,背景顏色
final int startColor = bgPaint.getColor();
valueAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float offset = (Float) animation.getAnimatedValue();
float fraction = animation.getAnimatedFraction();
if (checked)
//open狀態(tài),從當(dāng)前顏色漸變到open時的顏色
changeBgColor(fraction, startColor, openColor);
else
//close狀態(tài),從當(dāng)前顏色漸變到close時的顏色
changeBgColor(fraction, startColor, closeColor);
leftOffset = (int) offset;
// Log.i(TAG, "------>leftOffset = " + leftOffset);
// alpha = (int) (0x66 * (float) leftOffset / (float)
// mMaxWidth);
invalidate();
}
});
趕緊加上試一試,背景已經(jīng)如期望的那樣漸變了吧!到此為止我們的自定義開關(guān)就接近尾聲了,還有一些其他的功能,例如代碼控制開關(guān),控件不可用,當(dāng)應(yīng)用異常退出時保存View狀態(tài),改變顏色等等,都是一些很簡單的小功能,希望小伙伴們自行實現(xiàn),加深理解。
代碼沒托管到github,寫這個的主要目的是學(xué)習(xí)并且鞏固,畢竟這樣的輪子已經(jīng)有很多了,會用的同時也要會寫一寫。好累 ,吃個飯~ 下篇自定義控件見!



