前言

前几天写了一篇一步一步教你实现即刻点赞效果后,实现点赞效果主要是自己对自定义View的一些canvas绘制,缩放知识,位移的理解。而朋友说HenCoder还有给出薄荷健康滑动卷尺,小米运动记录界面,Flipboard 红板报的翻页效果。这几个例子对自定义View知识很有代表性,都用到了不同的知识。而今天要实现的是薄荷健康滑动卷尺效果,主要是加深触摸反馈,和在Android坐标系中,获取View不同环境下坐标系的方法,也刚好巩固滑动如scrllTo()和scrllBy()用法。

效果图


仔细观察上面的效果图,有六个点是可以观察知道的:

  1. 刻度尺是可以左右滑动的,看到实际的刻度尺是比所看到的的区域要长的。
  2. 刻度尺的刻度线有长有短,刻度线之间的间隔都是固定1,从1开始每隔10刻度线变长。
  3. 图中有一条绿线,这条绿线比刻度线都要长和粗。
  4. 当刻度尺停下来时,绿线所指的刻度就是绿色文本所显示的数字。
  5. 文本显示的数值是有单位的:Kg在数字的右上角,并伴随着刻度变化而左右一点距离。
  6. 具有惯性滑动。

知识准备

scrllTo和scrollBy

刚看到上图,就马上想到了Android里的Scroller,这个类是专门处理滚动的工具类,我们平时在开发中直接使用Scroller的场景不多,但是我们很多时候都会接触到它,像ViewPager、ListView。在Android中任何一个控件都是可以移动的,因为VIew类中有scrollTo()和scrollBy()方法。

scrllTo


意思是设置View的滚动位置,这会导致onScrollChanged(int,int,int,int)的调用,并且会刷新View。

scrollBy


意思是移动视图的滚动位置,这将导致对onScrollChanged(int,int,int,int)的调用,并且会刷新View。
这么说还是不明白,举个例子。
activity_main布局文件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout android:id="@+id/activity_main"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:paddingBottom="@dimen/activity_vertical_margin"android:paddingLeft="@dimen/activity_horizontal_margin"android:paddingRight="@dimen/activity_horizontal_margin"android:paddingTop="@dimen/activity_vertical_margin"tools:context="com.uestc.horizontalrulerview.MainActivity"><Buttonandroid:id="@+id/btn_one"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="BUTTONONE"/><Buttonandroid:id="@+id/btn_two"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_below="@id/btn_one"android:text="BUTTONTWO"/></RelativeLayout>

MainActivity文件:

public class MainActivity extends AppCompatActivity {private Button btn_one;private Button btn_two;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);btn_one = (Button) findViewById(R.id.btn_one);btn_two = (Button) findViewById(R.id.btn_two);btn_one.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {btn_one.scrollTo(-50, -100);}});btn_two.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {btn_two.scrollBy(-50, -100);}});}}

效果如下:

发现button没有移动,但是里面的文本缺不见了,可以猜测,应该是移动自己布局里面的内容。现在在布局文件给两个button加上一个父布局:

<RelativeLayout android:id="@+id/activity_main"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:paddingBottom="@dimen/activity_vertical_margin"android:paddingLeft="@dimen/activity_horizontal_margin"android:paddingRight="@dimen/activity_horizontal_margin"android:paddingTop="@dimen/activity_vertical_margin"tools:context="com.uestc.horizontalrulerview.MainActivity"><LinearLayoutandroid:id="@+id/ll_btn"android:layout_width="match_parent"android:layout_height="match_parent"android:orientation="vertical"><Buttonandroid:id="@+id/btn_one"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="BUTTONONE"/><Buttonandroid:id="@+id/btn_two"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="BUTTONTWO"/></LinearLayout></RelativeLayout>

MainActivity文件改为:

       btn_one.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {ll_btn.scrollTo(-50,-100);}});btn_two.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {ll_btn.scrollBy(-50,-100);}});

改为对这个布局进行滚动。
看看效果:

BUTTONONE调用了scrollTo方法,BUTTONTWO调用了scrollBy方法,发现BUTTONONE调用了一次scrollTo方法后继续调用会没有效果,而BUTTONTWO调用了scrollBy方法后继续调用还会继续滚动。那么下面可以得出结论:

  • scrollTo和scrollBy只是移动自己的内容.也就是如果ViewGroup设置scrollTo或者scrollBy的话,只有它的子View会有位移效果.如果是TextView设置scrollTo或者scrollBy的话只会让它内部的文字发生位移.
  • scrollBy()方法是让View相对于当前的位置滚动某段距离,而scrollTo()方法则是让View相对于初始的位置滚动某段距离。
    这里也许大家也像我一样会有疑问:为什么设置了**scrollTo(-50,-100)scrollBy(-50,-100)**却往右下移动呢?按照正常来讲,Android中的坐标系原点是在屏幕的左上角,x轴向右是正值,反之是负值,y轴向下是正值,向上是负值,所以应该往左上移动。想找出答案还是看源码,因为仔细分析源码比较长,上面也写了移动是会View会刷新,那么最后肯定会执行invaildate方法,这里另外说一下另外一个视图刷新方法postInvalidate,我们知道Android是不能在子线程中更新UI的,这个方法可以直接在子线程中更新视图,通过ViewRootImpl这个顶级视图检查管理类去负责分发轮询处理,然后在主线程调用invalidate方法,实现视图控件的线程安全,换句话说postInvalidate最后还是调用invalidate方法。
    invalidate最后通过ViewRootImpl类重写的invalidateChild方法对子View进行绘制,这就可以解释为什么scrollTo的作用在View的内容上了,最后再执行下面这个方法:
    public void scrollTo(int x, int y) {if (mScrollX != x || mScrollY != y) {int oldX = mScrollX;int oldY = mScrollY;mScrollX = x;mScrollY = y;invalidateParentCaches();onScrollChanged(mScrollX, mScrollY, oldX, oldY);if (!awakenScrollBars()) {postInvalidateOnAnimation();}}}
    public void invalidate(int l, int t, int r, int b) {final int scrollX = mScrollX;final int scrollY = mScrollY;invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);}

这里就可以知道看到的矩形是l-scrollX,t-scrollY,r-scrollX,b-scrollY,这就是为什么scrollTo设置负值就是往正方向走,设置负值往反方向走,并且里面加了判断条件if(mScrollx != x || mScrollY != y),第一次调用这个方法时,x的值赋给了mScrollX,y的值赋给了mScrollY,而再后面调用这个方法因为x等于mScrollX,y等于mScrollY,因此不会执行进入条件内的代码。源码中scrollBy还是调用了scrollTo方法:

    public void scrollBy(int x, int y) {scrollTo(mScrollX + x, mScrollY + y);}

参数是(mScrollX + x,mScrollY + y),这里就可以解释,scrollTo方法只会让View移动一次,它是对View初始方向来说,而scrollBy是对View的现在位置来说,所以可以不断移动。

简单绘制文本

布局文件:

public class SlidingRuleView extends View {//文字画笔private Paint paint;//文字足够长 超过屏幕显示宽度 方便后面看滑动效果private String currentNum = "1234sdddddddddd423dddddddd234dddddd234dddddd23423dddddddd234ddddd234ddddddd23423dddddd23ddd234ddddddd34334ddddddddddddddddddddddddddddddddsdddddddddddd";//这个自定义View的高度private int height;public SlidingRuleView(Context context) {this(context,null);}public SlidingRuleView(Context context, @Nullable AttributeSet attrs) {this(context, attrs,0);}public SlidingRuleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);init(context);}private void init(Context context){//初始化画笔 抗锯齿paint = new Paint(Paint.ANTI_ALIAS_FLAG);//画笔的颜色 黑色paint.setColor(Color.BLACK);//设置填充样式,只绘制图形的轮廓paint.setStyle(Paint.Style.STROKE);//设置文本大小paint.setTextSize(25f);}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){super.onMeasure(widthMeasureSpec, heightMeasureSpec);//MeasureSpec值由specMode和specSize共同组成,onMeasure两个参数的作用根据specMode的不同,有所区别。//当specMode为EXACTLY时,子视图的大小会根据specSize的大小来设置,对于布局参数中的match_parent或者精确大小值//当specMode为AT_MOST时,这两个参数只表示了子视图当前可以使用的最大空间大小,而子视图的实际大小不一定是specSize。所以我们自定义View时,重写onMeasure方法主要是在AT_MOST模式时,为子视图设置一个默认的大小,对于布局参数wrap_content。int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);if (heightSpecMode == MeasureSpec.AT_MOST) {setMeasuredDimension(widthSpecSize, SystemUtil.dp2px(getContext(),60));} else {setMeasuredDimension(widthSpecSize, heightSpecSize);}//这里获取View的高度 方便后面绘制算一些坐标height = getMeasuredHeight();}@Overrideprotected void onDraw(Canvas canvas) {super.onDraw(canvas);//得到文字的字体属性和测量Paint.FontMetrics fontMetrics = paint.getFontMetrics();//文字设置在View的中间float y = height / 2 + (Math.abs(fontMetrics.ascent) - fontMetrics.descent) / 2;//canvas绘制文本canvas.drawText(currentNum, 0,y, paint);}
}

下面重点讲解onMeasure方法和绘制文本方法

         int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);//MeasureSpec值由specMode和specSize共同组成,onMeasure两个参数的作用根据specMode的不同,有所区别。//当specMode为EXACTLY时,子视图的大小会根据specSize的大小来设置,对于布局参数中的match_parent或者精确大小值//当specMode为AT_MOST时,这两个参数只表示了子视图当前可以使用的最大空间大小,而子视图的实际大小不一定是specSize。所以我们自定义View时,重写onMeasure方法主要是在AT_MOST模式时,为子视图设置一个默认的大小,对于布局参数wrap_content。if (heightSpecMode == MeasureSpec.AT_MOST) {//这个方法确定了当前View的大小setMeasuredDimension(widthSpecSize, SystemUtil.dp2px(getContext(),60));} else {setMeasuredDimension(widthSpecSize, heightSpecSize);}

这里是获取specMode的模式和specSize大小,为什么确定View的大小根据heightSpecMode呢,因为要实现的滑动卷尺只是横向滑动,width设置精准值、wrap_content和match_parent都是可以的,不需要处理,超过View显示的区域到时候可以通过滑动来显示。实现这个效果高度一般设置wrap_content,当设置wrap_content时,最好设置一个固定高度,上面代码设置60px,如果不进行处理的话。有可能占满父容器所给的高度,或者高度过小显示不全。这里稍微讲下Measure.Mode测量模式:

  1. UNSPECIFIED
    父容器不对子View做任何限制,要多大给多大,一般用于系统内部,这里就不用多考虑

  2. EXACTLY
    精准模式,一般View指定了具体的大小(dp/px)或者设置match_parent

  3. AT_MOST
    父容器制定了一个可用的大小,子View不能大于这个值,这个是在布局设置wrap_content

最后还发现调用了setMeasuredDimension,这个方法主要是决定当前View的大小,onMeasure方法最后调用setMeasuredDimension方法保存测量的宽高值,当然写在onMesure方法里,也说明它会调用多次,因为有的时候,一次测量,当父控件发现子控件的尺寸不符合要求就会重新测量。如果不调用这个方法,可能会产生不可预测的问题。
下面讲下定位文本坐标的方法:

        //得到文字的字体属性和测量Paint.FontMetrics fontMetrics = paint.getFontMetrics();//文字设置在View的中间float y = height / 2 + (Math.abs(fontMetrics.ascent) + fontMetrics.descent) / 2;//canvas绘制文本canvas.drawText(currentNum, 0,y, paint);

x坐标就不讲了,这里重点讲一下y坐标,这里主要用到了FontMetrics这个类,官网解释是:

这里结合一张图来讲:

图中有五条线结合官方文档,自上而下来解释:

  • top:给定文本大小下,字体中最高字符高于基线之上的最大距离
  • ascent:单个文本下超出基线之上的推荐距离
  • baseLine:文本基线
  • descent:单个字符超出基线之上的推荐距离
  • bottom:字体中最低字符超出基线之下的最大距离
  • leading:文本行与文本行之间的距离

上面图中红色的圆点,那个点对于TextView来说就是基线的原点,现在问题是要确定这个点对于这个View下的y坐标,可以看到这个点离整个View的中线下移一段距离,这段距离我是设定整个文本高度的一半,文本字体的高度可以用Math.abs(ascent) + descent,那么文本高度的一半也就是**(Math.abs(ascent) + descent)/ 2**。因此最终红色原点对于整个View的y坐标是float y = height / 2 + (Math.abs(fontMetrics.ascent) + fontMetrics.descent) / 2;

具体实践

完成滑动

上面讲了些基础知识,下面讲述最核心的就是滑动效果,在init方法里创建滚动实例:

        //创建滑动实例mScroller = new Scroller(context);

因为滑动只是View里面的TextView,要确定最大的左右滑动边界值,这里先上一个图,就是Android中View的坐标和获取一些距离方法:

ViewGroup就是平时一些LinearLayout,RelativeLayout布局,View如TextView,ImageView这些控件。
View提供的获取坐标以及距离的方法:

  • getTop获取的是View自身顶边到父布局顶边的距离
  • getLeft获取的是View自身左边到父布局左边的距离
  • getRight获取的是View自身右边到父布局左边的距离
  • getBottom获取的是View自身底边到父布局顶边的距离

MotionEvent提供的方法:

  • getX获取触摸事件触摸点距离控件左边的距离,是视图坐标
  • getY获取触摸事件触摸点距离控件顶边的距离,是视图坐标
  • getRawX获取触摸事件触摸点距离整个屏幕左边的距离,是绝对坐标
  • getRawY获取触摸事件触摸点距离整个屏幕顶边的距离,是绝对坐标

在ondraw方法分别得到View自身左边距离父布局左边距离和View自身右边到父布局左边的距离:

        //得到左右边界leftBorder = getLeft();rightBorder = (int)paint.measureText(currentNum);

currentNum就是TextView显示的文字内容,Paint.measureText就是测量文字的宽度。每个View都有onTouchEvent方法,onTouchEvent有手指触摸屏幕MotionEvent.ACTION_DOEM,MotionEvent.Action_MOVE方法,那就在这个方法实现滑动逻辑。

 @Overridepublic boolean onTouchEvent(MotionEvent ev){switch (ev.getAction()){case MotionEvent.ACTION_DOWN://记录初始触摸屏幕下的坐标mXDown = ev.getRawX();mLastMoveX = mXDown;break;case MotionEvent.ACTION_MOVE:mCurrentMoveX = ev.getRawX();//本次的滑动距离int scrolledX = (int) (mLastMoveX - mCurrentMoveX);//如果右滑时 内容左边界超过初始化时候的左边界 就还是初始化时候的状态if(getScrollX() + scrolledX < leftBorder){scrollTo(leftBorder,0);}//同理 如果左滑  这里判断右边界else if(getScrollX() + getWidth() + scrolledX > rightBorder){scrollTo(rightBorder - getWidth(),0);}else{//在左右边界中 自由滑动scrollBy(scrolledX,0);}mLastMoveX = mCurrentMoveX;break;}return true;}

上面代码主要最难理解的就是边界检测,下面是左边界检测代码:

                //如果右滑时 内容左边界超过初始化时候的左边界 就还是初始化时候的状态if(getScrollX() + scrolledX < leftBorder){scrollTo(leftBorder,0);}

这里用了getScrollX方法,这个方法是返回当前View视图左上角X坐标与View视图初始位置左上角X坐标的距离,注意,这是以屏幕坐标为参照点,View右移这个值由正变为负数一直递增。
这个其实列出一张图理解:

结合上面代码来看,一开始判断右滑到达左边界的时候,是通过滑动后TextView的左边界在初始状态时的左边界右边时,就是右滑达到最大值。因为这里getScrollx取得值和我们正常理解的值是反的:

if(getScrollX()  < leftBorder){scrollTo(leftBorder,0);}

这样来判断,这里默认leftBorder是0,也就是父布局的左边界和内容左边界一致重叠。但我发现效果会有抖动,应该是临界值没有判断到位,然后加上移动距离。

getScrollX() + scrolledX < leftBorder

其实转换为自然语言就是,View的移动距离比当前View视图左上角坐标与View视图初始位置x轴方向上的距离大,同理左滑时边界检测也是一样。注意:在非左右边界情况下,要用scrollBy方法来移动,因为这个是对于当前View位置来说的,还有,onTouchEvent要返回return true。因为return false或者return super.onTouchEvent只会执行down方法,不会执行move和up方法,只有在true的时候,三个都会执行,具体什么原因自行查找事件分发和消耗。其实这里不用重写computerScroll方法,就是在其内部完成平滑移动,computeScroll在父控件执行drawChild时,会调用这个方法。,效果图如下:

绘制顶部刻度长线

在attrs下添加属性集合:

    <declare-styleable name="SlidingRuleView"><!--长刻度的长度--><attr name="longDegreeLine" format="dimension"/><!--//线条颜色--><attr name="lineDegreeColor" format="color" /><!--顶部的直线距离View顶部距离--><attr name="topDegreeLine" format="dimension"/><!-- 刻度间隔--><attr name="lineDegreeSpace" format="dimension"/><!-- 刻度大数目 --><attr name="lineCount" format="integer"/></declare-styleable>

在构造函数读取attrs文件下属性:

public SlidingRuleView(Context context, AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);//初始化一些参数TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SlidingRuleView);//刻度线的颜色lineDegreeColor = typedArray.getColor(R.styleable.SlidingRuleView_lineDegreeColor, Color.LTGRAY);//顶部的直线距离View顶部距离topDegreeLine = typedArray.getDimension(R.styleable.SlidingRuleView_topDegreeLine, SystemUtil.dp2px(getContext(),45));//刻度间隔lineDegreeSpace = typedArray.getDimension(R.styleable.SlidingRuleView_lineDegreeSpace, SystemUtil.dp2px(getContext(),10));//刻度大数目 默认30lineCount = typedArray.getInt(R.styleable.SlidingRuleView_lineCount, 30);init(context);typedArray.recycle();}

初始化方法init确定顶部刻度线的右端:

    private void init(Context context){//初始化画笔 抗锯齿paint = new Paint(Paint.ANTI_ALIAS_FLAG);//创建滑动实例mScroller = new Scroller(context);//第一步,获取Android常量距离对象,这个类有UI中所使用到的标准常量,像超时,尺寸,距离ViewConfiguration configuration = ViewConfiguration.get(context);//获取最小移动距离mTouchMinDistance = configuration.getScaledTouchSlop();//确定刻顶部度长线右边界 格数 * 之间的间隔 * 大数目(间隔)之间是有10小间隔的rightBorder = lineDegreeSpace * lineCount * 10;}

这里解释一下**rightBorder = lineDegreeSpace * lineCount * 10;**意思是刻度之间的间隔 * 大刻度数 * 每个大刻度之间会有10个小刻度。
ondraw方法绘制:

 @Overrideprotected void onDraw(Canvas canvas) {super.onDraw(canvas);//确定顶部长线的左端float x = leftBorder;//确定顶部长线float y = topDegreeLine;//设置画笔颜色paint.setColor(lineDegreeColor);//设置刻度线宽度paint.setStrokeWidth(3);canvas.drawLine(x, y, rightBorder, y, paint);}

这样顶部刻度长线绘制完成:

绘制长刻度

在构造方法增加:

        //长的刻度线条长度longDegreeLine = typedArray.getDimension(R.styleable.SlidingRuleView_longDegreeLine, SystemUtil.dp2px(getContext(),35));

在onDraw方法里添加:

@Overrideprotected void onDraw(Canvas canvas) {super.onDraw(canvas);//确定顶部长线的左端float x = leftBorder;//确定顶部长线float y = topDegreeLine;//设置画笔颜色paint.setColor(lineDegreeColor);//设置刻度线宽度paint.setStrokeWidth(3);canvas.drawLine(x, y, rightBorder, y, paint);//循环绘制for(int i = 0;i <= lineCount * 10;i++){//画长刻度if(i % 10 == 0){paint.setColor(lineDegreeColor);paint.setStrokeWidth(5);canvas.drawLine(x, y, x, y + longDegreeLine, paint);}x += lineDegreeSpace;}}

循环绘制里,我这边是循环所有的刻度值,但是现在只绘制长刻度,因此i % 10 == 0的时候才绘制,因为绘制是从左往右的,每个刻度值的间隔是用lineDegreeSpace表示,因此每循环一遍,X坐标的值要对应增加x += lineDegreeSpace。
运行效果如下:

发现左右边界的刻度线太靠边了,加上左右间隔:

        <!--刻度尺左边界记录View左边界的距离--><attr name="ruleLeftSpacing" format="dimension" /><!--刻度尺右边界记录View右边界的距离--><attr name="ruleRightSpacing" format="dimension" />

构造方法增加:

        ruleLeftSpacing = typedArray.getDimension(R.styleable.SlidingRuleView_ruleLeftSpacing, SystemUtil.dp2px(getContext(),5));ruleRightSpacing = typedArray.getDimension(R.styleable.SlidingRuleView_ruleRightSpacing, SystemUtil.dp2px(getContext(),5));

初始化init方法变成:

        //增加左边界距离leftBorder = ruleLeftSpacing;//确定刻顶部度长线右边界 格数 * 之间的间隔 * 大数目(间隔)之间是有10小间隔的rightBorder = lineDegreeSpace * lineCount * 10+ ruleLeftSpacing + ruleRightSpacing;

ondraw绘制顶部长线变为:

        canvas.drawLine(x, y, rightBorder - ruleRightSpacing, y, paint);

onTouchEvent方法左右检测需要加上左右边距

    @Overridepublic boolean onTouchEvent(MotionEvent ev){switch (ev.getAction()){case MotionEvent.ACTION_DOWN://记录初始触摸屏幕下的坐标mXDown = ev.getRawX();mLastMoveX = mXDown;break;case MotionEvent.ACTION_MOVE:mCurrentMoveX = ev.getRawX();//本次的滑动距离int scrolledX = (int) (mLastMoveX - mCurrentMoveX);//如果右滑时 内容左边界超过初始化时候的左边界 就还是初始化时候的状态if(getScrollX() + scrolledX < leftBorder){scrollTo((int)(-leftBorder),0);return true;}//同理 如果左滑  这里判断右边界else if(getScrollX() + getWidth() + scrolledX > rightBorder){scrollTo((int)(rightBorder - getWidth() + ruleRightSpacing),0);return true;}else{//左右边界中 自由滑动scrollBy(scrolledX,0);}//当停止滑动时,现在的滑动已经变成上次滑动mLastMoveX = mCurrentMoveX;break;}return true;}

运行效果如下:

绘制长刻度值

在attrs文件下添加数字的颜色和大小:

        <!--//数字颜色--><attr name="numberColor" format="color" /><!--//数字大小--><attr name="numberSize" format="dimension" />

在构造方法增加对attrrs属性获取:

        //数字颜色numberColor = typedArray.getColor(R.styleable.SlidingRuleView_numberColor, Color.BLACK);//数字大小numberSize = typedArray.getDimension(R.styleable.SlidingRuleView_numberSize, SystemUtil.dp2px(getContext(),15));

onDraw方法绘制数字:

                //画刻度值String number = String.valueOf(i / 10);//得到文字宽度float textWidth = paint.measureText(number);//绘制颜色paint.setColor(numberColor);//绘制文字大小paint.setTextSize(numberSize);paint.setStrokeWidth(1);canvas.drawText(number, x - textWidth / 2, y + longDegreeLine + SystemUtil.dp2px(getContext(),25), paint);

这里主要讲下,数字的坐标,因为数字是在刻度线正下方的,所以x坐标应该是刻度线的x坐标减去本身自身宽度的一半,y坐标应该是刻度线长度再加上部分距离,我这边是加了25dp。
运行效果:

绘制短刻度值

在attrs文件增加短刻度的长度:

        <!--短刻度值的长度--><attr name="shortDegreeLine" format="dimension"/>

在构造函数获取其属性:

        //短刻度值的长度shortDegreeLine = typedArray.getDimension(R.styleable.SlidingRuleView_shortDegreeLine, SystemUtil.dp2px(getContext(),20));

在onDraw方法循环里非i%10==0的情况下绘制,这里很好理解:

 //循环绘制for(int i = 0;i <= lineCount * 10;i++){//画长刻度if(i % 10 == 0){paint.setColor(lineDegreeColor);paint.setStrokeWidth(5);canvas.drawLine(x, y, x, y + longDegreeLine, paint);//画刻度值String number = String.valueOf(i / 10);//得到文字宽度float textWidth = paint.measureText(number);//绘制颜色paint.setColor(numberColor);//绘制文字大小paint.setTextSize(numberSize);paint.setStrokeWidth(1);canvas.drawText(number, x - textWidth / 2, y + longDegreeLine + SystemUtil.dp2px(getContext(),25), paint);}else {//画短刻度paint.setColor(lineDegreeColor);paint.setStrokeWidth(3);canvas.drawLine(x, y, x, y + shortDegreeLine, paint);}x += lineDegreeSpace;}

运行效果图如下:

画绿色指针

绿色指针底部其实是半圆的,可以在drawable下建立shape文件,通过bitmap绘制,如:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"android:shape="rectangle"><solid android:color="#CCCCCC"/><cornersandroid:bottomLeftRadius="0px"android:bottomRightRadius="30dp"android:topLeftRadius="0px"android:topRightRadius="30dp"/>
</shape>

我这里为了方便,直接用直线来代替,在attrs文件下增加绿色指针颜色和其宽度:

        <!--指针宽度--><attr name="greenPointWidth" format="dimension"/><!--指针颜色--><attr name="greenPointColor" format="color"/>

在构造方法添加获取颜色,粗细:

        //绿色指针粗细greenPointWidth = typedArray.getDimension(R.styleable.SlidingRuleView_greenPointWidth, SystemUtil.dp2px(getContext(),4));//绿色指针颜色greenPointColor = typedArray.getColor(R.styleable.SlidingRuleView_greenPointColor, 0xFF4FBA75);

因为绿色指针永远是在View的中间,在onMeasure方法获取X坐标:

        //绿色指针的x坐标greenPointX =getMeasuredWidth() / 2;

在onDraw方法绘制指针:

        //画指针paint.setColor(greenPointColor);paint.setStrokeWidth(greenPointWidth);canvas.drawLine(greenPointX + getScrollX(), y, greenPointX + getScrollX(), y + longDegreeLine + SystemUtil.dp2px(getContext(),3),paint);

这里x坐标为什么要加上getScrollx(刻度尺的偏移量)呢,因为要保持指针在View的中间位置,不加上的话,指针会随着刻度移动而移动。

画当前刻度值

在attrs文件添加刻度值的颜色和大小,同理在构造函数获取属性,这里就不多讲来,因为数字是保留一位的,我这里用到了DecimalFormat来格式化数字:

        //数字小数点一位df = new DecimalFormat("0.0");

在onDraw绘制:

        //绘制当前刻度值//画当前刻度值paint.setColor(currentNumberColor);//设置大小paint.setTextSize(currentNumberSize);//确定数字的值。用移动多少来确定currentNum = df.format((greenPointX + getScrollX() - leftBorder) / (lineDegreeSpace * 10.0f));//测量数字宽度float textWidth = paint.measureText(currentNum);canvas.drawText(currentNum, greenPointX - textWidth / 2 + getScrollX(), topDegreeLine - SystemUtil.dp2px(getContext(),15), paint);

这里说一下确定数值的方法:

        //确定数字的值。用移动多少来确定currentNum = df.format((greenPointX + getScrollX() - leftBorder) / (lineDegreeSpace * 10.0f));

greenPointX + getScrollX() - leftBorder这条公式是确定指针到刻度尺最左边的距离是多少,再除以大刻度(每个大刻度有10个小刻度)的距离,就可以得出指针所指的刻度。确定数字的x坐标和y坐标就不做解释,很容易理解,因为数字是在刻度尺上面,所以要减去一些距离。

绘制kg

绘制kg无非是在当前刻度值右边,字体小一点,左右移动先不实现了:

        //画kg 大小是刻度值的3分之一paint.setTextSize(currentNumberSize / 3);canvas.drawText("kg", greenPointX + textWidth / 2 + getScrollX() + SystemUtil.dp2px(getContext(),3), topDegreeLine - SystemUtil.dp2px(getContext(),30), paint);

相比当前刻度值而言,在刻度尺的距离要大,我这里减去30dp。
最终效果:

效果和薄荷健康很类似吧,但是这里注意,因为刻度值只是设置一位小数,也就是绿色指针不能移到两个小刻度之间,下面处理一下,在触摸方法,增加up方法,也就是当手指抬起时,如果指针滑到两个刻度值之间,就将绿色指针移动最近的刻度值。

    private void moveRecently(){float distance = (greenPointX + getScrollX() - leftBorder) % lineDegreeSpace;//指针的位置在小刻度中间位置往后(右)if (distance >= lineDegreeSpace / 2) {scrollBy((int) (lineDegreeSpace - distance), 0);} else {scrollBy((int) (-distance), 0);}}

注意这里:

(greenPointX + getScrollX() - leftBorder) % lineDegreeSpace;

这里是取余操作,这里是确定指针在小刻度之间的具体位置,如果结果小于间隔的一半,那向后(右)最近的刻度移动,如果大于间隔的一半,那就向前(左)最近的刻度移动。到这里发现,指针不能指向0或者最后的位置,因为绿色指针在View的中间,那么左右边界检测需要改变,需要左边界减去上View的宽度一半,右边界需要加上View宽度的一半。

 @Overridepublic boolean onTouchEvent(MotionEvent ev){switch (ev.getAction()){case MotionEvent.ACTION_DOWN://记录初始触摸屏幕下的坐标mXDown = ev.getRawX();mLastMoveX = mXDown;break;case MotionEvent.ACTION_MOVE:mCurrentMoveX = ev.getRawX();//本次的滑动距离int scrolledX = (int) (mLastMoveX - mCurrentMoveX);//如果右滑时 内容左边界超过初始化时候的左边界 就还是初始化时候的状态if(getScrollX() + scrolledX < leftBorder - getWidth() / 2){scrollTo((int)(- getWidth() / 2 +leftBorder),0);return true;}//同理 如果左滑  这里判断右边界else if(getScrollX() + getWidth() / 2 + scrolledX > rightBorder){scrollTo((int)(rightBorder - getWidth() /2 - ruleRightSpacing),0);return true;}else{//左右边界中 自由滑动scrollBy(scrolledX,0);}mLastMoveX = mCurrentMoveX;break;case MotionEvent.ACTION_UP:moveRecently();break;}return true;}private void moveRecently(){float distance = (greenPointX + getScrollX() - leftBorder) % lineDegreeSpace;//指针的位置在小刻度中间位置往后(右)if (distance >= lineDegreeSpace / 2) {scrollBy((int) (lineDegreeSpace - distance), 0);} else {scrollBy((int) (-distance), 0);}}

最终效果如下图:

增加惯性滑动

发现这里没加惯性滑动,滑动很艰难,下面添加速度追踪器:

    /*** 监控手势速度类*/private VelocityTracker mVelocityTracker;//惯性最大最小速度protected int mMaximumVelocity, mMinimumVelocity;

在初始化方法获取最大滑动速度,最小滑动速度:

        //添加速度追踪器mVelocityTracker = VelocityTracker.obtain();//获取最大速度mMaximumVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity();//获取最小速度mMinimumVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity();

在触摸事件onTouchEvent初始化mVelocityTracker

mVelocityTracker.addMovement(ev);
 @Overridepublic boolean onTouchEvent(MotionEvent ev){mVelocityTracker.addMovement(ev);switch (ev.getAction()){case MotionEvent.ACTION_DOWN://记录初始触摸屏幕下的坐标mXDown = ev.getRawX();mLastMoveX = mXDown;break;case MotionEvent.ACTION_MOVE:mCurrentMoveX = ev.getRawX();//本次的滑动距离int scrolledX = (int) (mLastMoveX - mCurrentMoveX);//左右边界中 自由滑动scrollBy(scrolledX,0);mLastMoveX = mCurrentMoveX;break;case MotionEvent.ACTION_UP://处理松手后的Fling 获取当前事件的速率,1毫秒运动了多少个像素的速率,1000表示一秒mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);//获取横向速率int velocityX = (int) mVelocityTracker.getXVelocity();//滑动速度大于最小速度 就滑动if (Math.abs(velocityX) > mMinimumVelocity) {fling(-velocityX);}//刻度之间检测moveRecently();break;case MotionEvent.ACTION_CANCEL:if (mVelocityTracker != null) {mVelocityTracker.recycle();mVelocityTracker = null;}break;}return true;}

发现Move方法调用scrollBy方法,ACTION_UP增加了速度速率判断逻辑,最后调用了fling方法:

    private void fling(int vX) {mScroller.fling(getScrollX(), 0, vX, 0,(int)(- rightBorder),  (int)rightBorder, 0, 0);}

当用户手指快速划过屏幕,手指快速离开屏幕时,系统会判定用户执行一个Fling手势,视图会快速滚动,并且在手指离开屏幕之后也会滚动一定时间。

    /*** Start scrolling based on a fling gesture. The distance travelled will* depend on the initial velocity of the fling.* * @param startX Starting point of the scroll (X)* @param startY Starting point of the scroll (Y)* @param velocityX Initial velocity of the fling (X) measured in pixels per*        second.* @param velocityY Initial velocity of the fling (Y) measured in pixels per*        second* @param minX Minimum X value. The scroller will not scroll past this*        point.* @param maxX Maximum X value. The scroller will not scroll past this*        point.* @param minY Minimum Y value. The scroller will not scroll past this*        point.* @param maxY Maximum Y value. The scroller will not scroll past this*        point.*/

Scroller的fling函数就是基于手势滑动,参数的意思:

  • startX:开始滑动的X起点
  • startY:开始滚动的Y起点
  • velocityX:滑动的速度X
  • velocityY:滑动的速度Y
  • minX:X方向的最小值
  • maxX:X方向的最大值
  • minY:Y方向的最小值
  • MaxY:Y方向的最大值

增加computeScroll,这个方法在fling或者startScroll方法,调用invalidate方法后执行的函数,并在里面增加刻度边界的检测,完成平滑移动:

    @Overridepublic void computeScroll() {// 第三步,重写computeScroll()方法,并在其内部完成平滑滚动的逻辑if (mScroller.computeScrollOffset()) {scrollTo(mScroller.getCurrX(), mScroller.getCurrY());//这是最后mScroller的最后一次滑动 进行刻度边界检测if(!mScroller.computeScrollOffset()){moveRecently();}}}

最后重写scrollTo方法,加刻度尺滑动左右边界检测:

//重写滑动方法,设置到边界的时候不滑,并显示边缘效果。滑动完输出刻度。@Overridepublic void scrollTo( int x, int y) {//左边界检测if (x <  leftBorder - getWidth() / 2) {x = (int)(- getWidth() / 2 +leftBorder);}//有边界检测if (x + getWidth() / 2> rightBorder) {x = (int)(rightBorder - getWidth() /2 - ruleRightSpacing);}if (x != getScrollX()) {super.scrollTo(x, y);}}

最终运行效果如下:

总结

每一次练习,小案例都是知识的巩固和提升。
Demo链接

Android自定义View教你一步一步实现薄荷健康滑动卷尺相关推荐

  1. Android自定义View教你一步一步实现即刻点赞效果

    前言 今天朋友看了HenCoder的自定义View后说,HenCoder对自定义View讲的不错.实践中仿写即刻的点赞你有思路吗,你不实现一下?二话不说,看了朋友手机效果,对他说:实现不难,用到了位移 ...

  2. 【朝花夕拾】Android自定义View之(一)手把手教你看懂View绘制流程——向源码要答案

    前言 原文:Android自定义View之(一)手把手教你看懂View绘制流程--向源码要答案 View作为整个app的颜值担当,在Android体系中占有重要的地位.深入理解Android View ...

  3. Android自定义View之画圆环(手把手教你如何一步步画圆环)

    关于自定义View: 好了,吐槽时间到.自定义view是Android开发知识体系中的重点,也是难点.好多小伙伴(也包括我)之前对自定义view也是似懂非懂.那种感觉老难受了.因此作为社会主义好青年, ...

  4. Android自定义View:ViewGroup(三)

    自定义ViewGroup本质是什么? 自定义ViewGroup本质上就干一件事--layout. layout 我们知道ViewGroup是一个组合View,它与普通的基本View(只要不是ViewG ...

  5. Android自定义View之画圆环(进阶篇:圆形进度条)

    前言: 如果你想读懂或者更好的理解本篇文章关于自定义圆环或圆弧的内容.请你务必提前阅读下Android自定义View之画圆环(手把手教你如何一步步画圆环).在这篇文章中,详细描述了最基本的自定义圆环的 ...

  6. android代码实现手机加速功能,Android自定义View实现内存清理加速球效果

    Android自定义View实现内存清理加速球效果 发布时间:2020-09-21 22:21:57 来源:脚本之家 阅读:105 作者:程序员的自我反思 前言 用过猎豹清理大师或者相类似的安全软件, ...

  7. android自定义抽奖,Android自定义view制作抽奖转盘

    本文实例为大家分享了Android自定义view制作抽奖转盘的具体代码,供大家参考,具体内容如下 效果图 TurntableActivity package com.bawei.myapplicati ...

  8. android 自定义 child,Android自定义View

    前言 Android自定义View的详细步骤是我们每一个Android开发人员都必须掌握的技能,因为在开发中总会遇到自定义View的需求.为了提高自己的技术水平,自己就系统的去研究了一下,在这里写下一 ...

  9. android 立体 流量球,Android自定义View——实现水波纹效果类似剩余流量球

    Android自定义View--实现水波纹效果类似剩余流量球 三个点   pre   ber   block   span   初始化   move   理解最近突然手痒就想搞个贝塞尔曲线做个水波纹效 ...

最新文章

  1. .net firamework 框架里面的控件的继承关系。
  2. SQL语句-exec执行
  3. linux 用户态 隐藏进程 简介
  4. Linux下源码编译安装Python3
  5. Effective C++ --4 设计与声明
  6. 计算机5800怎么开机,神舟5800笔记本怎么进bios
  7. python输入整数_Python中实现输入一个整数的案例
  8. 80端口攻击_内网端口转发工具的使用总结
  9. html5响应式网站建设网络类织梦模板
  10. 招聘 | 深圳人工智能与机器人研究院 扩展现实研究中心
  11. abaqus利用python实现部件合并_在abaqus python中使用Element()构造函数创建部件元素...
  12. 联想小新增加固态硬盘后安装不了系统_4千价位也能面面俱到?小新Air14 2020锐龙版体验测试...
  13. 动态数组--一维数组
  14. LeetCode——remove-duplicates-from-sorted-list
  15. 桌面计算机图标变黑块,win7桌面图标变成有黑色方块怎么办?4个步骤轻松搞定...
  16. GET和POST的区别?
  17. PHP获取中国所有的大学,全国300所大学的BBS论坛.doc
  18. 为什么用Win32forth编程的程序员不多
  19. 我的世界服务器修改高度放水,我的世界:水不够用?学会这4种方法,教你一桶水造无限水...
  20. 足球数据采集 php,如何获取足球【赛程结果】数据

热门文章

  1. [车联网安全自学篇] Car Hacking之车联网安全学习路线图
  2. 基于 github issues 实现第三方评论系统
  3. html汽车跑动特效,纯CSS3实现汽车行驶动画 特效源码!
  4. kali渗透测试之一信息搜集
  5. c语言低通滤波程序,一阶低通滤波器c语言
  6. modbus总线协议(一)modbus rtu
  7. Ubuntu18.04有线+离线划词翻译GoldenDict(四十五)
  8. 实现《黑客帝国》字符雨 html 代码
  9. easy to understander characterise resistent
  10. python散点图animation_如何设置散点图的动画?