自定義View高仿懂球帝我是教練效果

天下無(wú)敵

前言

這幾天很多歐洲球隊(duì)來(lái)中國(guó)進(jìn)行熱身賽,不知道喜歡足球的各位小伙伴們有沒(méi)有看球。喜歡足球的朋友可能知道懂球帝APP,鄙人也經(jīng)常使用這個(gè)應(yīng)用,里面有一個(gè)我是教練的功能挺好玩,就是可以模擬教練員的身份,排兵布陣;本著好奇心簡(jiǎn)單模仿了一下,在這里和大家分享。

效果圖

老規(guī)矩,先上效果圖看看模仿的像不。

add_player.gif
move_player

玩過(guò)我是教練這個(gè)功能的小伙伴可以對(duì)比一下。

總的來(lái)說(shuō),這樣的一個(gè)效果,其實(shí)很簡(jiǎn)單,就是一個(gè)view隨著手指在屏幕上移動(dòng)的效果,外加一個(gè)圖片替換的動(dòng)畫。但就是這些看似簡(jiǎn)單的效果,在實(shí)現(xiàn)的過(guò)程中也是遇到了很多坑,漲了許多新姿勢(shì)。好了,廢話不說(shuō),代碼走起(??ˇ?ˇ?)。

自定義View-BallGameView

整個(gè)內(nèi)容中最核心的就是一個(gè)自定義View-BallGameView,就是屏幕中綠色背景,有氣泡和球員圖片的整個(gè)view。

說(shuō)到自定義View,老生常談,大家一直都在學(xué)習(xí),卻永遠(yuǎn)都覺得自己沒(méi)有學(xué)會(huì),但是自定義View的知識(shí)本來(lái)就很多呀,想要熟練掌握,必須假以時(shí)日

既然是自定View就從大家最關(guān)心的兩個(gè)方法 onMeasure和onDraw 兩個(gè)方法說(shuō)起。這里由于是純粹繼承自View,就不考慮onLayout的實(shí)現(xiàn)了。

測(cè)量-onMeasure

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int viewW = screenW;
        int viewH = (int) (screenW * 1.3);
        setMeasuredDimension(viewW, viewH);
    }

這里onMeasure()方法的實(shí)現(xiàn)很簡(jiǎn)單,簡(jiǎn)單的用屏幕的寬度規(guī)定了整個(gè)View 的寬高;至于1.3這個(gè)倍數(shù),完全一個(gè)估算值,不必深究。

繪制-onDraw

onDraw()方法是整個(gè)View中最核心的方法。

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //繪制背景
        canvas.drawBitmap(backgroundBitmap, bitmapRect, mViewRect, mPaint);
        //繪制提示文字透明背景
        canvas.drawRoundRect(mRoundRect, 8, 8, mRectPaint);
        //繪制底部提示文字 ( TextPiant 文字垂直居中實(shí)現(xiàn) http://blog.csdn.net/hursing/article/details/18703599)
        Paint.FontMetricsInt fontMetrics = mTipPaint.getFontMetricsInt();
        float baseY=(mRoundRect.bottom+mRoundRect.top)/2-(fontMetrics.top+fontMetrics.bottom)/2;
        canvas.drawText(tips, screenW / 2, baseY, mTipPaint);


        //繪制初始的11個(gè)氣泡
        for (int i = 0; i < players.length; i++) {
            //繪制當(dāng)前選中的球員
            if (i == currentPos) {

                if (players[i].isSetReal()) {
                    //繪制球員頭像
                    canvas.drawBitmap(players[i].getBitmap(), positions[i].x - playW / 2,
                            positions[i].y - playW / 2, mPaint);
                    //繪制選中球員金色底座
                    canvas.drawBitmap(playSelectedBitmap, positions[i].x - goldW / 2,
                            positions[i].y - goldH / 2, mPaint);

                    //繪制球員姓名
                    canvas.drawText(players[i].getName(), positions[i].x,
                            positions[i].y + playW, mTextPaint);

                } else {
                    canvas.drawBitmap(selectedBitmap, positions[i].x - playW / 2,
                            positions[i].y - playW / 2, mPaint);
                }


            } else {
                canvas.drawBitmap(players[i].getBitmap(), positions[i].x - playW / 2,
                        positions[i].y - playW / 2, mPaint);
                if (players[i].isSetReal()) {

                    //繪制球員姓名
                    canvas.drawText(players[i].getName(), positions[i].x,
                            positions[i].y + playW, mTextPaint);
                    //繪制已設(shè)置正常圖片球員背景
                    canvas.drawBitmap(playeBgBitmap, positions[i].x - grayW / 2,
                            positions[i].y + 200, mPaint);
                }
            }
        }
    }

可以看到,在onDraw方法里,我們主要使用了canvas.drawBitmap 方法,繪制了很多圖片。下面就簡(jiǎn)單了解一下canvas.drawBitmap 里的兩個(gè)重載方法。

  • drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint)
/**
     * Draw the specified bitmap, scaling/translating automatically to fill
     * the destination rectangle. If the source rectangle is not null, it
     * specifies the subset of the bitmap to draw.
     *
     *
     * @param bitmap The bitmap to be drawn
     * @param src    May be null. The subset of the bitmap to be drawn
     * @param dst    The rectangle that the bitmap will be scaled/translated
     *               to fit into
     * @param paint  May be null. The paint used to draw the bitmap
     */
    public void drawBitmap(@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst,
            @Nullable Paint paint) {
       
    }

drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint),這個(gè)重載方法主要是通過(guò)兩個(gè)Rectangle 決定了bitmap以怎樣的形式繪制出來(lái)。簡(jiǎn)單來(lái)說(shuō),src 這個(gè)長(zhǎng)方形決定了“截取”bitmap的大小,dst 決定了最終繪制出來(lái)時(shí)Bitmap應(yīng)該占有的大小。。就拿上面的代碼來(lái)說(shuō)

        backgroundBitmap = BitmapFactory.decodeResource(res, R.drawable.battle_bg);
        //確保整張背景圖,都能完整的顯示出來(lái)
        bitmapRect = new Rect(0, 0, backgroundBitmap.getWidth(), backgroundBitmap.getHeight());
        //目標(biāo)區(qū)域,在整個(gè)視圖的大小中,繪制Bitmap
        mViewRect = new Rect(0, 0, viewW, viewH);
        //繪制背景
        canvas.drawBitmap(backgroundBitmap, bitmapRect, mViewRect, mPaint);

bitmapRect 是整個(gè)backgroundBitmap的大小,mViewRect也就是我們?cè)趏nMeasure里規(guī)定的整個(gè)視圖的大小,這樣相當(dāng)于把battle_bg這張圖片,以scaleType="fitXY"的形式畫在了視圖大小的區(qū)域內(nèi)。這樣,你應(yīng)該理解這個(gè)重載方法的含義了。

  • drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
    /**
     * Draw the specified bitmap, with its top/left corner at (x,y), using
     * the specified paint, transformed by the current matrix.
     *
     *
     * @param bitmap The bitmap to be drawn
     * @param left   The position of the left side of the bitmap being drawn
     * @param top    The position of the top side of the bitmap being drawn
     * @param paint  The paint used to draw the bitmap (may be null)
     */
    public void drawBitmap(@NonNull Bitmap bitmap, float left, float top, @Nullable Paint paint) {

    }

這個(gè)重載方法應(yīng)該很容易理解了,left,top 規(guī)定了繪制Bitmap的左上角的坐標(biāo),然后按照其大小正常繪制即可。

這里我們所有的氣泡(球員位置)都是使用這個(gè)方法繪制的。足球場(chǎng)上有11個(gè)球員,因此我們通過(guò)數(shù)組預(yù)先定義了11個(gè)氣泡的初始位置,然后通過(guò)其坐標(biāo)位置,繪制他們。為了繪制精確,需要減去每張圖片自身的寬高,這應(yīng)該是很傳統(tǒng)的做法了。

同時(shí),在之后的觸摸反饋機(jī)制中,我們會(huì)根據(jù)手指的滑動(dòng),修改這些坐標(biāo)值,這樣就可以隨意移動(dòng)球員在場(chǎng)上的位置了;具體實(shí)現(xiàn),結(jié)合代碼中的注釋應(yīng)該很容易理解了,就不再贅述;可以查看完整源碼BallGameView。

文字居中繪制

這里再說(shuō)一個(gè)在繪制過(guò)程中遇到一個(gè)小問(wèn)題,可以看到在整個(gè)視圖底部,繪制了一個(gè)半透明的圓角矩形,并在他上面繪制了一行黃色的文字,這行文字在水平和垂直方向都是居中的;使用TextPaint 繪制文字實(shí)現(xiàn)水平居中是很容易的事情,只需要設(shè)置mTipPaint.setTextAlign(Paint.Align.CENTER)即可,但是在垂直方向?qū)崿F(xiàn)居中,就沒(méi)那么簡(jiǎn)單了,這里需要考慮一個(gè)文本繪制時(shí)基線的問(wèn)題,具體細(xì)節(jié)可以參考這篇文章,分析的很詳細(xì)。

我們?cè)谶@里為了使文字在圓角矩形中居中,如下實(shí)現(xiàn)。

        canvas.drawRoundRect(mRoundRect, 8, 8, mRectPaint);
        Paint.FontMetricsInt fontMetrics = mTipPaint.getFontMetricsInt();
        float baseY = (mRoundRect.bottom + mRoundRect.top) / 2 - (fontMetrics.top + fontMetrics.bottom) / 2;
        canvas.drawText(tips, screenW / 2, baseY, mTipPaint);

圓角矩形的垂直中心點(diǎn)的基礎(chǔ)上,再一次做修正,確保實(shí)現(xiàn)真正的垂直居中。

好了,結(jié)合扔物線大神所總結(jié)的自定義View關(guān)鍵步驟,以上兩點(diǎn)算是完成了繪制和布局的工作,下面就看看觸摸反饋的實(shí)現(xiàn)。

觸摸反饋-onTouchEvent

這里觸摸反饋機(jī)制,使用到了GestureDetector這個(gè)類;這個(gè)類可以用來(lái)進(jìn)行手勢(shì)檢測(cè),用于輔助檢測(cè)用戶的單擊、滑動(dòng)、長(zhǎng)按、雙擊等行為。內(nèi)部提供了OnGestureListener、OnDoubleTapListener和OnContextClickListener三個(gè)接口,并提供了一系列的方法,比如常見的

  • onSingleTapUp : 手指輕觸屏幕離開
  • onScroll : 滑動(dòng)
  • onLongPress: 長(zhǎng)按
  • onFling: 按下后,快速滑動(dòng)松開(類似切水果的手勢(shì))
  • onDoubleTap : 雙擊

可以看到,使用這個(gè)類可以更加精確的處理手勢(shì)操作。

這里引入GestureDetector的原因是這樣的,單獨(dú)在onTouchEvent處理所有事件時(shí),在手指點(diǎn)擊屏幕的瞬間,很容易觸發(fā)MotionEvent.ACTION_MOVE事件,導(dǎo)致每次觸碰氣泡,被點(diǎn)擊氣泡的位置都會(huì)稍微顫抖一下,位置發(fā)生輕微的偏移,體驗(yàn)十分糟糕。采用GestureDetector對(duì)手指滑動(dòng)的處理,對(duì)點(diǎn)擊和滑動(dòng)的檢測(cè)顯得更加精確

@Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mValueAnimator != null) {
            if (mValueAnimator.isRunning()) {
                return false;
            }
        }
        m_gestureDetector.onTouchEvent(event);
        int lastX = (int) event.getX();
        int lastY = (int) event.getY();


        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            for (int i = 0; i < positions.length; i++) {
                int deltaX = positions[i].x - lastX;
                int deltaY = positions[i].y - lastY;

                // 手指 -- ACTION_DOWN 時(shí),落在了某一個(gè)氣泡上時(shí),刷新選中氣泡(球員)的bitmap
                if (Math.abs(deltaX) < playW / 2 && Math.abs(deltaY) < playW / 2) {
                    position = i;
                    currentPos = i;
                    invalidate();
                    moveEnable = true;
                    Log.e(TAG, "onTouchEvent: position= " + position);
                    return true;
                }


            }

            //沒(méi)有點(diǎn)擊中任意一個(gè)氣泡,點(diǎn)擊在外部是,重置氣泡(球員)狀態(tài)
            resetBubbleView();
            moveEnable = false;
            return false;
        }


        return super.onTouchEvent(event);

    }

這里m_gestureDetector.onTouchEvent(event),這樣就可以讓GestureDetector在他自己的回調(diào)方法OnGestureListener里,處理觸摸事件。

上面的邏輯很簡(jiǎn)單,動(dòng)畫正在進(jìn)行是,直接返回。MotionEvent.ACTION_DOWN事件發(fā)生時(shí)的處理邏輯,通過(guò)注釋很容易理解,就不再贅述。

當(dāng)我們點(diǎn)擊到某個(gè)氣泡時(shí),就獲取到了當(dāng)前選中位置currentPos;下面看看GestureDetector的回調(diào)方法,是怎樣處理滑動(dòng)事件的。

GestureDetector.OnGestureListener onGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (moveEnable) {
                positions[position].x -= distanceX;
                positions[position].y -= distanceY;


                //滑動(dòng)時(shí),考慮一下上下邊界的問(wèn)題,不要把球員移除場(chǎng)外
                // 橫向就不考慮了,因?yàn)榈讏D是3D 擺放的,上窄下寬,無(wú)法計(jì)算
                // 主要限制一下,縱向滑動(dòng)值
                if (positions[position].y < minY) {
                    positions[position].y = minY;
                } else if (positions[position].y > maxY) {
                    positions[position].y = maxY;
                }

                Log.e(TAG, "onScroll: y=" + positions[position].y);

                //跟隨手指,移動(dòng)氣泡(球員)
                invalidate();;
            }
            return true;
        }
    };

SimpleOnGestureListener 默認(rèn)實(shí)現(xiàn)了OnGestureListener,OnDoubleTapListener, OnContextClickListener這三個(gè)接口中所有的方法,因此非常方便我們使用GestureDetector進(jìn)行特定手勢(shì)的處理。

這里的處理很簡(jiǎn)單,當(dāng)氣泡被選中時(shí)moveEnable=true,通過(guò)onScroll回調(diào)方法返回的距離,不斷更新當(dāng)前位置的坐標(biāo),同時(shí)記得限制一下手勢(shì)滑動(dòng)的邊界,總不能把球員移動(dòng)到場(chǎng)地外面吧o(╯□╰)o,最后的postInvalidate()是關(guān)鍵,觸發(fā)onDraw方法,實(shí)現(xiàn)重新繪制。

這里有一個(gè)細(xì)節(jié),不知你發(fā)現(xiàn)沒(méi)有,我們?cè)诟伦鴺?biāo)的時(shí)候,每次都是在當(dāng)前坐標(biāo)的位置,減去了滑動(dòng)距離(distanceX/distanceY)。這是為什么(⊙o⊙)?,為什么不是加呢?

我們可以看看這個(gè)回調(diào)方法的定義

       /**
         * Notified when a scroll occurs with the initial on down {@link MotionEvent} and the
         * current move {@link MotionEvent}. The distance in x and y is also supplied for
         * convenience.
         *
         * @param e1 The first down motion event that started the scrolling.
         * @param e2 The move motion event that triggered the current onScroll.
         * @param distanceX The distance along the X axis that has been scrolled since the last
         *              call to onScroll. This is NOT the distance between {@code e1}
         *              and {@code e2}.
         * @param distanceY The distance along the Y axis that has been scrolled since the last
         *              call to onScroll. This is NOT the distance between {@code e1}
         *              and {@code e2}.
         * @return true if the event is consumed, else false
         */
        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

可以看到,這里特定強(qiáng)調(diào)了This is NOT the distance between {@code e1}and {@code e2},就是說(shuō)這個(gè)距離并不是兩次事件e1和e2 之間的距離。那么這個(gè)距離又是什么呢?那我們就找一找到底是在哪里觸發(fā)了這個(gè)回調(diào)方法.

最終在GestureDetector類的onTouchEvent()方法里找到了觸發(fā)這個(gè)方法發(fā)生的地方:

public boolean onTouchEvent(MotionEvent ev) {

    .....

        final boolean pointerUp =
                (action & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_UP;
        final int skipIndex = pointerUp ? ev.getActionIndex() : -1;

        // Determine focal point
        float sumX = 0, sumY = 0;
        final int count = ev.getPointerCount();
        for (int i = 0; i < count; i++) {
            if (skipIndex == i) continue;
            sumX += ev.getX(i);
            sumY += ev.getY(i);
        }
        final int div = pointerUp ? count - 1 : count;
        final float focusX = sumX / div;
        final float focusY = sumY / div;

        boolean handled = false;

        switch (action & MotionEvent.ACTION_MASK) {

        case MotionEvent.ACTION_MOVE:
            if (mInLongPress || mInContextClick) {
                break;
            }
            final float scrollX = mLastFocusX - focusX;
            final float scrollY = mLastFocusY - focusY;
            if (mIsDoubleTapping) {
                // Give the move events of the double-tap
                handled |= mDoubleTapListener.onDoubleTapEvent(ev);
            } else if (mAlwaysInTapRegion) {
                final int deltaX = (int) (focusX - mDownFocusX);
                final int deltaY = (int) (focusY - mDownFocusY);
                int distance = (deltaX * deltaX) + (deltaY * deltaY);
                if (distance > mTouchSlopSquare) {
                    handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
                    mLastFocusX = focusX;
                    mLastFocusY = focusY;
                    mAlwaysInTapRegion = false;
                    mHandler.removeMessages(TAP);
                    mHandler.removeMessages(SHOW_PRESS);
                    mHandler.removeMessages(LONG_PRESS);
                }
                if (distance > mDoubleTapTouchSlopSquare) {
                    mAlwaysInBiggerTapRegion = false;
                }
            } else if ((Math.abs(scrollX) >= 1) || (Math.abs(scrollY) >= 1)) {
                handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
                mLastFocusX = focusX;
                mLastFocusY = focusY;
            }
            break;

        
        return handled;
    }

這里還涉及到多指觸控的考慮,情況較為復(fù)雜;簡(jiǎn)單說(shuō)一下結(jié)論,在ACTION_MOVE時(shí),會(huì)從上一次手指離開的距離,減去此次手指觸碰的位置;這樣當(dāng)scrollX>0時(shí),就是在向右滑動(dòng),反之向左;scrollY > 0 時(shí),是在向上滑動(dòng),反之向下;因此,這兩個(gè)距離和我們習(xí)以為常的方向恰好都是相反的,因此,在更新坐標(biāo)時(shí),需要做相反的處理。

有興趣的同學(xué),可以把上面的“-”改成“+”,嘗試運(yùn)行一下代碼,就會(huì)明白其中的道理了。

好了,到了這里按照繪制,布局,觸摸反饋的順序我們已經(jīng)完成了BallGameView這個(gè)自定義View自己的內(nèi)容了,但是我們還看到在點(diǎn)擊下面的球員頭像時(shí),還有一個(gè)簡(jiǎn)單的動(dòng)畫,下面就看看動(dòng)畫是如何實(shí)現(xiàn)的。

動(dòng)畫效果

首先說(shuō)明一下,底部球員列表是一個(gè)橫向的RecyclerView,這樣一個(gè)橫向滑動(dòng)的雙列展示的RecyclerView 應(yīng)該很簡(jiǎn)單了,這里就不再詳述。文末有源碼,最后可以查看。

這里看一下每一個(gè)RecyclerView中item的點(diǎn)擊事件


@Override
    public void onRVItemClick(ViewGroup parent, View itemView, int position) {

        if (mPlayerBeanList.get(position).isSelected()) {
            Toast.makeText(mContext, "球員已被選擇!", Toast.LENGTH_SHORT).show();
        } else {
            View avatar = itemView.findViewById(R.id.img);
            int width = avatar.getWidth();
            int height = avatar.getHeight();
            Bitmap bitmap = Tools.View2Bitmap(avatar, width, height);
            int[] location = new int[2];
            itemView.getLocationOnScreen(location);
            if (bitmap != null) {
                mGameView.updatePlayer(bitmap, mPlayerBeanList.get(position).getName(), location, content);
            }

        }

    }

這里可以看到調(diào)用了GameView的updatePlayer方法:

/**
     * 在下方球員區(qū)域,選中球員后,根據(jù)位置執(zhí)行動(dòng)畫,將球員放置在選中的氣泡中
     *
     * @param bitmap      被選中球員bitmap
     * @param name        被選中球員名字
     * @param location    被選中球員在屏幕中位置
     * @param contentView 根視圖(方便實(shí)現(xiàn)動(dòng)畫)
     */
    public void updatePlayer(final Bitmap bitmap, final String name, int[] location, final ViewGroup contentView) {

        Path mPath = new Path();
        mPath.moveTo(location[0] + bitmap.getWidth() / 2, location[1] - bitmap.getHeight() / 2);
        mPath.lineTo(positions[currentPos].x - playW / 2, positions[currentPos].y - playW / 2);


        final ImageView animImage = new ImageView(getContext());
        animImage.setImageBitmap(bitmap);
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(120, 120);
        contentView.addView(animImage, params);


        final float[] animPositions = new float[2];
        final PathMeasure mPathMeasure = new PathMeasure(mPath, false);

        mValueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (float) animation.getAnimatedValue();
                mPathMeasure.getPosTan(value, animPositions, null);

                animImage.setTranslationX(animPositions[0]);
                animImage.setTranslationY(animPositions[1]);

            }
        });

        mValueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);

                contentView.removeView(animImage);

                players[currentPos].setBitmap(bitmap);
                players[currentPos].setSetReal(true);
                players[currentPos].setName(name);

                invalidate();


            }
        });
        mValueAnimator.setDuration(500);
        mValueAnimator.setInterpolator(new AccelerateInterpolator());
        mValueAnimator.start();


    }

這個(gè)動(dòng)畫,簡(jiǎn)單來(lái)說(shuō)就是一個(gè)一階貝塞爾曲線。根據(jù)RecyclerView中item在屏幕中的位置,構(gòu)造一個(gè)一模一樣的ImageView添加到根視圖中,然后通過(guò)一個(gè)屬性動(dòng)畫,在屬性值不斷更新時(shí),在回調(diào)方法中不斷調(diào)用setTranslation方法,改變這個(gè)ImageView的位置,呈現(xiàn)出動(dòng)畫的效果。動(dòng)畫結(jié)束后,將這個(gè)ImageView從視圖移除,同時(shí)氣泡中的數(shù)據(jù)即可,最后再次invalidate導(dǎo)致整個(gè)視圖重新繪制,這樣動(dòng)畫完成時(shí),氣泡就被替換為真實(shí)的頭像了。

到這里,基本上所有功能,都實(shí)現(xiàn)了。最后就是把自己排出來(lái)的陣型,保存為圖片分享給小伙伴了。這里主要說(shuō)一下保存圖片的實(shí)現(xiàn);分享功能,就不作為重點(diǎn)討論了。

自定義View保存為Bitmap

private class SavePicTask extends AsyncTask<Bitmap, Void, String> {

        @Override
        protected String doInBackground(Bitmap... params) {
            Bitmap mBitmap = params[0];
            String filePath = "";
            Calendar now = new GregorianCalendar();
            SimpleDateFormat simpleDate = new SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault());
            String fileName = simpleDate.format(now.getTime());
            //保存在應(yīng)用內(nèi)目錄,免去申請(qǐng)讀取權(quán)限的麻煩
            File mFile = new File(mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES), fileName + ".jpg");
            try {
                OutputStream mOutputStream = new FileOutputStream(mFile);
                mBitmap.compress(Bitmap.CompressFormat.JPEG, 100, mOutputStream);
                mOutputStream.flush();
                mOutputStream.close();
                filePath = mFile.getAbsolutePath();


            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }


            return filePath;
        }
    }

                mGameView.setDrawingCacheEnabled(true);
                Bitmap mBitmap = mGameView.getDrawingCache();

                if (mBitmap != null) {
                    new SavePicTask().execute(mBitmap);
                } else {
                    Toast.makeText(mContext, "fail", Toast.LENGTH_SHORT).show();
                }

一個(gè)典型的AsyncTask實(shí)現(xiàn),文件流的輸出,沒(méi)什么多說(shuō)的。主要是存儲(chǔ)目錄的選擇,這里有個(gè)技巧,如果沒(méi)有特殊限制,平時(shí)我們做開發(fā)的時(shí)候,可以 把一些存儲(chǔ)路徑做如下定義

  • mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES):代表/storage/emulated/0/Android/data/{packagname}/files/Pictures
  • mContext.getExternalCacheDir() 代表 /storage/emulated/0/Android/data/{packagname}/cache

對(duì)于mContext.getExternalFilesDir還可定義為Environment.DIRECTORY_DOWNLOADS,Environment.DIRECTORY_DOCUMENTS等目錄,對(duì)應(yīng)的文件夾名稱也會(huì)變化。

這個(gè)目錄中的內(nèi)容會(huì)隨著用戶卸載應(yīng)用,一并刪除。最重要的是,讀寫這個(gè)目錄是不需要權(quán)限的,因此省去了每次做權(quán)限判斷的麻煩,而且也避免了沒(méi)有權(quán)限時(shí)的窘境。

到這里,模仿功能,全部都實(shí)現(xiàn)了。下面稍微來(lái)一點(diǎn)額外的擴(kuò)展。

我們希望圖片保存后可以在通知欄提示用戶,點(diǎn)擊通知欄后可以通過(guò)手機(jī)相冊(cè)查看保存的圖片。

擴(kuò)展-Android Notification & FileProvider 的使用

private void SaveAndNotify() {
        if (!TextUtils.isEmpty(picUrl)) {
          
            NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(mContext);
            mBuilder.setWhen(System.currentTimeMillis())
                    .setTicker("下載圖片成功")
                    .setContentTitle("點(diǎn)擊查看")
                    .setSmallIcon(R.mipmap.app_start)
                    .setContentText("圖片保存在:" + picUrl)
                    .setAutoCancel(true)
                    .setOngoing(false);
            //通知默認(rèn)的聲音 震動(dòng) 呼吸燈
            mBuilder.setDefaults(NotificationCompat.DEFAULT_ALL);

            Intent mIntent = new Intent();
            mIntent.setAction(Intent.ACTION_VIEW);
            Uri contentUri;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                // 將文件轉(zhuǎn)換成content://Uri的形式
                contentUri = FileProvider.getUriForFile(mContext, getPackageName() + ".provider", new File(picUrl));
                // 申請(qǐng)臨時(shí)訪問(wèn)權(quán)限
                mIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            } else {
                contentUri = Uri.fromFile(new File(picUrl));
            }

            mIntent.setDataAndType(contentUri, "image/*");


            PendingIntent mPendingIntent = PendingIntent.getActivity(mContext
                    , 0, mIntent, PendingIntent.FLAG_UPDATE_CURRENT);
            mBuilder.setContentIntent(mPendingIntent);
            Notification mNotification = mBuilder.build();
            mNotification.flags |= Notification.FLAG_AUTO_CANCEL;
            NotificationManager mManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
            mManager.notify(0, mNotification);
        } else {
            T.showSToast(mContext, "圖片保存失敗");
        }
    }

Android 系統(tǒng)中的通知欄,隨著版本的升級(jí),已經(jīng)形成了固定了寫法,在Builder模式的基礎(chǔ)上,通過(guò)鏈?zhǔn)綄懛?,可以非常方便的設(shè)置各種屬性。這里重點(diǎn)說(shuō)一下PendingIntent的用法,我們知道這個(gè)PendingIntent 顧名思義,就是處于Pending狀態(tài),當(dāng)我們點(diǎn)擊通知欄,就會(huì)觸發(fā)他所包含的Intent。

嚴(yán)格來(lái)說(shuō),通過(guò)自己的應(yīng)用想用手機(jī)自帶相冊(cè)打開一張圖片是無(wú)法實(shí)現(xiàn)的,因?yàn)闊o(wú)法保證每一種手機(jī)上面相冊(cè)的包名是一樣的,因此這里我們創(chuàng)建ACTION=Intent.ACTION_VIEW的 Intent,去匹配系統(tǒng)所有符合這個(gè)Action 的Activity,系統(tǒng)相冊(cè)一定是其中之一。

到這里,還有一定需要注意,Android 7.0 開始,無(wú)法以file://xxxx 形式向外部應(yīng)用提供內(nèi)容了,因此需要考慮使用FileProvider。當(dāng)然,對(duì)這個(gè)問(wèn)題,Google官方提供了完整的使用實(shí)例,實(shí)現(xiàn)起來(lái)都是套路,沒(méi)有什么特別之處。

重點(diǎn)記住下面的對(duì)應(yīng)關(guān)系即可:

 <root-path/> 代表設(shè)備的根目錄new File("/");
 <files-path/> 代表context.getFilesDir()
 <cache-path/> 代表context.getCacheDir()
 <external-path/> 代表Environment.getExternalStorageDirectory()
 <external-files-path>代表context.getExternalFilesDirs()
 <external-cache-path>代表getExternalCacheDirs()

按照上面,我們存儲(chǔ)圖片的目錄,我們?cè)趂ile_path.xml 做如下定義即可:


<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="root"
        path=""/>
</paths>

在AndroidManifest中完成如下配置 :

        <!-- Android 7.0 FileUriExposedException -->
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_path"/>
        </provider>

這樣,當(dāng)Build.VERSION.SDK_INT大于等于24及Android7.0時(shí),可以安心的使用FileProvider來(lái)和外部應(yīng)用共享文件了。

最后

好了,從一個(gè)簡(jiǎn)單的自定義View 出發(fā),又牽出了一大堆周邊的內(nèi)容。好在,總算完整的說(shuō)完了。

特別申明

以上代碼中所用到的圖片資源,全部源自懂球帝APP內(nèi);此處對(duì)應(yīng)用解包,只是本著學(xué)習(xí)的目的,沒(méi)有其他任何用意。


源碼地址: Github-AndroidAnimationExercise。

有興趣的同學(xué)歡迎 star & fork。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評(píng)論 6 543
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,559評(píng)論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評(píng)論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評(píng)論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,581評(píng)論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,922評(píng)論 1 328
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評(píng)論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,096評(píng)論 0 290
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,639評(píng)論 1 336
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,374評(píng)論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,591評(píng)論 1 374
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評(píng)論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,789評(píng)論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評(píng)論 1 295
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,322評(píng)論 3 400
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,554評(píng)論 2 379

推薦閱讀更多精彩內(nèi)容

  • 作者:IAM四十二鏈接:http://www.lxweimin.com/p/d06c1d10bf7f著作權(quán)歸作者所有...
    passiontim閱讀 959評(píng)論 0 0
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,740評(píng)論 25 708
  • 一、Android開發(fā)初體驗(yàn) 二、Android與MVC設(shè)計(jì)模式模型對(duì)象存儲(chǔ)著應(yīng)用的數(shù)據(jù)和業(yè)務(wù)邏輯。模型類通常用來(lái)...
    為夢(mèng)想戰(zhàn)斗閱讀 908評(píng)論 0 3
  • 朋友A才貌雙全,可謂女中豪杰,但年過(guò)三旬仍獨(dú)守閨閣,讓身別不少的人大跌眼鏡。 昨日女神節(jié)幾閨蜜與A小聚,言及婚嫁之...
    千樹花閱讀 435評(píng)論 10 9
  • 1.簽協(xié)議 2.創(chuàng)建webView pragma mark -- 開始加載 pragma mark -- 結(jié)束加載
    李毅然閱讀 445評(píng)論 0 3