自制照片选择器
Android 从 11 版本后提供了照片选择器
看起来确实不错,本来想直接调用 Android 提供的照片选择器的,不用自己再去做缩略图缓存也不用处理麻烦的 API 结果定睛一看发现几个大问题
- Android 提供的照片选择器必须升级 App 的 androidx.activity 库到 1.7.0 版本,这可能意味着 app 的 targetSdkVersion 也得升级,同时需要处理好其他兼容性问题;
- Android 提供的照片选择器仅限搭载 Android 11(API 级别 30)或更高版本使用,其他的版本需要通过 Google 系统更新接收对模块化系统组件的更改,如果在低版本使用可能会调用 ACTION_OPEN_DOCUMENT 的 intent 操作来实现,这意味着很多现在例如限制选择几张照片可能不生效,这与需求严重不符。
- 能从网上找到的资料可以发现 Android 提供的照片选择器的 API 在变化,实际使用确实很难受。
综上,还不如自己做一个咯🤷♂️
开始动手
UI
UI 方面就照着 Google 的抄就好,图片加载用 Glide 来完成,参考微信的照片选择一列默认显示 4 个缩略图就好,然后用 RecyclerView 实现网格状列表容器,基于 DialogX 的 FullScreenDialog 对话框打底实现 activity 界面下沉效果以及从屏幕底部上移的对话框,准备就绪,开干!
复写 RecyclerView.Adapter 实现 PhotoAdapter,在其中用 Glide 加载照片并 override 尺寸进行加载和缓存以避免界面卡顿:
scss
Glide.with(context)
.load(imageUrls.get(position))
.override(imageSize)
.error(errorPhotoDrawableRes)
.into((PhotoSelectImageView) holder.itemView);
当照片被选中时,为了实现选中状态的图片缩小,增加边框和对钩图示,自定义了一个 PhotoSelectImageView 作为缩略图呈现使用,图片缩小效果直接用 padding 实现,边框绘制代码:
scss
canvas.drawRect(0 + getBorderWidth() / 2, 0 + getBorderWidth() / 2, getWidth() - getBorderWidth() / 2, getHeight() - getBorderWidth() / 2, paint);
图库部分带圆角,边框的绘制代码调整为:
scss
RectF rect = new RectF(0 + getBorderWidth() / 2, 0 + getBorderWidth() / 2, getWidth() - getBorderWidth() / 2, getHeight() - getBorderWidth() / 2);
canvas.drawRoundRect(rect, radius, radius, paint);
最后绘制标记:
less
//init 初始化部分代码:
//从图片资源加载
selectFlagBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.album_dialog_img_selected);
//按照主题色染色
Bitmap tintedBitmap = Bitmap.createBitmap(selectFlagBitmap.getWidth(), selectFlagBitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(tintedBitmap);
Paint paint = new Paint();
paint.setColorFilter(new PorterDuffColorFilter(getResources().getColor(R.color.albumDefaultThemeDeep), PorterDuff.Mode.SRC_IN));
//...
//onDraw 部分代码
canvas.drawBitmap(selectFlagBitmap, null, selectFlagRect, paint);
PhotoSelectImageView 的呈现效果:
RecyclerView 设置一个间隔装饰器 GridSpacingItemDecoration,指定 item 的间距:
ini
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
int position = parent.getChildAdapterPosition(view);
int column = position % spanCount;
if (column >= 1) {
outRect.left = spacing;
}
if (position >= spanCount) {
outRect.top = spacing;
}
}
基本上界面主体就完活了,额外的实现了一个相册列表的 Adapter,复用 RecyclerView 进行显示,区别就在于内容还需要考虑到相册名字的呈现:
接下来就是相册的读取了,在开始之前首先需要申请权限。
权限处理
API-33 以前使用存储文件读取权限 READ_EXTERNAL_STORAGE 即可,API - 33 以后则需要使用 READ_MEDIA_IMAGES 权限,因此需要先在 AndroidManifest 声明这两个权限:
ini
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
使用代码申请:
arduino
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activityContext, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activityContext, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, PERMISSION_REQUEST_CODE);
return false;
}
} else {
if (ContextCompat.checkSelfPermission(activityContext, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activityContext, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
return false;
}
}
本来想用 registerForActivityResult,至于为啥没用?别提那玩意了基本上就是一坨...
接下来有了权限,就只需要使用 MediaStore 读取所有相册和照片就可以完成实现了。
MediaStore 读取照片
MediaStore 和传统以文件方式读取照片的形式有所区别,它是一个媒体数据库,这意味着需要用读取数据库的思路去操作它。
首先是依据相册名称读取照片,如果相册名称为空则认为是所有照片,核心代码如下:
ini
List<String> photos = new ArrayList<>();
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
String[] projection = new String[]{
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.DATE_ADDED
};
String selection;
String[] selectionArgs;
if (isNull(albumName)) {
selection = null;
selectionArgs = null;
} else {
selection = MediaStore.Images.Media.BUCKET_DISPLAY_NAME + " = ?";
selectionArgs = new String[]{albumName};
}
Cursor cur = context.getContentResolver().query(images,
projection,
selection,
selectionArgs,
null);
if (cur != null && cur.moveToFirst()) {
int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA);
do {
String photoPath = cur.getString(dataColumn);
photos.add(photoPath);
} while (cur.moveToNext());
}
if (cur != null) {
cur.close();
}
photos 即查询到的所有照片列表了,但还需要处理为按照最近时间倒序,添加 sortOrder 即可:
ini
sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC"
添加 sortOrder 到 query 最后一个参数即可。这里的 MediaStore.Images.Media.DATE_ADDED 代表着按照添加到媒体库的时间排序,另外也可以选择 MediaStore.MediaColumns.DATE_TAKEN 按照拍摄时间排序,至于 DESC 就是倒序的意思了。
然后还需要查询所有相册,查询到的相册名称可能有重复的需要剔重。
ini
//读取相册列表
List<String> albums = new ArrayList<>();
String[] projection = new String[]{
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
MediaStore.Images.Media.BUCKET_ID
};
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Cursor cur = context.getContentResolver().query(images,
projection,
null,
null,
null
);
if (cur != null && cur.moveToFirst()) {
int bucketColumn = cur.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME);
do {
String albumName = cur.getString(bucketColumn);
if (!albums.contains(albumName) && !isNull(albumName)) albums.add(albumName);
} while (cur.moveToNext());
}
if (cur != null) {
cur.close();
}
在 UI 呈现时按照相册名称读取最后一张图片作为封面图即可。
至此,自制照片选择器就基本上完成了,相关完整代码已经开源到 Github 上,欢迎参考学习 github.com/kongzue/Dia...,DialogXSample 是基于 DialogX 对话框框架的一系列功能模块扩展包,目前也提供了 地址滚动选择对话框、日期/日历(区间)选择对话框、分享选择对话框、自定义联动滚动选择对话框、底部弹出的评论输入对话框、选择(多选/筛选)文件对话框、抽屉对话框和照片选择器的 Demo 代码,如果默认的就能满足你的业务需求,直接引入对应功能的包即可,如果不能,请自行拉取代码集成到自己的项目里修改使用。
照片选择器直接引入的 gradle 配置如下:
在 build.gradle(Project)(新版本 Android Studio 请在 settings.gradle)添加 jitpack 仓库:
rust
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
ini
def dialogx_sample_version = "0.0.10"
implementation 'com.github.kongzue.DialogXSample:AlbumDialog:${dialogx_sample_version}'
额外的还需引入:
bash
def DIALOGX_VERSION = "0.0.50.beta2"
implementation "com.github.kongzue.DialogX:DialogX:${DIALOGX_VERSION}"
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation "androidx.recyclerview:recyclerview:1.2.1"