最近擼了一個自定義view,仍是比較複雜的,感受有必要分享下實現的過程。java
先來看下效果吧:git
咱們來分析這個view須要實現哪些效果。github
別懼怕有這麼多的功能,咱們一個一個來實現。首先是刻度尺,這個簡單。因爲完整的刻度尺是比屏幕寬度大的,所以咱們先來了解幾個概念:面試
這裏手機屏幕的寬度是width,刻度尺的寬度的時maxWidth,咱們其實只須要繪製手機屏幕可見的部分就能夠了,這裏的offset表示手機屏幕的左邊與刻度尺左邊的偏移量。canvas
瞭解了這個概念,咱們就來開始寫吧,定義一個View,處理下構造都指向3個參數的那個,而後統一作初始化:api
public class SelectView extends View { private final int DEFAULT_HEIGHT = dp2px(100);//wrap_content高度 private Paint mPaint; public int dp2px(final float dpValue) { final float scale = getContext().getResources().getDisplayMetrics().density; return (int) (dpValue * scale + 0.5f); } public SelectView(Context context) { this(context, null); } public SelectView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public SelectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); scroller = new OverScroller(context); init(); } private void init() { mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setTextSize(textSize); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); width = widthSize; int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (heightMode == MeasureSpec.EXACTLY) { height = heightSize; } else { height = DEFAULT_HEIGHT;//wrap_content的高 } setMeasuredDimension(width, height); } }
咱們在onMeasure中處理了wrap_content的高度。而後在onSizeChanged中獲取尺寸參數:性能優化
private int width;//控件寬度 private int height;//控件高度 private int maxWidth;//最大內容寬度 private int totalWidth;//刻度總體寬度(最後一個刻度的文字在刻度外) private int minOffset = 0; private int maxOffset; private int offset = minOffset;//可視區域左邊界與總體內容左邊界的偏移量 @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); totalWidth = titles.length * space; maxWidth = totalWidth - space; maxOffset = totalWidth - width; if (maxOffset < 0) { maxOffset = 0; } areaTop = (1 - areaRate) * height; }
接着就開始繪製吧:架構
private String[] titles = {"09:00", "09:30", "10:00", "10:30", "11:00", "11:30", "12:00", "12:30", "13:00", "13:30", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00", "17:30", "18:00"}; private int space = dp2px(40);//刻度間隔 private int lineWidth = dp2px(1);//刻度線的寬度 private int textSize = dp2px(12); private int textMargin = dp2px(8);//文字與長刻度的margin值 private int rate = 1; //短刻度與長刻度數量的比例(>=1) private float lineRate = 0.4f;//短刻度與長刻度長度的比例(0.0~1.0) @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawLine(canvas); } private void drawLine(Canvas canvas) { mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(lineWidth); mPaint.setColor(Color.BLACK); canvas.drawLine(0, height, width, height, mPaint); for (int i = 0; i < titles.length; i++) { int position = i * space; if (position >= offset && position <= offset + width) {//判斷是否能夠顯示在屏幕中 int x = position - offset; if (i % (rate + 1) == 0) {//繪製長刻度 canvas.drawLine(x, 0, x, height, mPaint); mPaint.setStyle(Paint.Style.FILL); canvas.drawText(titles[i], x + textMargin, textSize, mPaint); mPaint.setStyle(Paint.Style.STROKE); } else {//繪製短刻度 canvas.drawLine(x, height * (1 - lineRate), x, height, mPaint); } } } }
這裏的titles表明了刻度的標識,每個元素表明一個刻度(這裏我字節寫死了,實際上能夠經過方法set,也不必定是時間,能表明刻度的均可以)。經過rate設置長短刻度的比例,這裏我設置了1:1。運行一下看看,目前僅僅能看到從0開始,看不到完整的刻度尺,咱們須要實現touch事件產生移動纔有效果。app
咱們重寫onTouchEvent來實現滑動效果:ide
private float downX, downY; private float lastX;//滑動上一個位置 @Override public boolean onTouchEvent(MotionEvent event) { int action = event.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: downX = event.getX(); lastX = downX; break; case MotionEvent.ACTION_MOVE: float x = event.getX(); float dx = x - lastX; changeOffsetBy(-dx); lastX = x; postInvalidate(); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: break; default: break; } return true; } private void changeOffsetBy(float dx) { offset += dx; if (offset < minOffset) { offset = minOffset; } else if (offset > maxOffset) { offset = maxOffset; } }
咱們計算出每次move事件的X方向的變化量dx,而後經過這個dx改變offset,而且處理一下邊界的狀況。而後調用postInvalidate刷新界面。
運行一下看看!如今咱們能夠滑動刻度尺了。可是好像還有點問題,平時咱們使用ScrollView的時候用力劃一下,能夠看到手指離開了屏幕,可是內容還能夠繼續滾動。而目前咱們自定義的這個view只能經過手指滑動,若是手指離開屏幕就不能滑動了。這樣的體驗顯然不夠好,咱們來實現這個慣性滑動的效果吧!
要實現慣性滑動,咱們須要用到兩個類:VelocityTracker,OverScroller。
VelocityTracker簡介
view滑動助手類OverScroller
private int minFlingVelocity;//最小慣性滑動速度 private VelocityTracker velocityTracker; private OverScroller scroller; private int lastFling;//慣性滑動上一個位置 private void init(Context context) { ... scroller = new OverScroller(context); minFlingVelocity = ViewConfiguration.get(getContext()).getScaledMinimumFlingVelocity(); } @Override public boolean onTouchEvent(MotionEvent event) { if (velocityTracker == null) { velocityTracker = VelocityTracker.obtain(); } velocityTracker.addMovement(event); int action = event.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: scroller.forceFinished(true); downX = event.getX(); lastX = downX; break; case MotionEvent.ACTION_MOVE: float x = event.getX(); float dx = x - lastX; changeOffsetBy(-dx); lastX = x; postInvalidate(); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: //處理慣性滑動 velocityTracker.computeCurrentVelocity(1000, 8000); float xVelocity = velocityTracker.getXVelocity(); if (Math.abs(xVelocity) > minFlingVelocity) { scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0); } velocityTracker.clear(); break; default: break; } return true; } @Override public void computeScroll() { if (scroller.computeScrollOffset()) { int currX = scroller.getCurrX(); float dx = currX - lastFling; //已經在邊界了,再也不處理慣性 if ((offset <= minOffset && dx > 0) || offset >= maxOffset && dx < 0) { scroller.forceFinished(true); return; } changeOffsetBy(-dx); lastFling = currX; postInvalidate(); } else { lastFling = 0;//重置上一次值,避免第二次慣性滑動計算錯誤的dx } }
velocityTracker.computeCurrentVelocity方法的第二個參數表示最大慣性速度,這裏我設置8000,避免刻度尺過快的滑動。經過調用scroller.fling方法將計算出的速度交給scroller,而後在computeScroll方法中獲取當前值,並與上一次的值作差算出變化量dx,一樣用這個dx變化offset刷新界面實現滑動效果。
刻度尺完成了,接下來是不可選的灰色區域。我採用兩個int值表示在刻度尺的區域,刻度尺的每一個刻度表示一個最小單位,前一個int表示在刻度尺的起始位置,後一個int表示佔據的刻度數量。
private List<int[]> unselectableList = new ArrayList<>(); private List<RectF> unselectableRectFs = new ArrayList<>(); private RectF tempRect = new RectF(); @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawLine(canvas); drawUnselectable(canvas); } private void drawUnselectable(Canvas canvas) { generateUnselectableRectFs(); mPaint.setStyle(Paint.Style.FILL); mPaint.setColor(Color.parseColor("#99878787")); for (RectF rectF : unselectableRectFs) { float left = Math.max(rectF.left, offset) - offset; float right = Math.min(rectF.right, offset + width) - offset; tempRect.set(left, rectF.top, right, rectF.bottom); canvas.drawRect(tempRect, mPaint); } } private void generateUnselectableRectFs() { //避免重複生成 if (unselectableRectFs.size() > 0 && unselectableList.size() == unselectableRectFs.size()) { return; } unselectableRectFs.clear(); for (int[] ints : unselectableList) { int start = ints[0]; int count = ints[1]; int max = titles.length - 1; if (start > max || start + count > max) { throw new IllegalArgumentException("unselectable area has wrong start or count, " + "the total limit is" + max); } if (count > 0) { unselectableRectFs.add(new RectF(start * space, areaTop, (start + count) * space, height)); } } } public void addUnseletable(int start, int count) { unselectableList.add(new int[]{start, count}); postInvalidate(); }
我用一個list存放設置的不可選區域,而後在另外一個list中存放轉換成RectF的位置信息。這裏的RectF是在相對於總體刻度尺而言的,所以繪製到屏幕的時候須要減去offset,而且須要考慮只有部分在屏幕可見的狀況。避免在onDraw方法中建立過多臨時變量,我聲明一個成員變量tempRect,用來保存繪製時的臨時參數。
完成了不可選區域,可選區域也是一樣的。因爲只能有一個可選區域,咱們只須要定義一個RectF。額外須要考慮與不可選區域相交時會變色,我定了一個overlapping表示是否相交,經過RectF的intersects方法判斷。
private int selectedBgColor = Color.parseColor("#654196F5"); private int selectedStrokeColor = Color.parseColor("#4196F5"); private int overlappingBgColor = Color.parseColor("#65FF6666"); private int overlappingStrokeColor = Color.parseColor("#FF6666"); private int selectedStrokeWidth = dp2px(2); private int extendRadius = dp2px(7);//擴展圓的半徑 private float extendTouchRate = 1.5f;//擴展觸摸區域與視圖的比率(>=1) private boolean overlapping;//是否覆蓋unselectable private RectF selectedRectF;//選擇區域位置 private RectF extendPointRectF;//擴展點位置 @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawLine(canvas); drawUnselectable(canvas); drawSelected(canvas); } private void drawSelected(Canvas canvas) { if (selectedRectF == null) { return; } overlapping = checkOverlapping(); float left = Math.max(selectedRectF.left, offset) - offset; float right = Math.min(selectedRectF.right, offset + width) - offset; tempRect.set(left, selectedRectF.top, right, selectedRectF.bottom); //填充 mPaint.setStyle(Paint.Style.FILL); mPaint.setColor(overlapping ? overlappingBgColor : selectedBgColor); canvas.drawRect(tempRect, mPaint); //邊框 mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(selectedStrokeWidth); mPaint.setColor(overlapping ? overlappingStrokeColor : selectedStrokeColor); canvas.drawRect(tempRect, mPaint); if ((selectedRectF.right - offset) == right) { //擴展圓邊框 mPaint.setColor(overlapping ? overlappingStrokeColor : selectedStrokeColor); canvas.drawCircle(tempRect.right, tempRect.centerY(), extendRadius, mPaint); //擴展圓填充 mPaint.setColor(Color.WHITE); mPaint.setStyle(Paint.Style.FILL); canvas.drawCircle(tempRect.right, tempRect.centerY(), extendRadius, mPaint); //擴展圓的位置信息,處理touch事件須要 extendPointRectF = new RectF(selectedRectF.right - extendRadius * extendTouchRate, selectedRectF.centerY() - extendRadius * extendTouchRate, selectedRectF.right + extendRadius * extendTouchRate, selectedRectF.centerY() + extendRadius * extendTouchRate); } else { extendPointRectF = null; } } private boolean checkOverlapping() { if (selectedRectF != null) { for (RectF rectF : unselectableRectFs) { if (rectF.intersects(selectedRectF.left, selectedRectF.top, selectedRectF.right, selectedRectF.bottom)) { return true; } } } return false; }
經過前面的分析,咱們知道這個view中的事件有不少種:點擊,移動刻度尺,移動選中區域,擴展選中區域。咱們定義這四種類型便於後續的事件處理:
public static final int TYPE_MOVE = 1; public static final int TYPE_EXTEND = 2; public static final int TYPE_CLICK = 3; public static final int TYPE_SLIDE = 4;
而後改造一下onTouchEvent:
private boolean linking;//是否正在聯動 private Handler handler = new BookHandler(this); private int boundary = space / 2;//屏幕邊界範圍 private static class BookHandler extends Handler { private static final int DELAY_MILLIS = 10;//刷新率(0~16) private WeakReference<SelectView> selectViewWeakReference; BookHandler(SelectView selectView) { super(); selectViewWeakReference = new WeakReference<>(selectView); } @Override public void handleMessage(Message msg) { SelectView view = selectViewWeakReference.get(); if (view != null) { float dx = (float) msg.obj; view.changeOffsetBy(dx); if (msg.what == MESSAGE_EXTEND) { float r = view.selectedRectF.right + dx; view.resetSelectedRight(r); } else if (msg.what == MESSAGE_MOVE) { float l = view.selectedRectF.left + dx; float r = view.selectedRectF.right + dx; view.resetSelectedRectF(l, r); } view.postInvalidate(); if (view.linking) { sendMessageDelayed(Message.obtain(msg), DELAY_MILLIS); } } } } @Override public boolean performClick() { return super.performClick(); } @Override public boolean onTouchEvent(MotionEvent event) { if (velocityTracker == null) { velocityTracker = VelocityTracker.obtain(); } velocityTracker.addMovement(event); int action = event.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: scroller.forceFinished(true); downX = event.getX(); lastX = downX; downY = event.getY(); checkTouchType(); break; case MotionEvent.ACTION_MOVE: float x = event.getX(); float dx = x - lastX; if (touchType == TYPE_EXTEND) { handleExtend(dx); } else if (touchType == TYPE_MOVE) { handleMove(dx); } else if (touchType == TYPE_SLIDE) { changeOffsetBy(-dx); } lastX = x; postInvalidate(); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: float upX = event.getX(); float upY = event.getY(); if (Math.abs(upX - downX) < touchSlop && Math.abs(upY - downY) < touchSlop) { touchType = TYPE_CLICK; performClick(); } handleActionUp(upX); break; default: break; } return true; } private void checkTouchType() { RectF extend = null; if (extendPointRectF != null) { extend = new RectF(extendPointRectF.left - offset, extendPointRectF.top, extendPointRectF.right - offset, extendPointRectF.bottom); Timber.i("extend:" + extend.toString()); } RectF selected = null; if (selectedRectF != null) { selected = new RectF(selectedRectF.left - offset, selectedRectF.top, selectedRectF.right - offset, selectedRectF.bottom); Timber.i("selected:" + selected.toString()); } if (extend != null && extend.contains(lastX, downY)) { touchType = TYPE_EXTEND; } else if (selected != null && selected.contains(lastX, downY)) { touchType = TYPE_MOVE; } else { touchType = TYPE_SLIDE; } } private void handleExtend(float dx) { //若是正在聯動時,避免手指抖動形成沒必要要中止 if (linking && Math.abs(dx) < touchSlop) { return; } float right = selectedRectF.right += dx; //下層聯動 Message message = null; if (dx > 0 && width - (right - offset) < boundary //選中區域滑到屏幕右邊 && offset < maxOffset) { message = handler.obtainMessage(MESSAGE_EXTEND, linkDx); } else if (dx < 0 && right > selectedRectF.left && right - offset < boundary && offset > minOffset) { message = handler.obtainMessage(MESSAGE_EXTEND, -linkDx); } if (message != null) { if (!linking) { linking = true; handler.sendMessage(message); } } else { stopLinking(); resetSelectedRight(right); } } private void handleMove(float dx) { //若是正在聯動時,避免手指抖動形成沒必要要中止 if (linking && Math.abs(dx) < touchSlop) { return; } float left = selectedRectF.left += dx; float right = selectedRectF.right += dx; Message message = null; if ((dx < 0 && left - offset < boundary && offset > minOffset)) {//選中區域滑到屏幕左邊並繼續向左滑動 message = handler.obtainMessage(MESSAGE_MOVE, -linkDx); } else if (dx > 0 && width - (right - offset) < boundary && offset < maxOffset) {//選中區域滑到屏幕右邊而且繼續向右滑動 message = handler.obtainMessage(MESSAGE_MOVE, linkDx); } Timber.e("message:" + message); if (message != null) {//處在兩邊界,須要聯動 if (!linking) { linking = true; handler.sendMessage(message); } } else { stopLinking(); resetSelectedRectF(left, right); } } private void handleActionUp(float upX) { if (touchType == TYPE_CLICK) { int start = (int) ((upX + offset) / space); int[] area = getSelected(); setSelected(start, area == null ? CLICK_SPACE : area[1]); } else if (touchType == TYPE_EXTEND) { stopLinking(); int right = Math.round(selectedRectF.right / space) * space; resetSelectedRight(right); postInvalidate(); } else if (touchType == TYPE_MOVE) { stopLinking(); int[] area = getSelected(); if (area != null) { setSelected(area[0], area[1]); } } else if (touchType == TYPE_SLIDE) { //處理慣性滑動 velocityTracker.computeCurrentVelocity(1000, 8000); float xVelocity = velocityTracker.getXVelocity(); if (Math.abs(xVelocity) > minFlingVelocity) { scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0); } velocityTracker.clear(); } } private void stopLinking() { linking = false; handler.removeCallbacksAndMessages(null); } /** * 重置選擇區域的位置 * * @param left * @param right */ private void resetSelectedRectF(float left, float right) { if (left < 0) { left = 0; right = selectedRectF.right - selectedRectF.left; } if (right > maxWidth) { right = maxWidth; left = maxWidth - (selectedRectF.right - selectedRectF.left); } int minSpace = minSelect * space; if (right - left < minSpace) {//最小值 if (maxWidth - selectedRectF.left < minSpace) { right = maxWidth; left = maxWidth - minSpace; } else { right = selectedRectF.left + minSpace; } } selectedRectF.left = left; selectedRectF.right = right; } /** * 重置選擇區域的right * * @param right */ private void resetSelectedRight(float right) { if (right > maxWidth) { right = maxWidth; } int minSpace = minSelect * space; if (right - selectedRectF.left < minSpace) {//最小值 if (maxWidth - selectedRectF.left < minSpace) { right = maxWidth; selectedRectF.left = maxWidth - minSpace; } else { right = selectedRectF.left + minSpace; } } selectedRectF.right = right; } /** * 將選擇內容轉換成區域 * * @param start 開始位置 * @param count 數量 */ public void setSelected(int start, int count) { if (start > titles.length - 1) { throw new IllegalArgumentException("wrong start"); } int right = (start + count) * space; if (right > maxWidth) { //int cut = Math.round((right - maxWidth) * 1f / space); //start -= cut;//總體向左移動 right = maxWidth; } int left = start * space; if (selectedRectF == null) { selectedRectF = new RectF(left, areaTop, right, height); if (selectChangeListener != null) { selectChangeListener.onSelected(); } } else { selectedRectF.set(left, areaTop, right, height); } notifySelectChangeListener(start, count); postInvalidate(); } /** * 將選中區域轉換成選擇內容 * * @return [start, count] */ public int[] getSelected() { if (selectedRectF == null) { return null; } int[] area = new int[2]; float w = selectedRectF.right - selectedRectF.left; area[0] = Math.round(selectedRectF.left / space); area[1] = Math.round(w / space); return area; }
performClick會在你重寫onTouchEvent時as提示你須要重寫的方法,由於你可能沒有考慮到若是給這個view設置OnClickListener的狀況。若是你沒有在onTouchEvent中調用performClick,那麼setOnClickListener方法就失效了。
你可能注意到這一次比較複雜,而且還有一個linking字段,表示是否正在聯動,我解釋一下這個聯動的概念:經過gif其實你可能注意到,當我移動或者擴展選中區域的時候,若是移動到了屏幕的邊界,後面的刻度尺就會跟着移動,實際上這個時候選中區域在屏幕中的位置沒有改變,只是刻度尺移動了。一開始我也是經過dx來改變offset,可是存在一個問題,移動到屏幕邊緣以後,手指能夠移動的區域已經很小了,不會產生足夠的dx(手指不移動的話,不會有新的touch事件產生)。最好的體驗是我把手機移動到屏幕邊緣,刻度尺就會本身按照必定的速率移動直到最大offset或者最小offset。因而我使用了Handler,當知足條件後發送消息,表示開始進行聯動,會按照固定速度產生一個dx改變offset。固然,在離開屏幕邊緣的時候還須要及時取消handler的任務。
至此,功能基本已經實現了,運行一下看看效果吧~
後面須要作什麼那?如今這個view只能本身玩,我須要它與其餘view有交互,好比選中什麼區域,狀態的改變生麼的。
聲明兩個接口,並在適當時候回調它們的方法,這樣外部就能感知view的狀態變化。
public interface OverlappingStateChangeListener { void onOverlappingStateChanged(boolean isOverlapping); } public interface SelectChangeListener { void onSelected(); void onSelectChanged(int start, int count); }
後面的話就是根據業務添加一些api了,例如添加不可選區域,改變刻度範圍什麼,一切都看需求了。
最後附上代碼:
https://github.com/huburt-Hu/...