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