最近擼了一個自定義view,還是比較複雜的,感覺有必要分享下實現的過程。
效果
先來看下效果吧:
我們來分析這個view需要實現哪些效果。
- 首先它有一個刻度尺代表了時間段(也可以是別的什麼),並且可以看到完整的刻度尺是比螢幕寬度大的,因此肯定需要可以左右滑動。
- 其次,可以有不可選的區域(gif中灰色塊)和選中的區域(gif中藍色塊),點選刻度的空白位置出現或者移動選中區域到點選位置。
- 點選並拖動選中的區域可以移動,當移動到螢幕兩邊的時候,下層的刻度也能跟著移動。
- 還可以點選並拖動選中區域右邊的白色小圓改變選中區域的大小,同樣到達螢幕邊界時下層刻度跟著移動。
- 當選中區域與不可選區域重疊時,選中區域變色。
- 選中區域最小為1個刻度,當移動後手指抬起時,選中區域貼合刻度。
- 最後還需要監聽一些狀態的變化,如是否重疊,選中區域改變的位置。
實現
刻度尺
別害怕有這麼多的功能,我們一個一個來實現。首先是刻度尺,這個簡單。由於完整的刻度尺是比螢幕寬度大的,因此我們先來了解幾個概念:
這裡手機螢幕的寬度是width,刻度尺的寬度的時maxWidth,我們其實只需要繪製手機螢幕可見的部分就可以了,這裡的offset表示手機螢幕的左邊與刻度尺左邊的偏移量。
瞭解了這個概念,我們就來開始寫吧,定義一個View,處理下構造都指向3個引數的那個,然後統一做初始化:
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事件產生移動才有效果。
實現滑動刻度尺
我們重寫onTouchEvent來實現滑動效果:
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簡介
android 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了,例如新增不可選區域,改變刻度範圍什麼,一切都看需求了。
原始碼
最後附上程式碼:SelectView