Android自定义图片拖拽控件

源码地址

效果图

基本思路

布局示意图

我们先考虑简单的情况,两个控件之间的图片拖拽,首先我们需要准备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);
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,510评论 25 707
  • 《夏季,爷爷》这是个炎热的夏季看着你匆忙的身影不觉中已七十个年头你是个勤劳的农民虽然已七十多岁了可是你从未放下过放...
    葉威阅读 214评论 0 1
  • 7,是我们,七个姑娘、平凡的姑娘。 本来是不讨喜的数字,可是因为我们,它突然讨喜了,还格外可爱。 其实这个序言本来...
    詹小邪阅读 107评论 0 1
  • 介绍 大约半个月前第一次听说了OKR的概念,此方法是google用于企业内部管理的方法,但我觉得这个方法一样可以用...
    陈素封阅读 10,303评论 1 24
  • JobScheduler的地位非常的重要,所有的关键都在JobScheduler,它的重要性就相当于是Spa...
    阳光男孩spark阅读 1,040评论 0 0