前言
這幾天很多歐洲球隊來中國進行熱身賽,不知道喜歡足球的各位小夥伴們有沒有看球。喜歡足球的朋友可能知道懂球帝APP,鄙人也經常使用這個應用,裡面有一個我是教練的功能挺好玩,就是可以模擬教練員的身份,排兵佈陣;本著好奇心簡單模仿了一下,在這裡和大家分享。
效果圖
老規矩,先上效果圖看看模仿的像不。
玩過我是教練這個功能的小夥伴可以對比一下。
總的來說,這樣的一個效果,其實很簡單,就是一個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。