1.演示:
别的不谈,先看下效果:
2.分析:
在做自定义控件之前,最重要的就是分析你要实现的控件的功能以及效果。将他们拆分成各个模块,然后一一实现。这里我们分析一下这个SlideView。
- (1) 由一个圆角矩形背景以及一个圆形滑块组成。
- (2) 圆形滑块可以左右滑动,在滑动时,背景有一个渐变的效果。即圆形滑块使用了平移动画,背景使用了透明度动画。
- (3) 圆形滑块没有紧贴背景的矩形,有一定的间隙。
3.实现:
剖析完控件之后,我们就可以按步骤一步步来实现了。
控件测量与绘制
首先建立一个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;
//关闭时默认背景颜色
public static final int CLOSE_PAINT_COLOR = 0x667f7f7f;
//打开时默认背景颜色
public static final int OPEN_PAINT_COLOR = 0xFF3378D4;
//打开时背景颜色
private int openColor = OPEN_PAINT_COLOR;
//关闭时背景颜色
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也可以,看个人喜好。
//此方法是在一个矩形中绘制内接圆,当这个矩形为正方形时,绘制的是园,否则是椭圆。
canvas.drawOval(Rectf rectf,Paint paint)
所以在drawBg() 和 drawPoint()方法中,这样实现:
// 圆点半径
private int mRadius;
// 圆形滑块距离控件左端的偏移量(当我们改变此偏移量的时候,滑块便可以左右移动,初始为0在最左端)
private int leftOffset = 0;
// 空隙距离2dp
private int intervalWidth = dip2px(2);
// 图形背景绘制区域
RectF bgRectf = new RectF();
// 圆点按钮绘制区域
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值转化为px值,方便适配
private int dip2px(float dpValue) {
final float scale = getContext().getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
很多同学一看到这立马炸毛,你这些变量都在哪初始化的值?别着急,这里我选择在onMeasure方法中初始化。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getMeasuredSize(widthMeasureSpec,60),
getMeasuredSize(heightMeasureSpec,25));
//此方法便是初始化各种默认属性值
initDefaultSize();
}
getMeasuredSize()方法是为了在布局文件中设置了wrap_content属性后,可以正常显示,属于一段模板代码,自定义View时经常要用到:
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,所以圆形滑块据相对控件左端最大的距离为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:
//手指正在滑动...不断记录当前x轴坐标
float culX = event.getRawX();
//手指滑动距离(当前手指所在x轴坐标减去按下时x轴坐标)
float dx = culX - preX;
//当手指滑动5个像素时,我们才认为是真正滑动了
if (Math.abs(dx) > 5) {
//当前
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;
}
加上上述代码限制,我们的小滑块就踏不出我们的手掌心了。但现在问题又出现了,就是当松手时,我们希望滑块自动滑动到左端或是右端,而不是停在中间,这个该怎么事件呢,其实很简单,用ValueAnimator值动画就可以快速实现。
控件的动画(滑块平移+背景渐变)
- 滑块平移动画
首先,我们要知道,滑块是在手指松开时才产生动画,这里分四种情况。
-
第一种:当手指松开时,滑块距控件左端的距离大于控件的一半,并且为close状态。这时,让滑块滑动到右端,状态置为open。
-
第二种:当手指松开时,滑块距控件左端的距离小于控件的一半,并且为close状态。这时,滑块滑回左端,状态依然为close。
-
第三种:当手指松开时,滑块距控件左端的距离小于控件的一半,并且为open状态。这时,让滑块滑动到左端,状态置为close。
-
第四种:当手指松开时,滑块距控件左端的距离大于控件的一半,并且为open状态。这时,滑块滑回右端,状态依然为open。
理解了上面四种情况,我们现在就可以编码实现啦~!
// 是否打开
private boolean checked = false;
在case MotionEvent.ACTION_UP添加以下代码:
case MotionEvent.ACTION_UP:
//拿到滑块的中心位置x轴坐标
int pointCenterX = (int) pointRectF.centerX();
//用滑块中心x轴坐标和背景(即控件)x轴坐标的一半作比较
if (pointCenterX >= bgRectf.right / 2 && !checked) {
changeState(checked);
} else if (pointCenterX < bgRectf.right / 2 && checked) {
changeState(checked);
}
//执行平移动画
releaseShowAnim();
break;
这时可以顺便加上状态监听接口,方便外部回调,得知当前控件状态:
public interface OnCheckedChangedListener {
void onCheckedChange(boolean isCheck);
}
private OnCheckedChangedListener onCheckedChangedListener;
public void setOnCheckedChangedListener(OnCheckedChangedListener onCheckedChangedListener) {
this.onCheckedChangedListener = onCheckedChangedListener;
}
//变更当前状态
private void changeState(boolean checked) {
this.checked = !checked;
//状态监听接口
if (onCheckedChangedListener != null)
onCheckedChangedListener.onCheckedChange(this.checked);
}
释放显示动画的代码:
private void releaseShowAnim() {
//值动画不难理解,下面这段代码的意思其实就是给定一个值,到另一个值。
//在400毫秒的时间内,每隔一定时间,给你返回一个当前动画执行的进度。
//动画执行的进度,是一个百分数(0~1),0没执行呢,1执行完了。期间还能返回执行了多少,是一个确定值。
//例如 1 ~ 100 执行100秒,执行进度30%(0.3),返回的是30(匀速运动前提下)
//pointRectF.left - intervalWidth滑块距控件左端的距离,注意要把间隙减掉
//如果为check状态,则滑动到最右端,否则滑到最左端。
ValueAnimator valueAnimator = ValueAnimator.ofFloat(pointRectF.left - intervalWidth,
checked ? mMaxWidth : mMinWidth);
//该动画执行400毫秒
valueAnimator.setDuration(400);
//定义该运动为先加速再减速 (还有很多)
valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
//开启动画
valueAnimator.start();
//增加动画执行监听 这里就可以每次给你返回执行进度和执行值
valueAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//当前动画执行了多少 也就是我们的偏移量
float offset = (Float) animation.getAnimatedValue();
//当前动画执行进度,这个值是用来以后改变背景颜色的~
float fraction = animation.getAnimatedFraction();
//赋值给我们的leftOffset吧
leftOffset = (int) offset;
//重新绘制
invalidate();
}
});
}
好啦 ,加上如上代码,部署到机器上,运行,是不是已经有平移动画了呢?
下面我们把点击事件也加上,当点击控件两端,我们也希望滑块做相应的滑动。这就要先判断当前手指是滑动还是点击。所以引入isScroll变量,记录手指是点击还是滑动。还需要有一个变量记录手指当前点击的位置距控件左端的距离clickLeftOffset,看代码:
// 是否是滑动
private boolean isScroll = false;
// 手指点击位置 距控件左端的偏移量
private float clickLeftOffset = 0;
在case MotionEvent.ACTION_DOWN添加以下代码:
//getX()和getRawX()前者是获取手指点击位置距控件左端x轴坐标,后者是距屏幕左端
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,用法我会在后面介绍
颜色渐变也分为两种情况:一种是在手指拖动的时候,另一种是在手指松开的时候(拖动到一半松开或者直接是点击)
我们先来实现第一种,那么肯定要定位到ACTION_MOVE:
if (Math.abs(dx) > 5) {
//...
//通过当前偏移量 / mMaxWidth , 计算出滑动的百分比(0~1)
float percent = leftOffset * 1.0f / mMaxWidth;
//将百分比,颜色变化的区间(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)方法接收三个参数,第一个是百分比,后两个参数是颜色区间。他会根据百分比计算出一个当前处于区间范围内的一个值,返回给你。我们把这个值赋给背景的画笔,再重绘界面,这样我们的背景就会有一个渐变的效果。
再看松开手指的执行渐变,这自然定位到我们的releaseShowAnim()方法。在其中找到动画监听,在监听里,之前所写的当前动画执行的百分比就派上用场了,看代码:
//获取松开手指时,背景颜色
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状态,从当前颜色渐变到open时的颜色
changeBgColor(fraction, startColor, openColor);
else
//close状态,从当前颜色渐变到close时的颜色
changeBgColor(fraction, startColor, closeColor);
leftOffset = (int) offset;
// Log.i(TAG, "------>leftOffset = " + leftOffset);
// alpha = (int) (0x66 * (float) leftOffset / (float)
// mMaxWidth);
invalidate();
}
});
赶紧加上试一试,背景已经如期望的那样渐变了吧!到此为止我们的自定义开关就接近尾声了,还有一些其他的功能,例如代码控制开关,控件不可用,当应用异常退出时保存View状态,改变颜色等等,都是一些很简单的小功能,希望小伙伴们自行实现,加深理解。
代码没托管到github,写这个的主要目的是学习并且巩固,毕竟这样的轮子已经有很多了,会用的同时也要会写一写。好累 ,吃个饭~ 下篇自定义控件见!