1. 程式人生 > >Android 仿QQ、新浪相簿的實現

Android 仿QQ、新浪相簿的實現

在移動應用中,很多時候都會用到圖片選擇、圖片裁剪等功能。最近我也在準備一個開源的相簿專案,以方便以後開發應用的時候使用,也儘可能的方便需要的人。一個完整的相簿,應該包含相簿列表、圖片列表、圖片的單選和多選、圖片的裁剪、拍照、多選圖片的大圖預覽等功能。這也是我這個專案將要包含的功能。在本篇部落格中,將會講述下我在這個專案中相簿列表和圖片列表的大致實現。

實現效果

結合幾個常用的APP中的相簿效果,當前專案中已經實現了一些基本的功能和UI,在後續完善的過程中還會有所變動。專案在Github上開源,歡迎fork和star。先展示實現的效果(後面會增加拍照功能):
單選效果 單選未選擇時的效果 單選已選擇的效果

相簿列表效果 裁剪效果

功能分析

在實現相簿功能之前,我們先需要明確它的邏輯。參照QQ、新浪、微博這中巨頭級的APP,當我們需要用選擇圖片時,會先開啟相簿,獲取到最新的照片列表。然後點選一個按鈕可以展開相簿列表,點選列表內容,可以切換相簿,重新整理當前照片列表中的內容。而且選擇這篇的時候,會有單選、多選、單選並裁剪等情況,多選的時候還要出現選擇效果和指示器等,單選的時候如果需要裁剪則進入裁剪頁,不裁剪則預設確定選擇,(拍照功能在後續部落格中再說明)。
這樣,我們就可以明確我們需要實現的功能有:

  1. 獲取手機中的最新圖片
  2. 獲取手機中的相簿列表
  3. 獲取制定相簿中的所有圖片
  4. 展示圖片和相簿
  5. 多圖選擇時需要有選擇效果和指示器
  6. 單選裁剪時需要用到裁剪功能

另外,掃描手機中的圖片也是一個相對耗時的工作,所以這個工作還需要主要避免放到主執行緒中。

準備資料

為了使用方便,我們可以將相簿列表的查詢、制定相簿的查詢、最新圖片的查詢都放到一個工具類中,主要工具類程式碼如下:

public class AlbumTool {

    private Handler handler;
    //private Semaphore semaphore;
    private Callback callback;
    private Context context;

    private final int TYPE_FOLDER=1
; private final int TYPE_ALBUM=2; public AlbumTool(Context context){ this.context=context; handler=new Handler(Looper.getMainLooper()){ @Override public void handleMessage(Message msg) { if(callback!=null){ switch (msg.what){ case TYPE_FOLDER: callback.onFolderFinish((ImageFolder) msg.obj); break; case TYPE_ALBUM: callback.onAlbumFinish((ArrayList<ImageFolder>) msg.obj); break; } } super.handleMessage(msg); } }; } public void setCallback(Callback callback){ this.callback=callback; } public void findAlbumsAsync(){ new Thread(new Runnable() { @Override public void run() { getAlbums(context); } }).start(); } public void findFolderAsync(final ImageFolder folder){ new Thread(new Runnable() { @Override public void run() { getFolder(context,folder); } }).start(); } //獲取所有圖片集 private ArrayList<ImageFolder> getAlbums(Context context) { ArrayList<ImageFolder> albums=new ArrayList<>(); albums.add(getNewestPhotos(context)); //利用ContentResolver查詢資料庫,找出所有包含圖片的資料夾,儲存到相簿列表中 ContentResolver resolver = context.getContentResolver(); Cursor cursor = resolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{ MediaStore.Images.Media.DATA, MediaStore.Images.ImageColumns.BUCKET_ID, MediaStore.Images.Media.DATE_MODIFIED, "count(*) as count" }, MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=?) " + "group by (" + MediaStore.Images.ImageColumns.BUCKET_ID, new String[]{"image/jpeg", "image/png", "image/jpg"}, MediaStore.Images.Media.DATE_MODIFIED + " desc"); if (cursor != null) { while (cursor.moveToNext()) { final File file = new File(cursor.getString(0)); ImageFolder imageFolder = new ImageFolder(); imageFolder.setDir(file.getParent()); imageFolder.setId(cursor.getString(1)); imageFolder.setFirstImagePath(cursor.getString(0)); String[] all=file.getParentFile().list(new FilenameFilter() { private boolean e(String filename,String ends){ return filename.toLowerCase().endsWith(ends); } @Override public boolean accept(File dir, String filename) { return e(filename,".png") || e(filename,".jpg") || e(filename,"jpeg"); } }); if(all!=null&&all.length>0){ imageFolder.setCount(all.length); albums.add(imageFolder); } } cursor.close(); } sendMessage(TYPE_ALBUM,albums); return albums; } //獲取《最新圖片》集 private ImageFolder getNewestPhotos(Context context) { ImageFolder newestFolder=new ImageFolder(); newestFolder.setName(ChooserSetting.newestAlbumName); ArrayList<ImageInfo> imageBeans = new ArrayList<>(); ContentResolver resolver = context.getContentResolver(); Cursor cursor = resolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{ MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_MODIFIED, }, MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=?", new String[]{"image/jpeg", "image/png", "image/jpg"}, MediaStore.Images.Media.DATE_MODIFIED + " desc" + (ChooserSetting.newestAlbumSize < 0 ? "" : (" limit " + ChooserSetting.newestAlbumSize))); if (cursor != null){ while (cursor.moveToNext()) { ImageInfo info=new ImageInfo(); info.path=cursor.getString(0); info.displayName=cursor.getString(1); info.time=cursor.getLong(2); imageBeans.add(info); } cursor.close(); newestFolder.setFirstImagePath(imageBeans.get(0).path); newestFolder.setDatas(imageBeans); newestFolder.setCount(imageBeans.size()); } sendMessage(TYPE_FOLDER,newestFolder); return newestFolder; } //獲取具體圖片集,確保圖片資料已被查詢 private ImageFolder getFolder(Context context,ImageFolder folder) { ContentResolver resolver = context.getContentResolver(); Cursor cursor; if(folder!=null&&folder.getDatas()!=null&&folder.getDatas().size()>0){ sendMessage(TYPE_FOLDER,folder); return folder; } if (folder == null) { return getNewestPhotos(context); } else { cursor = resolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{ MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_MODIFIED }, MediaStore.Images.ImageColumns.BUCKET_ID + "=? and (" + MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=?) ", new String[]{folder.getId(), "image/jpeg", "image/png", "image/jpg"}, MediaStore.Images.Media.DATE_MODIFIED + " desc"); } ArrayList<ImageInfo> datas=new ArrayList<>(); folder.setDatas(datas); if (cursor != null){ while (cursor.moveToNext()) { ImageInfo info=new ImageInfo(); info.path=cursor.getString(0); info.displayName=cursor.getString(1); info.time=cursor.getLong(2); datas.add(info); } cursor.close(); } sendMessage(TYPE_FOLDER,folder); return folder; } private void sendMessage(int what,Object obj){ Message msg=new Message(); msg.what=what; msg.obj=obj; handler.sendMessage(msg); } public interface Callback{ //資料夾查詢完畢 void onFolderFinish(ImageFolder folder); //成功搜尋出所有的圖片集 void onAlbumFinish(ArrayList<ImageFolder> albums); } }

這樣,我們就可以利用這個工具類方便的獲取相簿列表、獲取制定相簿的圖片了(最新照片合集當做是一個相簿)。裡面主要就是使用ContentResolver來做查詢,Android入門級問題,四大元件——Activity、Service、ContentProvider和BroadcastReceiver,中的ContentProvider和ContentResolver就是一對CP了,ContentProvider用來提供資料,ContentResolver用來獲取資料。

展示相簿和相簿列表

有了獲取相簿列表和獲取指定相簿的方法,展示相簿和相簿列表就容易了,按照通常的方式,我們直接使用GridView來展示相簿,用ListView來展示相簿列表。當然,你也可以選擇使用RecyclerView來替代掉GridView和ListView,其實也都一樣。
顯示圖片直接使用成熟的第三方框架即可,我使用的是Glide。
值得注意的是,在相簿中,我們展示出來的圖片都是正方塊、並且需要三個(你也可以設定四個或者五個,只要你高興)鋪滿寬度。在這裡我使用的是比較懶的方式,直接用一個自定義的佈局作為Item的跟佈局,這個自定義佈局繼承RelativeLayout,然後將複寫它的onMeasure方法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}

心有多懶,人就能有多懶。這樣它的高度就被強制保持為何寬度一致了。

選擇指示器

像QQ中,選擇圖片時,圖片會根據選擇的順序,在圖片上的那個圈圈裡面顯示出1234……等數字,然後取消選擇時,被選的數字會順序補位,比如你選了七張圖片、然後取消了顯示數字3的那張,這時4就變成3了、5變成了4、6變成了5。
像新浪微博中的圖片選擇,不會出現數字,而是出現一個勾,選中的時候這個勾還有動畫效果。
這樣的功能怎麼實現呢?
我實現的方式是,在每個Item中都有一個固定大小的View,根據圖片是否被選中,載入不同的Drawable。當然,寫這個專案既然是為了以後在不同的專案中使用,這個自然要方便被使用者自行設定。所以我寫一個抽象類:

public abstract class IChooseDrawable{

    private Paint paint;
    protected int width=0;
    protected int height=0;

    private SparseArray<Drawable> drawables;

    public IChooseDrawable(){
        paint=new Paint();
        paint.setAntiAlias(true);
        paint.setColor(0x88000000);
        drawables=new SparseArray<>();
    }

    public Drawable get(int state){
        if(drawables.indexOfKey(state)>=0){
            return drawables.get(state);
        }else{
            InDrawable drawable=new InDrawable(state);
            drawables.put(state,drawable);
            return drawable;
        }
    }

    public void clear(){
        drawables.clear();
    }

    public int getBaseline(Paint paint,int top,int bottom){
        Paint.FontMetrics i=paint.getFontMetrics();
        return (int) ((bottom+top-i.top-i.bottom)/2);
    }

    //state表示第幾個被選擇,0表示未選中
    public abstract void draw(Canvas canvas,Paint paint,int state);

    private class InDrawable extends Drawable{

        private int state=0;

        InDrawable(int state){
            this.state=state;
        }

        @Override
        public void draw(@NonNull Canvas canvas) {
            IChooseDrawable.this.draw(canvas,paint,state);
        }

        @Override
        public void setAlpha(int alpha) {

        }

        @Override
        public void setColorFilter(ColorFilter colorFilter) {

        }

        @Override
        public int getOpacity() {
            return PixelFormat.TRANSPARENT;
        }
    }
}

在相簿的Adapter的建構函式中會傳入一個IChooseDrawable實體,在顯示每個Item時,會根據當前狀態通過drawable.get(int state)取得指定的Drawable,設定為指示器View的背景。
上面效果圖中的指示器(也可配置為只顯示對號)實現為:

public class CircleChooseDrawable extends IChooseDrawable {

    private boolean isShowNum=true;
    private int chooseBgColor=0xFFFF6600;
    private Path path;

    public CircleChooseDrawable(){
        super();
    }

    public CircleChooseDrawable(boolean isShowNum,int chooseBgColor){
        super();
        this.isShowNum=isShowNum;
        this.chooseBgColor=chooseBgColor;
    }

    @Override
    public void draw(Canvas canvas, Paint paint, int state) {
        width=canvas.getWidth();
        height=canvas.getHeight();
        if(state==0){  //未選擇狀態
            paint.setColor(0x55000000);
            paint.setStyle(Paint.Style.FILL);
            canvas.drawCircle(width/2,height/2,width/2-2,paint);
            paint.setColor(0xDDFFFFFF);
            paint.setStrokeWidth(2);
            paint.setStyle(Paint.Style.STROKE);
            canvas.drawCircle(width/2,height/2,width/2-2,paint);
        }else{  //選中狀態
            paint.setColor(chooseBgColor);
            paint.setStyle(Paint.Style.FILL);
            canvas.drawCircle(width/2,height/2,width/2-2,paint);
            paint.setColor(0xDDFFFFFF);
            paint.setStrokeWidth(2);
            paint.setStyle(Paint.Style.STROKE);
            canvas.drawCircle(width/2,height/2,width/2-2,paint);
            paint.setColor(0xDDFFFFFF);
            if(isShowNum){    //顯示數字
                paint.setStyle(Paint.Style.FILL);
                paint.setTextAlign(Paint.Align.CENTER);
                paint.setTextSize(width*0.53f);
                canvas.drawText(state+"",width/2,getBaseline(paint,0,height),paint);
            }else{    //顯示一個√號
                paint.setStyle(Paint.Style.STROKE);
                paint.setStrokeWidth(3);
                paint.setStrokeCap(Paint.Cap.ROUND);
                if(path==null){
                    path=new Path();
                    path.moveTo(width/4f,height/2f);
                    path.lineTo(width*2/5f,height*5/7f);
                    path.lineTo(width*3/4f,height/3f);
                }
                canvas.drawPath(path,paint);
            }
        }
    }
}

裁剪、單選和多選

單選和多選的區別在於單選的時候,沒有選擇指示器,選中直接攜帶資料返回。而多選時,有選擇指示器,選擇完成後,需要確定後攜帶資料返回,在確定前可以取消之前所選的內容。
所以實現的時候,只需要判斷使用者傳入的選擇意圖,做出相應的處理。如果是裁剪,則選擇一張圖片後,進入到裁剪頁面,裁剪結束後攜帶裁剪結果返回到進入到相簿前的頁面。如果是單選,則選擇一張圖片後,直接攜帶資料返回到進入相簿前的頁面。如果是多選,則要在點選確認按鈕後,攜帶資料返回到進入相簿前的頁面。裁剪的實現見上一篇部落格——Android 圖片裁剪

其他

其他的一些功能,主要是拍照的功能、和大圖切換預覽現在還未新增進專案中,目前準備是利用OpenGl做拍照預覽和拍照(也許會新增些許常用濾鏡)。目前已加入呼叫系統相機拍照功能(與微信相同),自定義拍照(新浪)將在後續增加。實現的相關細節也會在後續單獨寫部落格來介紹。