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

2,317 阅读13分钟
原文链接: mp.weixin.qq.com

作者简介

本文作者:尘封的落叶

地址:http://www.jianshu.com/p/11899d1e1dea

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

普通模式:

交互模式:

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中的使用:

拼图游戏中计算出了每个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 seconde
               Index = 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 其他

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

Material Design 系列文章

Material Design 之 Toolbar 开发实践总结

Material Design 之 AppbarLayout开发实践总结

Material Design 之 Behavior的使用和自定义Behavior

Material Design 系列之CardView、FAB和Snackbar

Material Design 之TabLayout使用

Material Design之TextInputLayout和TextInputEditText