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

IAM四十二發表於2017-07-23

前言

這幾天很多歐洲球隊來中國進行熱身賽,不知道喜歡足球的各位小夥伴們有沒有看球。喜歡足球的朋友可能知道懂球帝APP,鄙人也經常使用這個應用,裡面有一個我是教練的功能挺好玩,就是可以模擬教練員的身份,排兵佈陣;本著好奇心簡單模仿了一下,在這裡和大家分享。

效果圖

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

add_player
add_player

move_player
move_player

玩過我是教練這個功能的小夥伴可以對比一下。

總的來說,這樣的一個效果,其實很簡單,就是一個view隨著手指在螢幕上移動的效果,外加一個圖片替換的動畫。但就是這些看似簡單的效果,在實現的過程中也是遇到了很多坑,漲了許多新姿勢。好了,廢話不說,程式碼走起(。◕ˇ∀ˇ◕)。

自定義View-BallGameView

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

說到自定義View,老生常談,大家一直都在學習,卻永遠都覺得自己沒有學會,但是自定義View的知識本來就很多呀,想要熟練掌握,必須假以時日

既然是自定View就從大家最關心的兩個方法 onMeasure和onDraw 兩個方法說起。這裡由於是純粹繼承自View,就不考慮onLayout的實現了。

測量-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()方法的實現很簡單,簡單的用螢幕的寬度規定了整個View 的寬高;至於1.3這個倍數,完全一個估算值,不必深究。

繪製-onDraw

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

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //繪製背景
        canvas.drawBitmap(backgroundBitmap, bitmapRect, mViewRect, mPaint);
        //繪製提示文字透明背景
        canvas.drawRoundRect(mRoundRect, 8, 8, mRectPaint);
        //繪製底部提示文字 ( TextPiant 文字垂直居中實現 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個氣泡
        for (int i = 0; i < players.length; i++) {
            //繪製當前選中的球員
            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);
                    //繪製已設定正常圖片球員背景
                    canvas.drawBitmap(playeBgBitmap, positions[i].x - grayW / 2,
                            positions[i].y + 200, mPaint);
                }
            }
        }
    }複製程式碼

可以看到,在onDraw方法裡,我們主要使用了canvas.drawBitmap 方法,繪製了很多圖片。下面就簡單瞭解一下canvas.drawBitmap 裡的兩個過載方法。

  • 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),這個過載方法主要是通過兩個Rectangle 決定了bitmap以怎樣的形式繪製出來。簡單來說,src 這個長方形決定了“擷取”bitmap的大小,dst 決定了最終繪製出來時Bitmap應該佔有的大小。。就拿上面的程式碼來說

        backgroundBitmap = BitmapFactory.decodeResource(res, R.drawable.battle_bg);
        //確保整張背景圖,都能完整的顯示出來
        bitmapRect = new Rect(0, 0, backgroundBitmap.getWidth(), backgroundBitmap.getHeight());
        //目標區域,在整個檢視的大小中,繪製Bitmap
        mViewRect = new Rect(0, 0, viewW, viewH);
        //繪製背景
        canvas.drawBitmap(backgroundBitmap, bitmapRect, mViewRect, mPaint);複製程式碼

bitmapRect 是整個backgroundBitmap的大小,mViewRect也就是我們在onMeasure裡規定的整個檢視的大小,這樣相當於把battle_bg這張圖片,以scaleType="fitXY"的形式畫在了檢視大小的區域內。這樣,你應該理解這個過載方法的含義了。

  • 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) {

    }複製程式碼

這個過載方法應該很容易理解了,left,top 規定了繪製Bitmap的左上角的座標,然後按照其大小正常繪製即可。

這裡我們所有的氣泡(球員位置)都是使用這個方法繪製的。足球場上有11個球員,因此我們通過陣列預先定義了11個氣泡的初始位置,然後通過其座標位置,繪製他們。為了繪製精確,需要減去每張圖片自身的寬高,這應該是很傳統的做法了。

同時,在之後的觸控反饋機制中,我們會根據手指的滑動,修改這些座標值,這樣就可以隨意移動球員在場上的位置了;具體實現,結合程式碼中的註釋應該很容易理解了,就不再贅述;可以檢視完整原始碼BallGameView

文字居中繪製

這裡再說一個在繪製過程中遇到一個小問題,可以看到在整個檢視底部,繪製了一個半透明的圓角矩形,並在他上面繪製了一行黃色的文字,這行文字在水平和垂直方向都是居中的;使用TextPaint 繪製文字實現水平居中是很容易的事情,只需要設定mTipPaint.setTextAlign(Paint.Align.CENTER)即可,但是在垂直方向實現居中,就沒那麼簡單了,這裡需要考慮一個文字繪製時基線的問題,具體細節可以參考這篇文章,分析的很詳細。

我們在這裡為了使文字在圓角矩形中居中,如下實現。

        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);複製程式碼

圓角矩形的垂直中心點的基礎上,再一次做修正,確保實現真正的垂直居中。

好了,結合扔物線大神所總結的自定義View關鍵步驟,以上兩點算是完成了繪製和佈局的工作,下面就看看觸控反饋的實現。

觸控反饋-onTouchEvent

這裡觸控反饋機制,使用到了GestureDetector這個類;這個類可以用來進行手勢檢測,用於輔助檢測使用者的單擊、滑動、長按、雙擊等行為。內部提供了OnGestureListener、OnDoubleTapListener和OnContextClickListener三個介面,並提供了一系列的方法,比如常見的

  • onSingleTapUp : 手指輕觸螢幕離開
  • onScroll : 滑動
  • onLongPress: 長按
  • onFling: 按下後,快速滑動鬆開(類似切水果的手勢)
  • onDoubleTap : 雙擊

可以看到,使用這個類可以更加精確的處理手勢操作。

這裡引入GestureDetector的原因是這樣的,單獨在onTouchEvent處理所有事件時,在手指點選螢幕的瞬間,很容易觸發MotionEvent.ACTION_MOVE事件,導致每次觸碰氣泡,被點選氣泡的位置都會稍微顫抖一下,位置發生輕微的偏移,體驗十分糟糕。採用GestureDetector對手指滑動的處理,對點選和滑動的檢測顯得更加精確

@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 時,落在了某一個氣泡上時,重新整理選中氣泡(球員)的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;
                }


            }

            //沒有點選中任意一個氣泡,點選在外部是,重置氣泡(球員)狀態
            resetBubbleView();
            moveEnable = false;
            return false;
        }


        return super.onTouchEvent(event);

    }複製程式碼

這裡m_gestureDetector.onTouchEvent(event),這樣就可以讓GestureDetector在他自己的回撥方法OnGestureListener裡,處理觸控事件。

上面的邏輯很簡單,動畫正在進行是,直接返回。MotionEvent.ACTION_DOWN事件發生時的處理邏輯,通過註釋很容易理解,就不再贅述。

當我們點選到某個氣泡時,就獲取到了當前選中位置currentPos;下面看看GestureDetector的回撥方法,是怎樣處理滑動事件的。

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;


                //滑動時,考慮一下上下邊界的問題,不要把球員移除場外
                // 橫向就不考慮了,因為底圖是3D 擺放的,上窄下寬,無法計算
                // 主要限制一下,縱向滑動值
                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);

                //跟隨手指,移動氣泡(球員)
                invalidate();;
            }
            return true;
        }
    };複製程式碼

SimpleOnGestureListener 預設實現了OnGestureListener,OnDoubleTapListener, OnContextClickListener這三個介面中所有的方法,因此非常方便我們使用GestureDetector進行特定手勢的處理。

這裡的處理很簡單,當氣泡被選中時moveEnable=true,通過onScroll回撥方法返回的距離,不斷更新當前位置的座標,同時記得限制一下手勢滑動的邊界,總不能把球員移動到場地外面吧o(╯□╰)o,最後的postInvalidate()是關鍵,觸發onDraw方法,實現重新繪製。

這裡有一個細節,不知你發現沒有,我們在更新座標的時候,每次都是在當前座標的位置,減去了滑動距離(distanceX/distanceY)。這是為什麼(⊙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);複製程式碼

可以看到,這裡特定強調了This is NOT the distance between {@code e1}and {@code e2},就是說這個距離並不是兩次事件e1和e2 之間的距離。那麼這個距離又是什麼呢?那我們就找一找到底是在哪裡觸發了這個回撥方法.

最終在GestureDetector類的onTouchEvent()方法裡找到了觸發這個方法發生的地方:

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;
    }複製程式碼

這裡還涉及到多指觸控的考慮,情況較為複雜;簡單說一下結論,在ACTION_MOVE時,會從上一次手指離開的距離,減去此次手指觸碰的位置;這樣當scrollX>0時,就是在向右滑動,反之向左;scrollY > 0 時,是在向上滑動,反之向下;因此,這兩個距離和我們習以為常的方向恰好都是相反的,因此,在更新座標時,需要做相反的處理。

有興趣的同學,可以把上面的“-”改成“+”,嘗試執行一下程式碼,就會明白其中的道理了。

好了,到了這裡按照繪製,佈局,觸控反饋的順序我們已經完成了BallGameView這個自定義View自己的內容了,但是我們還看到在點選下面的球員頭像時,還有一個簡單的動畫,下面就看看動畫是如何實現的。

動畫效果

首先說明一下,底部球員列表是一個橫向的RecyclerView,這樣一個橫向滑動的雙列展示的RecyclerView 應該很簡單了,這裡就不再詳述。文末有原始碼,最後可以檢視。

這裡看一下每一個RecyclerView中item的點選事件


@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);
            }

        }

    }複製程式碼

這裡可以看到呼叫了GameView的updatePlayer方法:

/**
     * 在下方球員區域,選中球員後,根據位置執行動畫,將球員放置在選中的氣泡中
     *
     * @param bitmap      被選中球員bitmap
     * @param name        被選中球員名字
     * @param location    被選中球員在螢幕中位置
     * @param contentView 根檢視(方便實現動畫)
     */
    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();


    }複製程式碼

這個動畫,簡單來說就是一個一階貝塞爾曲線。根據RecyclerView中item在螢幕中的位置,構造一個一模一樣的ImageView新增到根檢視中,然後通過一個屬性動畫,在屬性值不斷更新時,在回撥方法中不斷呼叫setTranslation方法,改變這個ImageView的位置,呈現出動畫的效果。動畫結束後,將這個ImageView從檢視移除,同時氣泡中的資料即可,最後再次invalidate導致整個檢視重新繪製,這樣動畫完成時,氣泡就被替換為真實的頭像了。

到這裡,基本上所有功能,都實現了。最後就是把自己排出來的陣型,儲存為圖片分享給小夥伴了。這裡主要說一下儲存圖片的實現;分享功能,就不作為重點討論了。

自定義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());
            //儲存在應用內目錄,免去申請讀取許可權的麻煩
            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();
                }複製程式碼

一個典型的AsyncTask實現,檔案流的輸出,沒什麼多說的。主要是儲存目錄的選擇,這裡有個技巧,如果沒有特殊限制,平時我們做開發的時候,可以 把一些儲存路徑做如下定義

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

對於mContext.getExternalFilesDir還可定義為Environment.DIRECTORY_DOWNLOADS,Environment.DIRECTORY_DOCUMENTS等目錄,對應的資料夾名稱也會變化。

這個目錄中的內容會隨著使用者解除安裝應用,一併刪除。最重要的是,讀寫這個目錄是不需要許可權的,因此省去了每次做許可權判斷的麻煩,而且也避免了沒有許可權時的窘境

到這裡,模仿功能,全部都實現了。下面稍微來一點額外的擴充套件。

我們希望圖片儲存後可以在通知欄提示使用者,點選通知欄後可以通過手機相簿檢視儲存的圖片。

擴充套件-Android Notification & FileProvider 的使用

private void SaveAndNotify() {
        if (!TextUtils.isEmpty(picUrl)) {

            NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(mContext);
            mBuilder.setWhen(System.currentTimeMillis())
                    .setTicker("下載圖片成功")
                    .setContentTitle("點選檢視")
                    .setSmallIcon(R.mipmap.app_start)
                    .setContentText("圖片儲存在:" + picUrl)
                    .setAutoCancel(true)
                    .setOngoing(false);
            //通知預設的聲音 震動 呼吸燈
            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) {
                // 將檔案轉換成content://Uri的形式
                contentUri = FileProvider.getUriForFile(mContext, getPackageName() + ".provider", new File(picUrl));
                // 申請臨時訪問許可權
                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 系統中的通知欄,隨著版本的升級,已經形成了固定了寫法,在Builder模式的基礎上,通過鏈式寫法,可以非常方便的設定各種屬性。這裡重點說一下PendingIntent的用法,我們知道這個PendingIntent 顧名思義,就是處於Pending狀態,當我們點選通知欄,就會觸發他所包含的Intent。

嚴格來說,通過自己的應用想用手機自帶相簿開啟一張圖片是無法實現的,因為無法保證每一種手機上面相簿的包名是一樣的,因此這裡我們建立ACTION=Intent.ACTION_VIEW的 Intent,去匹配系統所有符合這個Action 的Activity,系統相簿一定是其中之一。

到這裡,還有一定需要注意,Android 7.0 開始,無法以file://xxxx 形式向外部應用提供內容了,因此需要考慮使用FileProvider。當然,對這個問題,Google官方提供了完整的使用例項,實現起來都是套路,沒有什麼特別之處。

重點記住下面的對應關係即可:

 <root-path/> 代表裝置的根目錄new File("/");
 <files-path/> 代表context.getFilesDir()
 <cache-path/> 代表context.getCacheDir()
 <external-path/> 代表Environment.getExternalStorageDirectory()
 <external-files-path>代表context.getExternalFilesDirs()
 <external-cache-path>代表getExternalCacheDirs()複製程式碼

按照上面,我們儲存圖片的目錄,我們在file_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>複製程式碼

這樣,當Build.VERSION.SDK_INT大於等於24及Android7.0時,可以安心的使用FileProvider來和外部應用共享檔案了。

最後

好了,從一個簡單的自定義View 出發,又牽出了一大堆周邊的內容。好在,總算完整的說完了。

特別申明

以上程式碼中所用到的圖片資源,全部源自懂球帝APP內;此處對應用解包,只是本著學習的目的,沒有其他任何用意。


原始碼地址: Github-AndroidAnimationExercise

有興趣的同學歡迎 star & fork。

相關文章