源码地址
基本思路
我们先考虑简单的情况,两个控件之间的图片拖拽,首先我们需要准备ImageViewA和ImageViewB两个ImageView,然后在里面设置图片。接着我们需要考虑拖拽事件的触发条件,这里假设为手指从ImageView的某个边缘滑出一段距离即触发拖拽事件
假设我们此时在ImageViewA的边缘向下滑动了一段距离,在触发拖拽事件的时候我们需要将ImageViewA的图片隐藏,然后将A的图片传递给一个半透明的ImageViewC并将C显示出来,由于这个ImageViewC同一时间只会有一个,所以我们可以在自定义的layout中创建一个ImageView类型的成员变量进行复用,在触发拖拽事件的时候ImageViewC会跟随手指滑动,在手指抬起来的时候判断ImageViewC的中心是否在另一个ImageViewB上,若在则交换A和B的图片
整体思路还是比较清晰的,主要是要创建一个自定义的layout对子View进行管理并自定义事件分发规则
代码实现
自定义Layout
此处选择继承自FrameLayout,因为可以通过margin属性自由控制View所在的位置并且不会影响到其他的View,后期可能会向Layout中添加一些EditText、TextView或者各种自定义View,而这些View可能是要叠在ImageView上面的,因此选择继承自FrameLayout无疑是最合适的,而且它已经帮我们处理了很多细节,我们可以专注于功能的实现
获取子View所在的区域信息
因为对事件进行分发需要子View的位置信息,所以我们需要一个成员变量来保存,此处选择RectF来保存一个View所在的区域,然后将所有View的位置信息存放到一个HashMap中,这样就可以处理两个以上的控件间图片拖拽了
public class ConfigurableFrameLayout extends FrameLayout implements OnTriggerDragListener {
...
// 保存所有子View的区域
private HashMap<View, RectF> mChildViewRects;
...
@Override
protected void onLayout(boolean changed,int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right,bottom);
/* 因为layout后每个子View的位置才确定,所以在此处初始化子View的位置信息*/
initChildViewRect();
}
/**
* 获取各子View所在区域
*/
private void initChildViewRect() {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
LayoutParams lp = (LayoutParams)child.getLayoutParams();
// 避免多次创建对象
RectF rect = mChildViewRects.get(child;
if (rect == null) {
rect = new RectF();
}
// 设置子View所在的矩形区域
rect.set(lp.leftMargin,lp.topMargin,
lp.leftMargin + child.getWidt(),
lp.topMargin +child.getHeight());
mChildViewRects.put(child, rect);
}
}
}
对事件进行分发
子View有可能比较小,如果要两根手指都在子View里面才能对图片进行操作会不太方便,而我们要实现只要一根手指在子View内,另一根手指无论在哪都可以对子View的图片进行操作,并且同一时间只能操作一个子View,此时就需要自定义事件分发规则
自定义事件分发规则可以选择重写dispatchTouchEvent()方法,但是需要考虑的细节比较多,所以我们选择拦截所有事件,然后在onTouchEvent()方法中对事件进行分发
//重写自定义Layout的onInterceptTouchEvent()和onTouchEvent()
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
super.onInterceptTouchEvent(ev);
// 拦截所有事件
return true;
}
private View mCurrentChildView; // 当前正在处理触摸事件的子View
@Override
public boolean onTouchEvent(MotionEventevent) {
// 事件是否被子View消费
boolean handled = false;
// 当前事件流若已被分发给某个子View处理,则将后续事件都分发给该子View
if (mCurrentChildView != null) {
handled = dispatchTouchEventToChild(event, mCurrentChildView);
}
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// 获取位于触点的子View
mCurrentChildView = viewInXY(event.getX(), event.getY());
// 判断子View是否可以触发拖拽事件,可以则为其设置触发时的监听事件
if (mCurrentChildView instanceof TriggerDraggable) {
((TriggerDraggable) mCurrentChildView).setOnTriggerDragListener(this);
}
// 只在ACTION_DOWN时对事件进行分发,事件只能交由一个子View处理
if (mCurrentChildView != null) {
handled =dispatchTouchEventToChild(event, mCurrentChildView);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
// 设置当前处理事件流的子View为null
mCurrentChildView = null;
break;
}
if (!handled) handled = super.onTouchEvent(event);
return handled;
}
/**
* 获取位于指定坐标的子View
* @param x x坐标
* @param y y坐标
* @return 包含该坐标的子View,若找不到则返回null
*/
@Nullable
private View viewInXY(float x, float y) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
RectF rectF = mChildViewRects.get(child);
if (rectF != null && rectF.contains(x, y)) {
return child;
}
}
return null;
}
/**
* 将触摸事件坐标变换后传递给子View
* @return true如果事件被子View消费,否则返回false
*/
private boolean dispatchTouchEventToChild(MotionEvent event, View child) {
final float offsetX = getScrollX() - child.getLeft();
final float offsetY = getScrollY() - child.getTop();
event.offsetLocation(offsetX, offsetY);
boolean handled = child.dispatchTouchEvent(event);
event.offsetLocation(-offsetX, -offsetY);
return handled;
}
判断是否触发拖拽事件
现在子View已经可以接收到触摸事件了,然后我们需要判断是否触发了拖拽事件,我们要实现的效果是可以指定View的上下左右的某个或某几个边界,当超过指定的边界指定的距离后即触发拖拽事件,触发后隐藏触发条件的ImageViewA(此处假设在ImageViewA上触发)并将半透明的ImageViewC显示出来
这边需要考虑将判断逻辑写在ImageView中还是Layout中,当然,写在哪里都可以实现功能,但是设计上可能不够合理,由于拖拽事件是在操作子View图片的过程中触发的,所以个人认为在子View中对是否触发进行判断比较合理。
此处使用了观察者模式,将判断逻辑写在子View中,然后在触发拖拽事件的时候通过监听器通知Layout做相应的操作
为子View设置监听器的代码可以参考上文对事件进行分发中的OnTouchEvent()中ACTION_DOWN下面的代码
//用于解耦ConfigurableFrameLayout与DraggableImageView的接口
/**
* 实现该接口表示可触发拖拽事件
*/
public interface TriggerDraggable {
// 判断是否触发拖拽事件
boolean triggerDrag(MotionEvent event);
// 设置拖拽事件监听器
void setOnTriggerDragListener(OnTriggerDragListener listener);
}
/**
* 拖拽事件监听器
*/
public interface OnTriggerDragListener {
// 在拖拽的时候调用
void onDrag(MotionEvent event);
// 拖拽事件结束时调用
void onDragFinish(MotionEvent event);
}
在子View中判断是否触发拖拽事件,这边我没有直接使用ImageView,而是继承了我之前写的一个自定义ImageView,主要是比普通的ImageView多了手势操作图片旋转、平移、缩放三个功能。(ImageViewC只需要展示图片,所以使用的还是普通的ImageView)
public class DraggableImageView extends TransformativeImageView implements TriggerDraggable {
...
/**
* 可触发拖拽事件的边界,若数组某个index的变量为true,则表示该index对应的边界可以触发拖拽事件;
* 默认所有边界均不可触发拖拽事件
* index: {0, 1, 2, 3} -> boundary: {left, top, right, bottom}
*
* 例:mBoundary = {true, false, false, true} 表示左边界与下边界可触发拖拽事件
*/
private boolean[] mBoundary = new boolean[4];
private float mTriggerDistance = DEFAULT_TRIGGER_DISTANCE; // 触发拖拉事件的距离
/**
* 判断是否触发拖拽事件
* @param event 触摸事件
* @return 符合触发条件则返回true,否则返回false
*/
@Override
public boolean triggerDrag(MotionEvent event) {
boolean canDrag = false;
// 当前触点坐标
final float x = event.getX();
final float y = event.getY();
// 判断某个边界是否可触发拖拽事件并且达到了触发条件
if (mBoundary[0] && -x > mTriggerDistance
|| mBoundary[1] && -y > mTriggerDistance
|| mBoundary[2] && x - getWidth() > mTriggerDistance
|| mBoundary[3] && y - getHeight() > mTriggerDistance) {
canDrag = true;
}
return canDrag;
}
private boolean isPointerCountChanged = false; // 本次触摸事件流中触点数量是否减少
private boolean mCanDrag = false; // 是否可以将图片拖拽出控件
private OnTriggerDragListener mOnTriggerDragListener; // 拖拽事件监听器
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
/* 只有处于不可拖拽状态时才判断是否触发拖拽事件,
* 且本次触摸事件流触点数量未减少的情况,才判断是否触发拖拽事件
*/
if (!mCanDrag && !isPointerCountChanged && triggerDrag(event)) {
mCanDrag = true;
}
// 调用拖拽监听方法
if (mCanDrag && mOnTriggerDragListener != null) {
mOnTriggerDragListener.onDrag(event);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
// 拖拽事件结束
if (mCanDrag && mOnTriggerDragListener != null) {
mOnTriggerDragListener.onDragFinish(event);
}
// 清除是否可拖拽的标志位
mCanDrag = false;
// ACTION_UP意味着本次事件流结束,所以将记录触点数量是否减少的标志位清除
isPointerCountChanged = false;
break;
case MotionEvent.ACTION_POINTER_UP:
// 触点数量减少
isPointerCountChanged = true;
break;
} return true;
}
...
}
触发拖拽事件后执行的操作
触发拖拽事件后我们需要一个半透明的ImageViewC(代码中为mInterpolationImageView)来存放触发拖拽的子View中的图片
//在自定义Layout中创建并初始化ImageViewC
private void initInterpolationView() {
mInterpolationImageView = new ImageView(getContext());
// TODO dp转px, 大小,透明度为可配置
LayoutParams lp = new LayoutParams(300, 300);
mInterpolationImageView.setLayoutParams(lp);
mInterpolationImageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
mInterpolationImageView.setAlpha(0.5f);
mInterpolationImageView.setVisibility(GONE);
addView(mInterpolationImageView);
}
触发拖拽后,触发事件的控件的图片会隐藏,并且半透明的ImageViewC会跟随手指移动
public class ConfigurableFrameLayout extends FrameLayout implements OnTriggerDragListener {
...
// 判断拖拽的ImageView是否已经设置了当前处理事件的子View的图片
private boolean mInterpolationHasImg = false;
/**
* 拖拽ImageView,使之跟随触点位置移动
* @param event 当前触摸事件
*/
private void dragInterpolationImageView(MotionEvent event) {
ImageView curImgView = null;
if (mCurrentChildView instanceof ImageView) {
curImgView = (ImageView) mCurrentChildView;
}
if (curImgView != null) {
// 为中间控件设置图片
if (!mInterpolationHasImg
&& curImgView.getDrawable() instanceof BitmapDrawable) {
Drawable drawable = curImgView.getDrawable();
Bitmap bitmap = null;
if (drawable instanceof BitmapDrawable) {
bitmap = ((BitmapDrawable) drawable).getBitmap();
}
if (bitmap != null) {
mInterpolationImageView.setImageBitmap(bitmap);
}
mInterpolationHasImg = true;
}
// 隐藏控件的图片
curImgView.setImageAlpha(0);
// 设置中间控件为可见
mInterpolationImageView.setVisibility(VISIBLE);
// 跟随手指移动中间控件
LayoutParams lp = (LayoutParams) mInterpolationImageView.getLayoutParams();
lp.setMargins((int)(event.getX() - mInterpolationImageView.getWidth() / 2),
(int)(event.getY() - mInterpolationImageView.getHeight() / 2),
0, 0);
// 将中间控件移到最上层
mInterpolationImageView.bringToFront();
}
}
...
@Override
public void onDrag(MotionEvent event) {
// 由于传递过来的事件是相对于子View的坐标,所以需要变换为相对Layout的坐标
final float offsetX = mCurrentChildView.getLeft() - getScrollX();
final float offsetY = mCurrentChildView.getTop() - getScrollY();
event.offsetLocation(offsetX, offsetY);
dragInterpolationImageView(event);
event.offsetLocation(-offsetX, -offsetY);
}
...
}
拖拽结束时判断是否交换图片
当所有手指抬起,即拖拽事件结束后,判断ImageViewC 的中心是否在另一个子View上,若在则交换两者图片
public class ConfigurableFrameLayout extends FrameLayout implements OnTriggerDragListener {
...
/**
* 判断是否需要交换图片,并清理一些标志位,设置各子控件的最终状态
* @param event 当前触摸事件
*/
private void dragFinish(MotionEvent event) {
ImageView curImgView = null;
if (mCurrentChildView instanceof ImageView) {
curImgView = (ImageView) mCurrentChildView;
}
if (curImgView != null) {
// 判断当前触点是否在其他子View内,若是则交换两者图片
View v = viewInXY(event.getX(), event.getY());
if (v instanceof ImageView) {
exchangeImg(curImgView, (ImageView) v);
}
// 将之前拖拽过程隐藏当前处理事件的子View的图片显示出来
curImgView.setImageAlpha(255);
// 隐藏中间控件
mInterpolationImageView.setVisibility(GONE);
// 设置中间控件不含当前处理事件的子View的图片
mInterpolationHasImg = false;
}
}
/**
* 交换两个ImageView的图片
* @param fromImgView 源控件
* @param toImgView 目标控件
*/
private void exchangeImg(ImageView fromImgView, ImageView toImgView) {
// 若源控件不包含图片则不交换
if (toImgView == null || fromImgView == null || fromImgView.getDrawable() == null) {
return;
}
Bitmap fromBmp = null;
Bitmap toBmp = null;
// 获取源控件图片
if (fromImgView.getDrawable() instanceof BitmapDrawable) {
fromBmp = ((BitmapDrawable) fromImgView.getDrawable()).getBitmap();
}
// 获取目标控件图片
if (toImgView.getDrawable() instanceof BitmapDrawable) {
toBmp = ((BitmapDrawable) toImgView.getDrawable()).getBitmap();
}
// 交换两者图片
if (toBmp != null) fromImgView.setImageBitmap(toBmp);
if (fromBmp != null) toImgView.setImageBitmap(fromBmp);
}
...
@Override
public void onDragFinish(MotionEvent event) {
// 由于传递过来的事件是相对于子View的坐标,所以需要变换为相对Layout的坐标
final float offsetX = mCurrentChildView.getLeft() - getScrollX();
final float offsetY = mCurrentChildView.getTop() - getScrollY();
event.offsetLocation(offsetX, offsetY);
dragFinish(event);
event.offsetLocation(-offsetX, -offsetY);
}
}
使用
自定义Layout和ImageView写好后就大功告成了,只需要在xml文件中进行配置,然后为ImageView设置图片即可看到效果
// 下面两个属性为TrasmativeImageView中的自定义属性
app:max_scale="4" 最大缩放比例为4倍
app:revert="false" 不开启回弹效果
// 下面的为DraggableImageView的自定义属性
app:boundary_left="true" 左边界可触发拖拽
app:boundary_top="true" 上边界可触发拖拽
app:trigger_distance="3dp" 触发拖拽的距离
<?xml version="1.0" encoding="utf-8"?>
<cn.lkllkllkl.configurableframelayout.ConfigurableFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="cn.lkllkllkl.configurableframelayoutsample.MainActivity">
<cn.lkllkllkl.configurableframelayout.DraggableImageView
android:background="@color/gray"
android:id="@+id/draggable_image_view_1"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginLeft="90dp"
android:layout_marginTop="30dp"
app:max_scale="4"
app:revert="false"
app:boundary_bottom="true"
app:trigger_distance="100dp"
/>
<cn.lkllkllkl.configurableframelayout.DraggableImageView
android:background="@color/gray"
android:id="@+id/draggable_image_view_2"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_marginTop="270dp"
android:layout_marginLeft="50dp"
app:revert="false"
app:max_scale="4"
app:boundary_top="true"
app:boundary_right="true"
app:trigger_distance="3dp"/>
<cn.lkllkllkl.configurableframelayout.DraggableImageView
android:background="@color/gray"
android:id="@+id/draggable_image_view_3"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginTop="290dp"
android:layout_marginLeft="220dp"
app:max_scale="4"
app:revert="false"
app:boundary_left="true"
app:boundary_top="true"
app:trigger_distance="3dp"/>
</cn.lkllkllkl.configurableframelayout.ConfigurableFrameLayout>
/**
* Activity 代码
*/
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DraggableImageView draggableImageView1 =
(DraggableImageView) findViewById(R.id.draggable_image_view_1);
DraggableImageView draggableImageView2 =
(DraggableImageView) findViewById(R.id.draggable_image_view_2);
DraggableImageView draggableImageView3 =
(DraggableImageView) findViewById(R.id.draggable_image_view_3);
// 此处使用Glide将图片载入View中
GlideApp.with(this)
.load(R.drawable.black)
.into(draggableImageView1);
GlideApp.with(this)
.load(R.drawable.cat)
.into(draggableImageView2);
GlideApp.with(this)
.load(R.drawable.cat)
.into(draggableImageView3);
}
}