主页 > 开发文档 > Android自定义控件:做一个拼图游戏

Android自定义控件:做一个拼图游戏

一些简单的游戏可以用自定义控件实现,如拼图游戏。先上效果图:


 
普通模式
 
交换模式

1、游戏的大概思路

游戏的基本思路:将一个大图切割成多份小图,然后将小图的顺序打乱,整齐排列在一个ViewGroup中,通过点击小图互换位置将图片拼合为原来的大图。

2、技术要点

1、继承ViewGroup的自定义控件以及onLayout方法的使用。
2、把一张大图切割成多个小图。
3、图片压缩。
4、属性动画。
5、DialogFragment的使用。

3、技术点分析

3.1、继承ViewGroup实现自定义View

在实现一个自定义View时,需要判断继承View还是ViewGroup。
继承View:继承View的自定义控件可以叫自绘控件,需要用到paint、canvas等类来进行绘制。例如:http://www.jianshu.com/p/ac33e61a1476
继承ViewGroup:继承ViewGroup的自定义控件可以叫组合控件,有多个控件组合而成的自定义控件,例如这个拼图游戏,就是由多个ImageView和一个ViewGroup组合而成。

3.2、非常重要的onLayout方法

继承ViewGroup,onLayout方法必须实现的。这个方法非常重要,是控制子控件在父容器中位置的关键方法。

  @Override
  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
  }

View有对应的四个方法:getLeft() 、getTop()、 getRight()、 getBottom() 。来分别获取left,top,right,bottom的值。这些值代表子view的边界和父容器边界的距离。

 
四个方法的含义

图片来自:http://blog.csdn.net/u013872857/article/details/53750682

从图片中可以看出:

left = view.getLeft();
top = view.getTop();
right = view.getLeft()+view的宽度
botto = view.getTop()+view的高度

如果一个View获取到它自身的left、top、right、bottom四个参数,就可以通过view的layout方法来确定该view在父容器的位置。
这些参数在ViewGroup中的使用:

  protected void onLayout(boolean changed, int l, int t, int r, int b) {
    view.layout(left,top,right, bottom);
  }

拼图游戏中计算出了每个ImageView的left、top、right、bottom。计算的原因是:ViewGroup通过AddView(子view)之后子View默认都是显示在左上角。通过计算之后才能使子view根据不同的left、top、right、bottom展示在不同的位置。

    private void initBitmapsWidth() {
        int line = 0;
        int left = 0;
        int top = 0;
        int right = 0;
        int bottom = 0;
        for (int i = 0; i < mImagePieces.size(); i++) {
            /// ... 省略若干代码
            if (i != 0 && i % mCount == 0) {
                line++;
            }
            if (i % mCount == 0) {
                left = i % mCount * mItemWidth;
            } else {
                left = i % mCount * mItemWidth + (i % mCount) * mMargin;
            }
            top = mItemWidth * line + line * mMargin;
            right = left + mItemWidth;
            bottom = top + mItemWidth;
            imageView.setRight(right);
            imageView.setLeft(left);
            imageView.setBottom(bottom);
            imageView.setTop(top);
            imageView.setId(i);
            imageView.setOnClickListener(this);
            mImagePieces.get(i).setImageView(imageView);
            addView(imageView);
        }
    }

每个ImageView在onLayout方法中的展示:
这里通过 imageView.layout()方法将图片展示在父容器不同的位置。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        for (int i = 0; i < getChildCount(); i++) {
            if (getChildAt(i) instanceof ImageView) {
                ImageView imageView = (ImageView) getChildAt(i);
                imageView.layout(imageView.getLeft(), imageView.getTop(), imageView.getRight(), imageView.getBottom());
            } else {
                //针对动画层的layout
                if (getChildAt(i) instanceof RelativeLayout) {
                    RelativeLayout relativeLayout = (RelativeLayout) getChildAt(i);
                    relativeLayout.layout(0, 0, mViewWidth, mViewWidth);
                }
            }
        }
    }

3.3 将一张大图切割成多个小图

这里的拼图游戏并不是自己找来很多的图片,而是用一张大图片切割成多个小图片。这也比较好理解,随着难度等级提高,每一行显示的图片要增加,如果每个小图是单独的图片,那么这会非常麻烦。
图片切割的方法:

 /**
     * 传入一个bitmap 返回 一个picec集合
     *
     * @param bitmap
     * @param count
     * @return
     */
    public static List<ImagePiece> splitImage(Context context, Bitmap bitmap, int count, String gameMode) {
        List<ImagePiece> imagePieces = new ArrayList<>();
        int width = bitmap.getWidth();
        int height = bitmap.getHeight();
        int picWidth = Math.min(width, height) / count;
        for (int i = 0; i < count; i++) {
            for (int j = 0; j < count; j++) {
                ImagePiece imagePiece = new ImagePiece();
                imagePiece.setIndex(j + i * count);
                //为createBitmap 切割图片获取xy
                int x = j * picWidth;
                int y = i * picWidth;
                if (gameMode.equals(PuzzleLayout.GAME_MODE_NORMAL)) {
                    if (i == count - 1 && j == count - 1) {
                        imagePiece.setType(ImagePiece.TYPE_EMPTY);
                        Bitmap emptyBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.empty);
                        imagePiece.setBitmap(emptyBitmap);
                    } else {
                        imagePiece.setBitmap(Bitmap.createBitmap(bitmap, x, y, picWidth, picWidth));
                    }
                } else {
                    imagePiece.setBitmap(Bitmap.createBitmap(bitmap, x, y, picWidth, picWidth));
                }
                imagePieces.add(imagePiece);
            }
        }
        return imagePieces;
    }

通过Bitmap.createBitmap的方法将图片分割成多份。建立一个javaBean用来保存bitmap和index(index保存图片的下标,用于检查是否完成拼图)。在拼图游戏的普通模式(普通模式效果图)中有一张空白的图,这里用一张透明的.9 图代替。

3.4 图片的压缩

为了防止图片过大导致OOM,这里用了压缩图片的方法:

    /**
     * 读取图片,按照缩放比保持长宽比例返回bitmap对象
     * <p>
     *
     * @param scale 缩放比例(1到10, 为2时,长和宽均缩放至原来的2分之1,为3时缩放至3分之1,以此类推)
     * @return Bitmap
     */
    public synchronized static Bitmap readBitmap(Context context, int res, int scale) {
        try {
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inJustDecodeBounds = false;
            options.inSampleSize = scale;
            options.inPurgeable = true;
            options.inInputShareable = true;
            options.inPreferredConfig = Bitmap.Config.RGB_565;
            return BitmapFactory.decodeResource(context.getResources(), res, options);
        } catch (Exception e) {
            return null;
        }
    }

3.5 属性动画

3.5.1 动画层的概念

QQ的列表中的气泡拖拽效果,就用到类似的概念:

 
气泡拖拽效果

如果这个气泡是在某个ViewGroup中,那么拖动的时候是不可能拖出这个ViewGroup的,因为气泡是这个ViewGroup的子View,它不可能展示在ViewGroup之外,更不要说整个屏幕都能拖动了。因此这里可能用到动画层的概念:在点击气泡时,隐藏点击的气泡,并添加一个透明的全屏的ViewGroup覆盖在整个布局上,然后在原来的气泡位置添加一个相似的气泡,然后就可以做到在这个ViewGroup上面滑动了。(注:这是我YY出来的结果,可能QQ并不是这样实现的)

 

    /**
     * 构造动画层 用于点击之后的动画
     * 为什么要做动画层? 要保证动画在整个view上面执行。
     */
    private void setUpAnimLayout() {
        if (mAnimLayout == null) {
            mAnimLayout = new RelativeLayout(getContext());
        }
        if (!isAddAnimatorLayout) {
            isAddAnimatorLayout = true;
            addView(mAnimLayout);
        }
    }

这个会遇到一个问题:调用了addView(mAnimLayout);这段代码之后发现,动画层不显示。这个问题可能需要去看源码,不过我还是暂时找到了一个解决方案(暂时也不知道原因):addview之后要重新给子view设置宽高。http://www.cnblogs.com/renjiemei1225/p/6215671.html
解决该问题的代码:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(mViewWidth, mViewWidth);
        for (int i = 0; i < getChildCount(); i++) {
            if (getChildAt(i) instanceof RelativeLayout) {
                getChildAt(i).measure(widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
3.5.2 实现小图滑动或者交换

拼图时,无论是普通模式或者交换模式,无非都是两个图片的交换效果。
当点击图片时,隐藏点击的图片并记录,然后生成动画层,在动画层上生成大小位置一样的图片,然后在动画层上实现图片交换的效果。
动画完成之后可以在onAnimationEnd中隐藏动画层,移除掉动画层中的ImageView,将记录好的两个ImageView一些属性的交换,比如说bitmap的交换,index的交换。
注意:效果看起来像两个ImageView互换了位置,实际上只是bitmap相互替换了。普通模式中的空白图片默认就是记录好的一张图片。

    /**
     * @param imageView 点击时记录下的ImageView
     * @return
     */
    private ImageView addAnimationImageView(ImageView imageView) {
        ImageView getImage = new ImageView(getContext());
        RelativeLayout.LayoutParams firstParams = new RelativeLayout.LayoutParams(mItemWidth, mItemWidth);
        firstParams.leftMargin = imageView.getLeft() - mPadding;
        firstParams.topMargin = imageView.getTop() - mPadding;
        Bitmap firstBitmap = mImagePieces.get(imageView.getId()).getBitmap();
        getImage.setImageBitmap(firstBitmap);
        getImage.setLayoutParams(firstParams);
        mAnimLayout.addView(getImage);
        return getImage;
    }
   /**
     * 添加动画层,并且添加平移的动画
     */
    private void exChangeView() {

        //添加动画层
        setUpAnimLayout();
        //添加第一个图片
        ImageView first = addAnimationImageView(mFirst);
        //添加另一个图片
        ImageView second = addAnimationImageView(mSecond);

        ObjectAnimator secondXAnimator = ObjectAnimator.ofFloat(second, "TranslationX", 0f, -(mSecond.getLeft() - mFirst.getLeft()));
        ObjectAnimator secondYAnimator = ObjectAnimator.ofFloat(second, "TranslationY", 0f, -(mSecond.getTop() - mFirst.getTop()));
        ObjectAnimator firstXAnimator = ObjectAnimator.ofFloat(first, "TranslationX", 0f, mSecond.getLeft() - mFirst.getLeft());
        ObjectAnimator firstYAnimator = ObjectAnimator.ofFloat(first, "TranslationY", 0f, mSecond.getTop() - mFirst.getTop());
        AnimatorSet secondAnimator = new AnimatorSet();
        secondAnimator.play(secondXAnimator).with(secondYAnimator).with(firstXAnimator).with(firstYAnimator);
        secondAnimator.setDuration(300);
        secondAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                 ImagePiece firstPiece = mImagePieces.get(mFirst.getId());
                ImagePiece secondPiece = mImagePieces.get(mSecond.getId());
                int firstType = firstPiece.getType();
                int secondType = secondPiece.getType();
                Bitmap firstBitmap = mImagePieces.get(mFirst.getId()).getBitmap();
                Bitmap secondBitmap = mImagePieces.get(mSecond.getId()).getBitmap();

                int fristIndex = firstPiece.getIndex();
                int secondeIndex = secondPiece.getIndex();
                if (mFirst != null) {
                    mFirst.setColorFilter(null);
                    mFirst.setVisibility(VISIBLE);
                    mFirst.setImageBitmap(secondBitmap);
                    firstPiece.setBitmap(secondBitmap);
                    firstPiece.setIndex(secondeIndex);
                }
                if (mSecond != null) {
                    mSecond.setVisibility(VISIBLE);
                    mSecond.setImageBitmap(firstBitmap);
                    secondPiece.setBitmap(firstBitmap);
                    secondPiece.setIndex(fristIndex);
                }
                if (mGameMode.equals(GAME_MODE_NORMAL)) {
                    firstPiece.setType(secondType);
                    secondPiece.setType(firstType);
                }
                mAnimLayout.removeAllViews();
                mAnimLayout.setVisibility(GONE);
                mFirst = null;
                mSecond = null;
                isAnimation = false;
                invalidate();
                if (checkSuccess()) {
                    Toast.makeText(getContext(), "成功!", Toast.LENGTH_SHORT).show();
                    if (mSuccessListener != null) {
                        mSuccessListener.success();
                    }
                }
            }

            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                isAnimation = true;
                mAnimLayout.setVisibility(VISIBLE);
                mFirst.setVisibility(INVISIBLE);
                mSecond.setVisibility(INVISIBLE);
            }
        });
        secondAnimator.start();
    }

3.6 DialogFragment的使用

3.6.1 基本概念

DialogFragment在android 3.0时被引入。是一种特殊的Fragment,用于在Activity的内容之上展示一个模态的对话框。典型的用于:展示警告框,输入框,确认框等等。

3.6.2 使用的好处

使用DialogFragment来管理对话框,当旋转屏幕和按下后退键时可以更好的管理其声明周期,它和Fragment有着基本一致的声明周期。且DialogFragment也允许开发者把Dialog作为内嵌的组件进行重用,类似Fragment(可以在大屏幕和小屏幕显示出不同的效果)。上面会通过例子展示这些好处~
以上的文字来自博客:http://blog.csdn.net/lmj623565791/article/details/37815413/
基本用法都在上面的博客了,不详细讲解用法了。

图片选择和游戏成功时用到DialogFragment。


 
图片选择

3.7 一些公开的API

提供一些公共的方法便于改变游戏的模式、难度、图片等。每次改变都应该重置一些必要参数。

  /**
    * 重置游戏 
    */
 public void reset() {
        mItemWidth = (mViewWidth - mPadding * 2 - mMargin * (mCount - 1)) / mCount;
        if (mImagePieces != null) {
            mImagePieces.clear();
        }
        isAddAnimatorLayout = false;
        mBitmap = null;
        removeAllViews();
        initBitmaps();
        initBitmapsWidth();
    }

    /**
     * 添加count 最多每行7个
     */
    public boolean addCount() {
        mCount++;
        if (mCount > 7) {
            mCount--;
            return false;
        }
        reset();
        return true;
    }

    /**
     * 改变图片
     */
    public void changeRes(int res) {
        this.res = res;
        reset();
    }

    /**
     * 减少count 最少每行三个,否则普通模式无法游戏
     */
    public boolean reduceCount() {
        mCount--;
        if (mCount < 3) {
            mCount++;
            return false;
        }
        reset();
        return true;
    }

3.8 其他

一些推荐的网址:
属性动画:
http://blog.csdn.net/lmj623565791/article/details/38067475
http://www.jianshu.com/p/ecba05115d80
http://www.jianshu.com/p/2412d00a0ce4
DialogFragment:
http://blog.csdn.net/lmj623565791/article/details/37815413/
ViewGroup:
http://blog.csdn.net/lmj623565791/article/details/38339817/

源码地址:https://github.com/AxeChen/PuzzleGame



作者:AxeChen
链接:https://www.jianshu.com/p/11899d1e1dea
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

来源: https://www.jianshu.com/p/11899d1e1dea