本文已授權微信公衆號:鴻洋(hongyangAndroid)原創首發。
這是模仿樂視遙控App中添加萬能遙控器的交互效果,實現效果如下:
感覺是不是有點小炫酷與小複雜,其實整個實現大致分爲三部分:
繪製手機
實現拖動
修正位置
1.繪製手機
這部分其實都是自定義View的基礎。仔細觀察手機的組成,無非就是圓角矩形、圓、線、矩形組成。
首先在onMeasure
中計算手機的寬高。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measure(widthMeasureSpec), measure(heightMeasureSpec));
// 手機高度爲View高度減去上下間隔24dp
int phoneHeight = getMeasuredHeight() - dp2px(24);
// 手機內容區域高 :手機高度 - 手機頭尾(48dp)- 手機屏幕間距(5dp) * 2)
mPhoneContentHeight = phoneHeight - dp2px(58);
// 手機內容區域寬 :手機內容區域高/ 7 * 4(手機內容區域爲4:7)
mPhoneContentWidth = mPhoneContentHeight / HEIGHT_COUNT * WIDTH_COUNT;
// 手機寬度爲手機內容區域寬 + 手機屏幕間距 * 2
mPhoneWidth = mPhoneContentWidth + dp2px(10);
// 繪製起始點
startX = (getMeasuredWidth() - mPhoneWidth) / 2;
}
下來就是在onDraw
中去繪製手機了。
1.繪製手機外殼
mPhonePaint.setColor(Color.parseColor(BORDER_COLOR));
mPhonePaint.setStyle(Paint.Style.STROKE);
mPhonePaint.setStrokeWidth(2);
int i = dp2px(12);
mRectF.left = startX;
mRectF.right = getMeasuredWidth() - startX;
mRectF.top = i;
mRectF.bottom = getMeasuredHeight() - i;
canvas.drawRoundRect(mRectF, i, i, mPhonePaint);
// 繪製手機上下兩條線分隔線
canvas.drawLine(startX, i * 3, getMeasuredWidth() - startX, i * 3, mPhonePaint);
canvas.drawLine(startX, getMeasuredHeight() - i * 3, getMeasuredWidth() - startX, getMeasuredHeight() - i * 3, mPhonePaint);
2.繪製手機上的按鈕及模塊
// 繪製手機上方聽筒、攝像頭
mRectF.left = getMeasuredWidth() / 2 - dp2px(25);
mRectF.right = getMeasuredWidth() / 2 + dp2px(25);
mRectF.top = dp2px(22);
mRectF.bottom = dp2px(26);
canvas.drawRoundRect(mRectF, dp2px(2), dp2px(2), mPhonePaint);
canvas.drawCircle(getMeasuredWidth() / 2 - dp2px(40), i * 2, i / 3, mPhonePaint);
canvas.drawCircle(getMeasuredWidth() / 2 + dp2px(40), i * 2, i / 3, mPhonePaint);
// 繪製手機下方按鍵
canvas.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() - i * 2, i / 2, mPhonePaint);
canvas.drawRect(startX + mPhoneWidth / 5, getMeasuredHeight() - dp2px(29), startX + mPhoneWidth / 5 + dp2px(10), getMeasuredHeight() - dp2px(19), mPhonePaint);
mBackPath.moveTo(getMeasuredWidth() - startX - mPhoneWidth / 5, getMeasuredHeight() - dp2px(30));
mBackPath.lineTo(getMeasuredWidth() - startX - mPhoneWidth / 5 - dp2px(10), getMeasuredHeight() - dp2px(24));
mBackPath.lineTo(getMeasuredWidth() - startX - mPhoneWidth / 5, getMeasuredHeight() - dp2px(18));
mBackPath.close();
canvas.drawPath(mBackPath, mPhonePaint);
3.繪製網格(4 * 7的田字格)田字格外框爲實線,裏側爲虛線
DashPathEffect mDashPathEffect = new DashPathEffect(new float[] {10, 10}, 0);
// 手機屏幕間距5pd
int j = dp2px(5);
// 格子的寬高
int size = mPhoneContentHeight / HEIGHT_COUNT;
// 橫線
for (int z = 0; z <= HEIGHT_COUNT; z++){
mPhonePaint.setPathEffect(null);
mPhonePaint.setColor(Color.parseColor(SOLID_COLOR));
mPhonePaint.setStrokeWidth(1);
// 實線
canvas.drawLine(startX + j, dp2px(41) + z * size,
getMeasuredWidth() - startX - j, dp2px(41) + z * size, mPhonePaint);
// 虛線
if (z != HEIGHT_COUNT){
mPhonePaint.setPathEffect(mDashPathEffect);
mPhonePaint.setColor(Color.parseColor(DASHED_COLOR));
canvas.drawLine(startX + j, dp2px(41) + z * size + size / 2,
getMeasuredWidth() - startX - j, dp2px(41) + z * size + size / 2, mPhonePaint);
}
}
// 豎線
for (int z = 0; z <= WIDTH_COUNT; z++){
mPhonePaint.setPathEffect(null);
mPhonePaint.setColor(Color.parseColor(SOLID_COLOR));
mPhonePaint.setStrokeWidth(1);
// 實線
canvas.drawLine(startX + j + z * size, dp2px(41),
startX + j + z * size, getMeasuredHeight() - dp2px(41), mPhonePaint);
// 虛線
if (z != WIDTH_COUNT){
mPhonePaint.setPathEffect(mDashPathEffect);
mPhonePaint.setColor(Color.parseColor(DASHED_COLOR));
canvas.drawLine(startX + j + z * size + size / 2, dp2px(41),
startX + j + z * size + size / 2, getMeasuredHeight() - dp2px(41), mPhonePaint);
}
}
上面的代碼看似很多,其實主要是一些位置的計算,並沒有什麼。。。
2.實現拖動
通過解壓原本的安裝包,我拿到了按鈕的素材圖片,但是圖片本身是沒有圓形邊框的,數字按鈕也沒有對應的圖片。包括按鈕的按下時會有一個背景變化。所以先來實現一下這個拖拽的按鈕。
public class DraggableButton extends AppCompatImageView{
private Paint mPaint;
private Rect mRect = new Rect();
// 文本按鈕的文字
private String text;
public DraggableButton(Context context) {
this(context, null);
}
public DraggableButton(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DraggableButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStrokeWidth(1);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = getMeasuredWidth(), height = getMeasuredHeight();
int size = Math.min(width, height);
setMeasuredDimension(size, size);
}
@Override
protected void onDraw(Canvas canvas) {
int radius = getWidth() / 2;
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.parseColor("#cdcdcd"));
// 繪製圓形邊框
canvas.drawCircle(radius, radius , radius - 2, mPaint);
// 按下時有背景變化
if (isPressed()){
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.parseColor("#20000000"));
canvas.drawCircle(radius, radius , radius - 4, mPaint);
}
// 有文字時的文字繪製
if (!TextUtils.isEmpty(text)){
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.WHITE);
mPaint.setTextSize(radius / 2);
mPaint.getTextBounds(text, 0, text.length(), mRect);
int textHeight = mRect.bottom - mRect.top;
int textWidth = mRect.right - mRect.left;
canvas.drawText(text, radius - textWidth / 2, radius + textHeight / 2, mPaint);
}
super.onDraw(canvas);
}
@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
// View的狀態有發生改變的觸發
invalidate();
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
}
1.開始拖動
其實拖動一個View很簡單,調用View的startDrag
方法。
v.startDrag(dragData, // 要拖動的數據
myShadow, // 拖動的影子
null, // 本地數據(不需要用到)
0); // 標誌位(目前未啓用,設爲0)
首先是拖動的數據,我定一個對象用來存放數據,裏面包括按鈕的類型、圖片、文字信息。
public class DraggableInfo implements Serializable{
/**
* 0 : 1 * 1 圖片
* 1 : 1 * 1 文字
* 2 : 3 * 3 圖片
* 3 : 1 * 2 圖片
*/
private int type;
private String text;
private int pic;
private int id;
public DraggableInfo(String text, int pic, int id, int type) {
this.text = text;
this.pic = pic;
this.id = id;
this.type = type;
}
}
Intent intent = new Intent();
intent.putExtra("data", draggableInfo);
// 利用Intent來傳遞數據
ClipData dragData = ClipData.newIntent("value", intent);
接下來是拖動在手上的影子,我們可以直接使用DragShadowBuilder
創建。默認的,它會生成一個和需要拖拽的View一模一樣的影子。
View.DragShadowBuilder myShadow = new View.DragShadowBuilder(view);
當然,如果你想自定義影子的樣式。可以通過繼承DragShadowBuilder
來實現:
public class MyDragShadowBuilder extends View.DragShadowBuilder {
/**
* 拖動的陰影圖像
*/
private static Drawable shadow;
private int width, height;
public MyDragShadowBuilder(View v) {
// 保存傳給myDragShadowBuilder的View參數
super(v);
// 將view轉爲Drawable
v.setDrawingCacheEnabled(true);
Bitmap bitmap = Bitmap.createBitmap(v.getDrawingCache(true));
shadow = new BitmapDrawable(null, bitmap);
v.destroyDrawingCache();
v.setDrawingCacheEnabled(false);
}
/**
* 用於設置拖動陰影的大小和觸摸點位置
* @param size
* @param touch
*/
@Override
public void onProvideShadowMetrics(Point size, Point touch){
width = getView().getWidth();
height = getView().getHeight();
// 設置陰影大小
shadow.setBounds(0, 0, width, height);
// 設置長寬值,通過size參數返回給系統。
size.set(width, height);
// 把觸摸點的位置設爲拖動陰影的中心
touch.set(width / 2, height / 2);
}
/**
* 繪製拖動陰影
* @param canvas
*/
@Override
public void onDrawShadow(Canvas canvas) {
// 在系統傳入的Canvas上繪製Drawable
shadow.draw(canvas);
}
}
最終拖拽的方法如下:
public static void startDrag(View view){
DraggableInfo tag = (DraggableInfo) view.getTag();
if (tag == null){
tag = new DraggableInfo("Text", 0, 0, 1);
}
Intent intent = new Intent();
intent.putExtra("data", tag);
ClipData dragData = ClipData.newIntent("value", intent);
View.DragShadowBuilder myShadow = new View.DragShadowBuilder(view);
// 震動反饋,不需要震動權限
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS, HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
view.startDragAndDrop(dragData, myShadow, null, 0);
}else{
view.startDrag(dragData, myShadow, null, 0);
}
}
2.響應拖動
一邊有拖動的View,一邊就要有響應拖動,用來接收的地方。
我們創建一個FrameLayout用來響應拖動事件,接收拖動過來的View信息。
// 拖拽有效區域
frameLayout = new FrameLayout(context);
frameLayout.setBackgroundColor(Color.parseColor(CONTENT_COLOR));
// 註冊監聽
frameLayout.setOnDragListener(this);
addView(frameLayout);
在onLayout
中我們確定它的大小
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
frameLayout.layout(startX, dp2px(36), getMeasuredWidth() - startX, getMeasuredHeight() - dp2px(36));
}
監聽器如下:首先我們要在響應到開始拖動時判斷是不是我們需要接收的數據類型。我們可不是隨便的人,不能來者不拒。
@Override
public boolean onDrag(View v, DragEvent event) {
final int action = event.getAction();
switch(action) {
case DragEvent.ACTION_DRAG_STARTED:
// 判斷是否是需要接收的數據
if (event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_INTENT)) {
Log.e(TAG, "開始拖動");
}else {
return false;
}
break;
case DragEvent.ACTION_DRAG_ENTERED:
Log.e(TAG, "進入區域");
break;
case DragEvent.ACTION_DRAG_EXITED:
Log.e(TAG, "移出區域");
break;
case DragEvent.ACTION_DRAG_ENDED:
Log.e(TAG, "停止拖動");
break;
case DragEvent.ACTION_DRAG_LOCATION:
Log.e(TAG, "停留在區域中");
break;
case DragEvent.ACTION_DROP:
Log.e(TAG, "釋放拖動View");
break;
default:
return false;
}
return true;
}
3.修正位置
通過上一步,我們可以在DragEvent.ACTION_DROP
中拿到我們拖拽的信息,從而創建一個View添加至frameLayout中,但是我們不能隨便的擺放。
獲取信息:
DraggableInfo data = (DraggableInfo) event.getClipData().getItemAt(0).getIntent().getSerializableExtra("data");
1.確定大小
因爲我們有三種規格大小的按鈕,1×1 ,1×2 ,3×3。所以我們需要根據按鈕類型來計算對應的大小。
/**
* 計算控件位置
*/
private void compute(int type, Rect rect, DragEvent event){
int size = mPhoneContentWidth / WIDTH_COUNT - dp2px(10);
int x = (int) event.getX();
int y = (int) event.getY();
if (type == ONE_BY_ONE_PIC || type == ONE_BY_ONE_TEXT){
// 1 * 1 方格
rect.left = x - size / 2;
rect.top = y - size / 2;
rect.right = x + size / 2;
rect.bottom = y + size / 2;
}else if (type == THREE_BY_THREE_PIC){
// 3 * 3 方格
rect.left = x - size * 3 / 2;
rect.top = y - size * 3 / 2;
rect.right = x + size * 3 / 2;
rect.bottom = y + size * 3 / 2;
}else if (type == ONE_BY_TWO_PIC){
// 1 * 2 方格
rect.left = x - size / 2;
rect.top = y - size;
rect.right = x + size / 2;
rect.bottom = y + size;
}
}
2.修正位置
我們拖動中的位置和釋放時的位置都不一定準確的放在田字格中,所以我們要修正偏移量。
/**
* 調整控件位置
*/
private void adjust(int type, Rect rect, DragEvent event){
// 最小單元格寬高
int size = mPhoneContentWidth / WIDTH_COUNT / 2;
// 手機屏幕間距
int padding = dp2px(5);
// 1 * 1方格寬高
int width = size * 2 - dp2px(10);
int offsetX = (rect.left - padding) % size;
if (offsetX < size / 2){
rect.left = rect.left + padding - offsetX;
}else {
rect.left = rect.left + padding - offsetX + size;
}
int offsetY = (rect.top - padding) % size;
if (offsetY < size / 2){
rect.top = rect.top + padding - offsetY;
}else {
rect.top = rect.top + padding - offsetY + size;
}
if (type == ONE_BY_ONE_PIC || type == ONE_BY_ONE_TEXT){
rect.right = rect.left + width;
rect.bottom = rect.top + width;
}else if (type == ONE_BY_TWO_PIC){
rect.top = rect.top + padding;
rect.right = rect.left + width;
rect.bottom = rect.top + width * 2;
}else if (type == THREE_BY_THREE_PIC){
rect.top = rect.top + padding * 2;
rect.left = rect.left + padding * 2;
rect.right = rect.left + width * 3;
rect.bottom = rect.top + width * 3;
}
//超出部分修正(超出部分)
if (rect.right > frameLayout.getWidth() || rect.bottom > frameLayout.getHeight()){
int currentX = (int) event.getX();
int currentY = (int) event.getY();
int centerX = frameLayout.getWidth() / 2;
int centerY = frameLayout.getHeight() / 2;
if (currentX <= centerX && currentY <= centerY){
//左上角區域
}else if (currentX >= centerX && currentY <= centerY){
//右上角區域
rect.left = rect.left - size;
rect.right = rect.right - size;
}else if (currentX <= centerX && currentY >= centerY){
//左下角區域
rect.top = rect.top - size;
rect.bottom = rect.bottom - size;
}else if (currentX >= centerX && currentY >= centerY){
//右下角區域
if (rect.right > frameLayout.getWidth()){
rect.left = rect.left - size;
rect.right = rect.right - size;
}
if (rect.bottom > frameLayout.getHeight()){
rect.top = rect.top - size;
rect.bottom = rect.bottom - size;
}
}
}
}
3.避免重疊
當拖動在frameLayout中的View逐漸多了時,避免不了的會空間不足。如果不加以控制,View會出現重疊擺放的情況。拖動時我們會循環所有的Rect(除去自身)來進行判斷。有重疊的,我們不予添加。
/**
* 判斷是否重疊
*/
private boolean isOverlap(Rect rect){
for (Rect mRect : mRectList){
if (!isEqual(mRect)){
if (isRectOverlap(mRect, rect)){
return true;
}
}
}
return false;
}
/**
* 判斷兩Rect是否重疊
*/
private boolean isRectOverlap(Rect oldRect, Rect newRect) {
return (oldRect.right > newRect.left &&
newRect.right > oldRect.left &&
oldRect.bottom > newRect.top &&
newRect.bottom > oldRect.top);
}
/**
* 判斷與拖拽的Rect是否相等
*/
private boolean isEqual(Rect rect) {
if (dragRect == null){
return false;
}
return (rect.left == dragRect.left &&
rect.right == dragRect.right &&
rect.top == dragRect.top &&
rect.bottom == dragRect.bottom);
}
對於兩Rect是否重疊,這裏可以採用摩根定理來推導出。
最後,符合所有的條件的View纔可以添加進frameLayout。
/**
* 是否在有效區域
*/
private boolean isEffectiveArea(Rect rect){
return rect.left >= 0 && rect.top >= 0 && rect.right >= 0 && rect.bottom >= 0 &&
rect.right <= frameLayout.getWidth() && rect.bottom <= frameLayout.getHeight();
}
// 計算
compute(data.getType(), rect, event);
// 調整
adjust(data.getType(), rect, event);
// 不重疊且在有效區域
if (isEffectiveArea(rect) && !isOverlap(rect)){
//添加
mRectList.add(rect);
frameLayout.addView(imageView);
}
4.其他
對效果觀察仔細的會發現,在拖動中會有一個類似“引導投影”在拖拽區域,這個是怎麼實現的?其實很簡單,利用監聽的DragEvent.ACTION_DRAG_LOCATION
狀態,實時的計算,修正位置,在onDraw中去繪製出這個“引導投影”。
case DragEvent.ACTION_DRAG_LOCATION:
compute(info.getType(), mRect, event);
adjust(info.getType(), mRect, event);
if (isEffectiveArea(mRect) && !isOverlap(mRect)){
shadowRect = mRect;
}else {
shadowRect = null;
}
try {
Thread.sleep(33);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
invalidate();
break;
onDraw中
if (shadowRect != null){
int type = info.getType();
mPhonePaint.setStyle(Paint.Style.FILL);
mPhonePaint.setColor(Color.WHITE);
shadowRect.left = shadowRect.left + startX;
shadowRect.right = shadowRect.right + startX;
shadowRect.top = shadowRect.top + dp2px(36);
shadowRect.bottom = shadowRect.bottom + dp2px(36);
if (type == ONE_BY_ONE_TEXT){
//文字類型繪製
int width = shadowRect.right - shadowRect.left;
String text = info.getText();
mPhonePaint.setTextSize(width / 4);
mPhonePaint.getTextBounds(text, 0, text.length(), mTextRect);
int textHeight = mTextRect.bottom - mTextRect.top;
int textWidth = mTextRect.right - mTextRect.left;
canvas.drawText(text, shadowRect.left + width / 2 - textWidth / 2, shadowRect.top + width / 2 + textHeight / 2, mPhonePaint);
}else {
//圖片類型繪製
if (type == ONE_BY_ONE_PIC){
// 1 * 1 方格
int padding = dp2px(12);
shadowRect.left = shadowRect.left + padding;
shadowRect.right = shadowRect.right - padding;
shadowRect.top = shadowRect.top + padding;
shadowRect.bottom = shadowRect.bottom - padding;
}else if (type == THREE_BY_THREE_PIC){
// 3 * 3 方格
int padding = dp2px(10);
shadowRect.left = shadowRect.left + padding;
shadowRect.right = shadowRect.right - padding;
shadowRect.top = shadowRect.top + padding;
shadowRect.bottom = shadowRect.bottom -padding;
}else if (type == ONE_BY_TWO_PIC){
int padding = dp2px(4);
shadowRect.left = shadowRect.left + padding;
shadowRect.right = shadowRect.right - padding;
}
canvas.drawBitmap(shadowBitmap, null, shadowRect, mPhonePaint);
}
}
以上就是大體的實現流程,可以看到拖拽本身並不複雜,只是需要計算判斷的部分比較繁瑣。至於其他細節部分,可以去下載源碼瞭解體驗。歡迎交流~