自定义FM刻度尺
Scroller学习
- Scroller:Android里面专门处理滚动的工具类,比如ViewPager、ListView等。
- scrollTo:设置View的滚动位置,并且会刷新View。
- scrollBy:移动View的滚动位置,并且会刷新View。
- 无论scrollTo还是scrollBy都是移动父布局里的内容,但是scrollTo再调用后再次调用就没有效果了,这是因为scrollTo()方法是让View相对于初始的位置滚动某段距离;而scrollBy则可以继续调用,这是因为scrollBy()方法是让View相对于当前的位置滚动某段距离;无论是scrollTo还是scrollBy最终都会调用invalidate方法进行刷新。
自定义View中的onMeasure方法
/**
* 获得此ViewGroup上级容器为其推荐的宽和高,以及计算模式
*/
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);
}
Measure.Mode测量模式:
- UNSPECIFIED:父容器不对子View做任何限制,要多大给多大,一般用于系统内部,这里就不用多考虑
- EXACTLY:精准模式,一般View指定了具体的大小(dp/px)或者设置match_parent
- AT_MOST:父容器制定了一个可用的大小,子View不能大于这个值,这个是在布局设置wrap_content
文字属性
图中有五条线结合官方文档,自上而下来解释:
- top:给定文本大小下,字体中最高字符高于基线之上的最大距离
- ascent:单个文本下超出基线之上的推荐距离
- baseLine:文本基线
- descent:单个字符超出基线之上的推荐距离
- bottom:字体中最低字符超出基线之下的最大距离
- leading:文本行与文本行之间的距离
//得到文字的字体属性和测量
Paint.FontMetrics fontMetrics = paint.getFontMetrics();
//文字设置在View的中间
float y = height / 2 + (Math.abs(fontMetrics.ascent) + fontMetrics.descent) / 2;
文本字体的高度可以用Math.abs(ascent) + descent,那么文本高度的一半也就是**(Math.abs(ascent) + descent)/ 2**。因此最终红色原点对于整个View的y坐标是float y = height / 2 + (Math.abs(fontMetrics.ascent) + fontMetrics.descent) / 2;
完成滑动
-
首先创建滚动实例
//创建滑动实例 mScroller = new Scroller(context);
-
Android中View的坐标和获取距离方法
View提供的获取坐标以及距离的方法:
- getTop获取的是View自身顶边到父布局顶边的距离
- getLeft获取的是View自身左边到父布局左边的距离
- getRight获取的是View自身右边到父布局左边的距离
- getBottom获取的是View自身底边到父布局顶边的距离
MotionEvent提供的方法:
1. getX获取触摸事件触摸点距离控件左边的距离,是视图坐标 2. getY获取触摸事件触摸点距离控件顶边的距离,是视图坐标 3. getRawX获取触摸事件触摸点距离整个屏幕左边的距离,是绝对坐标 4. getRawY获取触摸事件触摸点距离整个屏幕顶边的距离,是绝对坐标
-
获取View自身左右边距离到到父布局左右距离
//得到左右边界 leftBorder = getLeft(); rightBorder = (int)paint.measureText(currentNum);
-
左右边界检测
//如果右滑时 内容左边界超过初始化时候的左边界 就还是初始化时候的状态 if (getScrollX() + scrolledX < mLeftBorder - getWidth() / 2) { scrollTo((int) (-getWidth() / 2 + mLeftBorder), 0); } else if (getScrollX() + scrolledX > mRightBorder) { //如果左滑 这里判断右边界 scrollTo((int) mRightBorder, 0); } else { //在左右边界中 自由滑动 scrollBy(scrolledX, 0); }
getScrollX方法:返回当前View视图左上角X坐标与View视图初始位置左上角X坐标的距离,注意,这是以屏幕坐标为参照点,View右移这个值由正变为负数一直递增。
-
完整代码,增加惯性滑动和手势监听:
public class RulerView extends View { private static final String TAG = "SlideView"; //刻度线画笔 private Paint mLinePaint; //数字画笔 private Paint mTextPaint; //指针画笔 private Paint mPointPaint; //当前刻度值 private String mCurrentNum; //左边界 private float mLeftBorder; //右边界 private float mRightBorder; //手指最开始触摸屏幕的X坐标 private float mDownX; //上次触发移动事件的坐标 private float mLastMoveX; //当前手指移动结束后停下来的X坐标 private float mCurrentMoveX; //10的倍数高度 private float mLongDegreeLine; //除以10余5高度 private float mMiddleDegreeLine; //小刻度 private float mMinDegreeLine; //滑动实例 private Scroller mScroller; //刻度间隔 private float mLineDegreeSpace; //刻度颜色 private int mLineDegreeColor; //刻度顶部线 private float mTopDegreeLine; //中刻度数 private int mLineCount; //最小滑动距离 private int mTouchMinDistance; //数字颜色 private int mTextColor; //数字大小 private float mTextSize; //指针大小 private float mPointWidth; //指针X轴位置 private float mPointX; //格式化当前FM private DecimalFormat mFormat; //当前刻度值的大小 private float mCurrentNumberSize; //当前刻度值的颜色 private int mCurrentNumberColor; //监听手势滑动速度 private VelocityTracker mVelocityTracker; //设置滑动惯性的最大速度 private int mMaxVelocity; //设置滑动惯性的最大速度 private int mMinVelocity; //手动设置刻度 private float mSetDegree; public RulerView(Context context) { super(context); init(context); } public RulerView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(context); } private void init(Context context) { mLinePaint = new Paint(); mLinePaint.setAntiAlias(true); mLinePaint.setStyle(Paint.Style.STROKE); mLinePaint.setStrokeWidth(3); mTextPaint = new Paint(); mTextPaint.setAntiAlias(true); mTextPaint.setStyle(Paint.Style.FILL); mTextPaint.setStrokeWidth(5); mPointPaint = new Paint(); mPointPaint.setAntiAlias(true); mPointPaint.setStyle(Paint.Style.FILL); mPointWidth = dp2px(4, context); mPointPaint.setStrokeWidth(mPointWidth); mPointPaint.setColor(0xFF4FBA75); mFormat = new DecimalFormat("0.0"); mScroller = new Scroller(context); mLineDegreeColor = Color.GRAY; mTopDegreeLine = dp2px(45, context); mLineDegreeSpace = dp2px(10, context); mLineCount = 41; ViewConfiguration viewConfiguration = ViewConfiguration.get(context); mTouchMinDistance = viewConfiguration.getScaledTouchSlop(); mLongDegreeLine = dp2px(35, context); mMiddleDegreeLine = dp2px(20, context); mMinDegreeLine = dp2px(10, context); mRightBorder = mLineDegreeSpace * mLineCount * 5; mTextColor = Color.BLACK; mTextSize = dp2px(15, context); mCurrentNumberColor = 0xFF4FBA75; mCurrentNumberSize = dp2px(30, context); //初始化实例 mVelocityTracker = VelocityTracker.obtain(); //设置最大速度和最小速度 mMaxVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity(); mMinVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity(); } private static int dp2px(float dp, Context context) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); Log.e(TAG, "onMeasure: widthSpecMode = " + widthSpecMode); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); Log.e(TAG, "onMeasure: widthSpecSize = " + widthSpecSize); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); Log.e(TAG, "onMeasure: heightSpecMode = " + heightSpecMode); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); Log.e(TAG, "onMeasure: heightSpecSize = " + heightSpecSize); if (heightSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSpecSize, dp2px(120, getContext())); } else { setMeasuredDimension(widthSpecSize, heightSpecSize); } //使得绘制时从中间开始绘制 mLeftBorder = getMeasuredWidth() / 2; mPointX = getMeasuredWidth() / 2; Log.e(TAG, "onMeasure: mPointX = " + mPointX); } @Override public boolean onTouchEvent(MotionEvent event) { //在触摸事件onTouchEvent初始化mVelocityTracker mVelocityTracker.addMovement(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //滑动中点击,停止滑动 if (!mScroller.isFinished()) { mScroller.abortAnimation(); } //记录初始触摸屏幕下的坐标 mDownX = event.getRawX(); mLastMoveX = mDownX; break; case MotionEvent.ACTION_MOVE: mCurrentMoveX = event.getRawX(); //本次的滑动距离 int scrolledX = (int) (mLastMoveX - mCurrentMoveX); Log.e(TAG, "onTouchEvent: scrolledX = " + scrolledX); scrollBy(scrolledX, 0); mLastMoveX = mCurrentMoveX; break; case MotionEvent.ACTION_UP: //计算松手后当前滑动的速率,一秒内滑动多少像素 mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity); //获取横向滑动速度 int xVelocity = (int) mVelocityTracker.getXVelocity(); //判断滑动速度是否大于最小滑动速度 if (Math.abs(xVelocity) > mMinVelocity) { mScroller.fling(getScrollX(), 0, -xVelocity, 0, (int) -mRightBorder, (int) mRightBorder, 0, 0); Log.e(TAG, "setDegree: getScroller = " + getScrollX()); } else { Log.d(TAG, "onTouchEvent: xVelocity < mMinVelocity"); } moveToRecentlyDegree(); break; case MotionEvent.ACTION_CANCEL: //VelocityTracker资源回收 if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } else { Log.d(TAG, "onTouchEvent: mVelocityTracker is null"); } break; default: break; } return true; } @Override public void computeScroll() { super.computeScroll(); if (mScroller.computeScrollOffset()) { scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); //最后一次滑动则进行边界检测 if (!mScroller.computeScrollOffset()) { moveToRecentlyDegree(); } } } @Override public void scrollTo(int x, int y) { //左边界检测 if (x < mLeftBorder - getWidth() / 2) { x = (int) (-getWidth() / 2 + mLeftBorder); } else if (x > mRightBorder) { //右边界检测 x = (int) mRightBorder; } if (x != getScrollX()) { super.scrollTo(x, y); } } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //确定顶部长线的左端 float x = mLeftBorder; //确定顶部长线 float y = mTopDegreeLine; mLinePaint.setColor(mLineDegreeColor); mLinePaint.setStrokeWidth(3); //绘制顶部长线 //canvas.drawLine(x, y, mRightBorder - mRightSpace - mLeftSpace + getWidth() / 2, y, mLinePaint); canvas.drawLine(x, y, mRightBorder + getWidth() / 2, y, mLinePaint); for (int i = 0; i <= mLineCount * 5; i++) { if (i % 10 == 0) { mLinePaint.setStrokeWidth(5); canvas.drawLine(x, y, x, y + mLongDegreeLine, mLinePaint); //FM范围为87.5-108 String num = String.valueOf(i / 10 + 87.5); //测量数字宽度 float textWidth = mTextPaint.measureText(num); mTextPaint.setColor(mTextColor); mTextPaint.setTextSize(mTextSize); canvas.drawText(num, x - textWidth / 2, y + mLongDegreeLine + dp2px(25, getContext()), mTextPaint); } else if (i % 10 == 5) { mLinePaint.setStrokeWidth(5); canvas.drawLine(x, y, x, y + mMiddleDegreeLine, mLinePaint); } else { mLinePaint.setStrokeWidth(3); canvas.drawLine(x, y, x, y + mMinDegreeLine, mLinePaint); } x += mLineDegreeSpace; } mTextPaint.setColor(mCurrentNumberColor); mTextPaint.setTextSize(mCurrentNumberSize); mCurrentNum = mFormat.format((mPointX + getScrollX() - mLeftBorder) / (mLineDegreeSpace * 10.0f) + 87.5); float textWidth = mTextPaint.measureText(mCurrentNum); //绘制当前刻度数字 canvas.drawText(mCurrentNum, mPointX - textWidth / 2 + getScrollX(), y - dp2px(15, getContext()), mTextPaint); //绘制指针 canvas.drawLine(mPointX + getScrollX(), y, mPointX + getScrollX(), y + mLongDegreeLine + dp2px(3, getContext()), mPointPaint); Log.e(TAG, "onDraw: "); } /** * 吸附处理 */ private void moveToRecentlyDegree() { //获取当前刻度距离中心刻度的距离 float distance = (mPointX + getScrollX() - mLeftBorder) % mLineDegreeSpace; //如果大于间隔的二分之一就向前滑 if (distance >= mLineDegreeSpace / 2) { scrollBy((int) (mLineDegreeSpace - distance), 0); } else { //否则向后滑动 scrollBy((int) -distance, 0); } } //自定义设置刻度 public void setDegree(float degree) { Log.e(TAG, "setDegree: getScroller = " + getScrollX()); mSetDegree = degree; int scrolledX = (int) ((degree - Float.parseFloat(mCurrentNum)) * (mLineDegreeSpace * 10.0f)); scrollBy(scrolledX, 0); moveToRecentlyDegree(); mSetDegree = 0; } }
MainActivity:
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); RulerView rulerView = findViewById(R.id.ruler_view); EditText editText = findViewById(R.id.frequency); editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL); Button send = findViewById(R.id.send); send.setOnClickListener(view -> { String frequency; float fr; if (!TextUtils.isEmpty(editText.getText().toString())){ frequency = editText.getText().toString(); if (frequency.endsWith(".")){ Toast.makeText(MainActivity.this,"请输入正确的电台!",Toast.LENGTH_SHORT).show(); return; } fr = Float.parseFloat(frequency); if (fr < 87.5 || fr > 108 ){ Toast.makeText(MainActivity.this,"请输入电台FM范围内的电台频点!",Toast.LENGTH_SHORT).show(); } else { rulerView.setDegree(fr); } } else { Log.d("TAG", "onCreate: editText is null"); } }); } }
布局文件:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <com.example.scorllerdemo.RulerView android:id="@+id/ruler_view" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <EditText android:id="@+id/frequency" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/send" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Send" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/frequency" /> </androidx.constraintlayout.widget.ConstraintLayout>