本文已授权微信公众号:鸿洋(hongyangAndroid)原创首发。
这是模仿乐视遥控App中添加万能遥控器的交互效果,实现效果如下:
感觉是不是有点小炫酷与小复杂,其实整个实现大致分为三部分:
绘制手机
实现拖动
修正位置
这部分其实都是自定义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);
}
}
上面的代码看似很多,其实主要是一些位置的计算,并没有什么。。。
通过解压原本的安装包,我拿到了按钮的素材图片,但是图片本身是没有圆形边框的,数字按钮也没有对应的图片。包括按钮的按下时会有一个背景变化。所以先来实现一下这个拖拽的按钮。
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;
}
}
其实拖动一个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);
}
}
一边有拖动的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;
}
通过上一步,我们可以在DragEvent.ACTION_DROP
中拿到我们拖拽的信息,从而创建一个View添加至frameLayout中,但是我们不能随便的摆放。
获取信息:
DraggableInfo data = (DraggableInfo) event.getClipData().getItemAt(0).getIntent().getSerializableExtra("data");
因为我们有三种规格大小的按钮,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;
}
}
我们拖动中的位置和释放时的位置都不一定准确的放在田字格中,所以我们要修正偏移量。
/**
* 调整控件位置
*/
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;
}
}
}
}
当拖动在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);
}
对效果观察仔细的会发现,在拖动中会有一个类似“引导投影”在拖拽区域,这个是怎么实现的?其实很简单,利用监听的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);
}
}
以上就是大体的实现流程,可以看到拖拽本身并不复杂,只是需要计算判断的部分比较繁琐。至于其他细节部分,可以去下载源码了解体验。欢迎交流~
源码在此,多多点赞点星哦~~
Android开发者指南 - 拖放
判断两个矩形是否重叠