利用ViewDragHelper轻松实现Android拼图游戏

前言

最近一段时间看了一些介绍ViewDragHelper的博客,感觉这是一个处理手势滑动的神器,看完以后就想做点东西练练手,于是就做了这个Android拼图小游戏。

先上个效果图

demo.gif

源码 https://github.com/kevin-mob/Puzzle

实现思路

  1. 自定义PuzzleLayout继承自RelativeLayout。
  2. 将PuzzleLayout的onInterceptTouchEvent和onTouchEvent交给ViewDragHelper来处理。
  3. 将拼图Bitmap按九宫格切割,生成ImageView添加到PuzzleLayout并进行排列。
  4. 创建ImageView的对应数据模型。
  5. ViewDragHelper.Callback控制滑动边界的实现。
  6. 打乱ImageView的摆放位置。

下面介绍一下以上5步的具体实现细节。

第一步: 创建一个PuzzleLayout继承自RelativeLayout。

public class PuzzleLayout extends RelativeLayout {
    public PuzzleLayout(Context context) {
            super(context);
        }
    
        public PuzzleLayout(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        public PuzzleLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        }
}

第二步:将PuzzleLayout的onInterceptTouchEvent和onTouchEvent交给ViewDragHelper来处理。

这里我们会用到ViewDragHelper这个处理手势滑动的神器。
在使用之前我们先简单的了解一下它的相关函数。

/**
 * Factory method to create a new ViewDragHelper.
 *
 * @param forParent Parent view to monitor
 * @param sensitivity Multiplier for how sensitive the helper
 *  should be about detecting the start of a drag. 
 *  Larger values are more sensitive. 1.0f is normal.
 * @param cb Callback to provide information and receive events
 * @return a new ViewDragHelper instance
 */
public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb)

上面这个是创建一个ViewDragHelper的静态函数,根据注释我们可以了解到:

  • 第一个参数是当前的ViewGroup。
  • 第二个参数是检测拖动开始的灵敏度,1.0f为正常值。
  • 第三个参数Callback,是ViewDragHelper给ViewGroup的回调。

这里我们主要来看看Callback这个参数,Callback会在手指触摸当前ViewGroup的过程中不断返回解析到的相关事件和状态,并获取ViewGroup返回给ViewDragHelper的状态,来决定接下来的操作是否需要执行,从而达到了在ViewGroup中管理和控制ViewDragHelper的目的。

Callback的方法很多,这里主要介绍本文用到的几个方法

  • public abstract boolean tryCaptureView(View child, int pointerId)
    尝试捕获当前手指触摸到的子view, 返回true 允许捕获,false不捕获。

  • public int clampViewPositionHorizontal(View child, int left, int dx)
    控制childView在水平方向的滑动,主要用来限定childView滑动的左右边界。

  • public int clampViewPositionVertical(View child, int top, int dy)
    控制childView在垂直方向的滑动,主要用来限定childView滑动的上下边界。

  • public void onViewReleased(View releasedChild, float xvel, float yvel)
    当手指从childView上离开时回调。

有了以上这些函数,我们的拼图游戏大致就可以做出来了,通过ViewDragHelper.create()来创建一个ViewDragHelper,通过Callback中tryCaptureView来控制当前触摸的子view是否可以滑动,clampViewPositionHorizontal、clampViewPositionVertical来控制水平方向和垂直方向的移动边界,具体的方法实现会在后面讲到。

public class PuzzleLayout extends RelativeLayout {
    private ViewDragHelper viewDragHelper;
    public PuzzleLayout(Context context) {
        super(context);
        init();
    }

    public PuzzleLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public PuzzleLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                mHeight = getHeight();
                mWidth = getWidth();
                getViewTreeObserver().removeOnPreDrawListener(this);
                if(mDrawableId != 0 && mSquareRootNum != 0){
                    createChildren();
                }
                return false;
            }
        });
        viewDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(View child, int pointerId) {
                return true;
            }

            @Override
            public int clampViewPositionHorizontal(View child, int left, int dx) {

                return left;
            }

            @Override
            public int clampViewPositionVertical(View child, int top, int dy) {
                return top;
            }

            @Override
            public void onViewReleased(View releasedChild, float xvel, float yvel) {
            }
        });
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event){
        return viewDragHelper.shouldInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        viewDragHelper.processTouchEvent(event);
        return true;
    }
}

第三步,将拼图Bitmap按九宫格切割,生成ImageView添加到PuzzleLayout并进行排列。

pic1.png

首先,外界需要传入一个切割参数mSquareRootNum做为宽和高的切割份数,我们需要获取PuzzleLayout的宽和高,然后计算出每一块的宽mItemWidth和高mItemHeight, 将Bitmap等比例缩放到和PuzzleLayout大小相等,然后将图片按照类似上面这张图所标的形式进行切割,生成mSquareRootNum*mSquareRootNum份Bitmap,每个Bitmap对应创建一个ImageView载体添加到PuzzleLayout中,并进行布局排列。
创建子view, mHelper是封装的用来操作对应数据模型的帮助类DataHelper。

/**
 *  将子View index与mHelper中models的index一一对应,
 *  每次在交换子View位置的时候model同步更新currentPosition。
 */
private void createChildren(){
    mHelper.setSquareRootNum(mSquareRootNum);

    DisplayMetrics dm = getResources().getDisplayMetrics();
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inDensity = dm.densityDpi;

    Bitmap resource = BitmapFactory.decodeResource(getResources(), mDrawableId, options);
    Bitmap bitmap = BitmapUtil.zoomImg(resource, mWidth, mHeight);
    resource.recycle();

    mItemWidth = mWidth / mSquareRootNum;
    mItemHeight = mHeight / mSquareRootNum;

    for (int i = 0; i < mSquareRootNum; i++){
        for (int j = 0; j < mSquareRootNum; j++){
            Log.d(TAG, "mItemWidth * x " + (mItemWidth * i));
            Log.d(TAG, "mItemWidth * y " + (mItemWidth * j));
            ImageView iv = new ImageView(getContext());
            iv.setScaleType(ImageView.ScaleType.FIT_XY);
            LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
            lp.leftMargin = j * mItemWidth;
            lp.topMargin = i * mItemHeight;
            iv.setLayoutParams(lp);
            Bitmap b = Bitmap.createBitmap(bitmap, lp.leftMargin, lp.topMargin, mItemWidth, mItemHeight);
            iv.setImageBitmap(b);
            addView(iv);
        }
    }
}

第四步,创建ImageView的对应数据模型。

public class Block {
    public Block(int position, int vPosition, int hPosition){
        this.position = position;
        this.vPosition = vPosition;
        this.hPosition = hPosition;
    }
    public int position;
    public int vPosition;
    public int hPosition;
}

DataHelper.class
子View在父类的index与mHelper中model在models的index一一对应

class DataHelper {
    static final int N = -1;
    static final int L = 0;
    static final int T = 1;
    static final int R = 2;
    static final int B = 3;
    private static final String TAG = DataHelper.class.getSimpleName();

    private int squareRootNum;
    private List<Block> models;

    DataHelper(){
        models = new ArrayList<>();
    }

    private void reset() {
        models.clear();
        int position = 0;
        for (int i = 0; i< squareRootNum; i++){
            for (int j = 0; j < squareRootNum; j++){
                models.add(new Block(position, i, j));
                position ++;
            }
        }
    }

    void setSquareRootNum(int squareRootNum){
        this.squareRootNum = squareRootNum;
        reset();
    }
}

第五步,ViewDragHelper.Callback控制滑动边界的实现。

tryCaptureView的实现

public boolean tryCaptureView(View child, int pointerId) {
            int index = indexOfChild(child);
            return mHelper.getScrollDirection(index) != DataHelper.N;
        }

DataHelper的getScrollDirection函数

/**
 * 获取索引处model的可移动方向,不能移动返回 -1。
 */
int getScrollDirection(int index){

    Block model = models.get(index);
    int position = model.position;

    //获取当前view所在位置的坐标 x y
    /*
     *      * * * *
     *      * o * *
     *      * * * *
     *      * * * *
     */
    int x = position % squareRootNum;
    int y = position / squareRootNum;
    int invisibleModelPosition = models.get(0).position;

    /*
     * 判断当前位置是否可以移动,如果可以移动就return可移动的方向。
     */

    if(x != 0 && invisibleModelPosition == position - 1)
        return L;

    if(x != squareRootNum - 1 && invisibleModelPosition == position + 1)
        return R;

    if(y != 0 && invisibleModelPosition == position - squareRootNum)
        return T;

    if(y != squareRootNum - 1 && invisibleModelPosition == position + squareRootNum)
        return B;

    return N;
}

clampViewPositionHorizontal的实现细节,获取滑动方向左或右,再控制对应的滑动区域。

public int clampViewPositionHorizontal(View child, int left, int dx) {

            int index = indexOfChild(child);
            int position = mHelper.getModel(index).position;
            int selfLeft = (position % mSquareRootNum) * mItemWidth;
            int leftEdge = selfLeft - mItemWidth;
            int rightEdge = selfLeft + mItemWidth;
            int direction = mHelper.getScrollDirection(index);
            //Log.d(TAG, "left " + left + " index" + index + " dx " + dx + " direction " + direction);
            switch (direction){
                case DataHelper.L:
                    if(left <= leftEdge)
                        return leftEdge;
                    else if(left >= selfLeft)
                        return selfLeft;
                    else
                        return left;

                case DataHelper.R:
                    if(left >= rightEdge)
                        return rightEdge;
                    else if (left <= selfLeft)
                        return selfLeft;
                    else
                        return left;
                default:
                    return selfLeft;
            }
        }

clampViewPositionVertical的实现细节,获取滑动方向上或下,再控制对应的滑动区域。

public int clampViewPositionVertical(View child, int top, int dy) {
            int index = indexOfChild(child);
            Block model = mHelper.getModel(index);
            int position = model.position;

            int selfTop = (position / mSquareRootNum) * mItemHeight;
            int topEdge = selfTop - mItemHeight;
            int bottomEdge = selfTop + mItemHeight;
            int direction = mHelper.getScrollDirection(index);
            //Log.d(TAG, "top " + top + " index " + index + " direction " + direction);
            switch (direction){
                case DataHelper.T:
                    if(top <= topEdge)
                        return topEdge;
                    else if (top >= selfTop)
                        return selfTop;
                    else
                        return top;
                case DataHelper.B:
                    if(top >= bottomEdge)
                        return bottomEdge;
                    else if (top <= selfTop)
                        return selfTop;
                    else
                        return top;
                default:
                    return selfTop;
            }
        }

onViewReleased的实现,当松手时,不可见View和松开的View之间进行布局参数交换,同时对应的model之间也需要通过swapValueWithInvisibleModel函数进行数据交换。

public void onViewReleased(View releasedChild, float xvel, float yvel) {
            Log.d(TAG, "xvel " + xvel + " yvel " + yvel);
            int index = indexOfChild(releasedChild);
            boolean isCompleted = mHelper.swapValueWithInvisibleModel(index);
            Block item =  mHelper.getModel(index);
            viewDragHelper.settleCapturedViewAt(item.hPosition * mItemWidth, item.vPosition * mItemHeight);
            View invisibleView = getChildAt(0);
            ViewGroup.LayoutParams layoutParams = invisibleView.getLayoutParams();
            invisibleView.setLayoutParams(releasedChild.getLayoutParams());
            releasedChild.setLayoutParams(layoutParams);
            invalidate();
            if(isCompleted){
                invisibleView.setVisibility(VISIBLE);
                mOnCompleteCallback.onComplete();
            }
        }

viewDragHelper.settleCapturedViewAt和viewDragHelper.continueSettling配合实现松手后的动画效果。

PuzzleLayout重写computeScroll函数。

@Override
public void computeScroll() {
    if(viewDragHelper.continueSettling(true)) {
        invalidate();
    }
}

swapValueWithInvisibleModel函数,每次交换完成后会return拼图是否完成

/**
 * 将索引出的model的值与不可见
 * model的值互换。
 */
boolean swapValueWithInvisibleModel(int index){
    Block formModel = models.get(index);
    Block invisibleModel = models.get(0);
    swapValue(formModel, invisibleModel);
    return isCompleted();
}

/**
 * 交换两个model的值
 */
private void swapValue(Block formModel, Block invisibleModel) {

    int position = formModel.position;
    int hPosition = formModel.hPosition;
    int vPosition = formModel.vPosition;

    formModel.position = invisibleModel.position;
    formModel.hPosition = invisibleModel.hPosition;
    formModel.vPosition = invisibleModel.vPosition;

    invisibleModel.position = position;
    invisibleModel.hPosition = hPosition;
    invisibleModel.vPosition = vPosition;
}

/**
 * 判断是否拼图完成。
 */
private boolean isCompleted(){
    int num = squareRootNum * squareRootNum;
    for (int i = 0; i < num; i++){
        Block model = models.get(i);
        if(model.position != i){
            return false;
        }
    }
    return true;
}

第六步,打乱ImageView的摆放位置。

这里不能随意打乱顺序,否则你可能永远也不能复原拼图了,这里使用的办法是每次在不可见View附近随机找一个View与不可见View进行位置交换,这里的位置交换指的是布局参数的交换,同时对应的数据模型也需要进行数据交换。

public void randomOrder(){
    int num = mSquareRootNum * mSquareRootNum * 8;
    View invisibleView = getChildAt(0);
    View neighbor;
    for (int i = 0; i < num; i ++){
        int neighborPosition = mHelper.findNeighborIndexOfInvisibleModel();
        ViewGroup.LayoutParams invisibleLp = invisibleView.getLayoutParams();
        neighbor = getChildAt(neighborPosition);
        invisibleView.setLayoutParams(neighbor.getLayoutParams());
        neighbor.setLayoutParams(invisibleLp);
        mHelper.swapValueWithInvisibleModel(neighborPosition);
    }
    invisibleView.setVisibility(INVISIBLE);
}

DataHelper中findNeighborIndexOfInvisibleModel函数

/**
 * 随机查询出不可见
 * 位置周围的一个model的索引。
 */
public int findNeighborIndexOfInvisibleModel() {
    Block invisibleModel = models.get(0);
    int position = invisibleModel.position;
    int x = position % squareRootNum;
    int y = position / squareRootNum;
    int direction = new Random(System.nanoTime()).nextInt(4);
    Log.d(TAG, "direction " + direction);
    switch (direction){
        case L:
            if(x != 0)
                return getIndexByCurrentPosition(position - 1);
        case T:
            if(y != 0)
                return getIndexByCurrentPosition(position - squareRootNum);
        case R:
            if(x != squareRootNum - 1)
                return getIndexByCurrentPosition(position + 1);
        case B:
            if(y != squareRootNum - 1)
                return getIndexByCurrentPosition(position + squareRootNum);
    }
    return findNeighborIndexOfInvisibleModel();
}

/**
 * 通过给定的位置获取model的索引
 */
private int getIndexByCurrentPosition(int currentPosition){
    int num = squareRootNum * squareRootNum;
    for (int i = 0; i < num; i++) {
        if(models.get(i).position == currentPosition)
            return i;
    }
    return -1;
}

以上为主要的代码实现,全部工程已上传Github,欢迎学习,欢迎star,传送门
https://github.com/kevin-mob/Puzzle

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,386评论 25 707
  • ViewDragHelper实例的创建 ViewDragHelper重载了两个create()静态方法public...
    傀儡世界阅读 653评论 0 3
  • 内容是博主照着书敲出来的,博主码字挺辛苦的,转载请注明出处,后序内容陆续会码出。 当了解了Android坐标系和触...
    Blankj阅读 6,622评论 3 61
  • 经不住风雨的洗礼,路上的微尘便没有回家的路。启明星的南方在谁的心上,助长了风和雨的迷茫。谁能明白竹篙的长度只是...
    竹鸿初阅读 344评论 0 1
  • 结束 不过两个字 忘记 却是要一辈子 回忆 太过漫长 等待 却是一生 我很自私 要的不只是你的过去 你的现在和未来...
    橙兮悦阅读 231评论 9 8