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做拍照预览和拍照(也许会添加些许常用滤镜)。目前已加入调用系统相机拍照功能(与微信相同),自定义拍照(新浪)将在后续增加。实现的相关细节也会在后续单独写博客来介绍。


欢迎转载,转载请保留文章出处。湖广午王的博客[http://blog.csdn.net/junzia/article/details/53091606]


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值