接上一篇:Android仿微信图片选择器(一)
上一篇介绍了发表界面的编写及数据的处理,这一篇主要介绍图片选择界面的编写。
老规矩,先上效果图:
一、基础条件
1. 实体类设计
public class PhotoFolder {
private String dir;
private String firstPhotoPath;
private String name;
private int count;
public String getDir() {
return dir;
}
public void setDir(String dir) {
this.dir = dir;
int lastIndexOf = this.dir.lastIndexOf(File.separator);
this.name = this.dir.substring(lastIndexOf + 1);
}
public String getFirstPhotoPath() {
return firstPhotoPath;
}
public void setFirstPhotoPath(String firstPhotoPath) {
this.firstPhotoPath = firstPhotoPath;
}
public String getName() {
return name;
}
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
}
2. 工具类
public class PhotoUtils {
public static List<PhotoFolder> getPhotoes(Context context) {
Uri photoUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
ContentResolver contentResolver = context.getContentResolver();
Cursor cursor = contentResolver.query(photoUri, null,
MediaStore.Images.Media.MIME_TYPE + "=? or " + MediaStore.Images.Media.MIME_TYPE + "=?",
new String[]{"image/jpeg", "image/png"},
MediaStore.Images.Media.DATE_MODIFIED);
String firstImage = null;
List<PhotoFolder> photoFolders = null;
HashSet<String> dirPathSet = new HashSet<>(); // 辅助工具
if (cursor != null) {
photoFolders = new ArrayList<>();
while (cursor.moveToNext()) {
String path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA));
if (firstImage == null) {
firstImage = path;
}
File parentFile = new File(path).getParentFile();
if (parentFile == null) {
continue;
}
String dirPath = parentFile.getAbsolutePath();
PhotoFolder photoFolder = null;
if (dirPathSet.contains(dirPath)) {
continue;
} else {
dirPathSet.add(dirPath);
photoFolder = new PhotoFolder();
photoFolder.setDir(dirPath);
photoFolder.setFirstPhotoPath(path);
}
if (parentFile.list() == null) {
continue;
}
int photoSize = parentFile.list(new FilenameFilter() {
@Override
public boolean accept(File file, String fileName) {
return fileName.endsWith(".jpg") || fileName.endsWith(".png") || fileName.endsWith(".jpeg");
}
}).length;
photoFolder.setCount(photoSize);
photoFolders.add(photoFolder);
}
Log.i("PhotoUtils", "photoFolders.size() = " + photoFolders.size());
cursor.close();
dirPathSet = null;
}
return photoFolders;
}
}
二、界面设计
1. 主界面
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/background_dark"
android:fitsSystemWindows="true"
android:orientation="vertical">
<include layout="@layout/layout_toolbar" />
<android.support.v7.widget.RecyclerView
android:id="@+id/id_image_grid_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<RelativeLayout
android:id="@+id/id_bottom_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#515151">
<Button
android:id="@+id/id_photo_spinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:background="?android:selectableItemBackground"
android:ellipsize="end"
android:maxLines="1"
android:text="所有图片"
android:textColor="#E0E0E0" />
<Button
android:id="@+id/id_photo_preview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:background="?android:selectableItemBackground"
android:text="预览"
android:textColor="#E0E0E0" />
</RelativeLayout>
</LinearLayout>
RecyclerView的作用是显示当前选择的文件夹的图片,其中一个按钮的作用是弹出选择文件夹的窗口,一个是预览的按钮。
先看RecyclerView的item布局,包含一个ImageView和CheckBox。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="4dp">
<ImageView
android:id="@+id/id_pick_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="centerCrop"
tools:src="@drawable/ic_profile" />
<CheckBox
android:id="@+id/id_select_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true" />
</RelativeLayout>
为RecyclerView编写Adapter,此处有一个坑是ViewHolder的复用机制会导致CheckBox乱序,通常的解决方法是使用一个HashMap来保存CheckBox的选中状态,在使用HashMap<Integer,Boolean>
的时候,AS提示使用SparseBooleanArray
会有更好的效率,有兴趣的同学可以去百度一下原理,这里就不解释了。但是,结合当前项目的需求,我可以通过点击按钮切换文件夹路径显示不同文件夹的图片,这时复用的机制再次成为一个坑。幸好机智如我,最后通过使用一个HashMap<String,SparseBooleanArray>
,为每一个路径创建一个SparseBooleanArray
来保存对应路径的CheckBox的选中情况解决了乱序和复用的问题。
以下是adapter的代码:
public class PhotoPickAdapter extends AbsRecyclerAdapter<String> {
private Object tag;
private int mImageWidth;
private OnItemSelectedListener onItemSelectedListener;
private HashMap<String, SparseBooleanArray> mFolderSelectedMap = new HashMap<>();
private String mCurrentFolder;
private SparseBooleanArray mSelectedMap;
public PhotoPickAdapter(Context context, String currentFolder, List<String> list) {
super(context, list);
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
mImageWidth = metrics.widthPixels / 3;
mCurrentFolder = currentFolder;
mSelectedMap = new SparseBooleanArray();
initArray(mSelectedMap, list);
mFolderSelectedMap.put(mCurrentFolder, mSelectedMap);
}
@Override
protected AbsViewHolder createHolder(ViewGroup parent, int viewType) {
return new ItemViewHolder(mInflater.inflate(R.layout.layout_pick_image_item, parent, false));
}
@Override
protected void showViewHolder(AbsViewHolder holder, final int position) {
mSelectedMap = mFolderSelectedMap.get(mCurrentFolder);
final ItemViewHolder viewHolder = (ItemViewHolder) holder;
Picasso.with(mContext)
.load(new File(mData.get(position)))
.placeholder(R.drawable.ic_place_holder)
.error(R.drawable.ic_load_error)
.config(Bitmap.Config.RGB_565)
.resize(mImageWidth, mImageWidth)
.centerCrop()
.tag(tag = mData.get(position))
.into(viewHolder.image);
viewHolder.select.setOnCheckedChangeListener(null);
viewHolder.select.setChecked(mSelectedMap.get(position));
viewHolder.select.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
mSelectedMap.put(position, b);
if (b) {
if (onItemSelectedListener != null) {
onItemSelectedListener.onChecked(compoundButton, mData.get(position));
}
} else {
if (onItemSelectedListener != null) {
onItemSelectedListener.onRemoved(mData.get(position));
}
}
}
});
}
public void setOnItemSelectedListener(OnItemSelectedListener onItemSelectedListener) {
this.onItemSelectedListener = onItemSelectedListener;
}
public interface OnItemSelectedListener {
void onChecked(CompoundButton compoundButton, String image);
void onRemoved(String image);
}
public Object getTag() {
return tag;
}
public void setCurrentFolder(String folder, List<String> data) {
LogUtils.e("PickAdapter", "current folder" + folder);
if (!mFolderSelectedMap.containsKey(folder)) {
SparseBooleanArray array = new SparseBooleanArray();
initArray(array, data);
mFolderSelectedMap.put(folder, array);
}
mCurrentFolder = folder;
mSelectedMap = mFolderSelectedMap.get(mCurrentFolder);
mData.clear();
mData.addAll(data);
notifyDataSetChanged();
}
private void initArray(SparseBooleanArray array, List<String> data) {
for (int i = 0; i < data.size(); i++) {
array.put(i, false);
}
}
private static class ItemViewHolder extends AbsViewHolder {
ImageView image;
CheckBox select;
ItemViewHolder(View itemView) {
super(itemView);
image = (ImageView) itemView.findViewById(R.id.id_pick_image);
select = (CheckBox) itemView.findViewById(R.id.id_select_image);
}
}
}7
其中,OnItemSelectedListener
的作用是为了把CheckBox的选中事件监听回调到Activity中,让Activity去处理相应的数据和逻辑。setCurrentFolder()
是一个关键的方法,通过该方法可以为当前路径创建一个SparseBooleanArray
来保存CheckBox的选中状态。adapter中的tag
的作用是在RecyclerView滚动的时候可以通过tag
来控制是否暂停加载图片,加快响应速度。
2. 弹出窗口设计
先看弹出窗口的效果图:
该效果通过一个PopupWindow
实现,该PopupWindow
布局仅包括一个RecyclerView
。实现代码如下:
public class PhotoSpinnerWindow extends PopupWindow {
public PhotoSpinnerWindow(Context context, final List<PhotoFolder> list, final OnItemSelectedListener listener) {
LayoutInflater inflater = LayoutInflater.from(context);
RecyclerView view = new RecyclerView(context);
view.setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
view.setLayoutManager(new LinearLayoutManager(context));
PhotoFolderAdapter adapter = new PhotoFolderAdapter(context, list);
view.setAdapter(adapter);
adapter.setOnItemClickListener(new AbsRecyclerAdapter.DefaultItemClickListener() {
@Override
public void onClick(View view, int position) {
String dir = list.get(position).getDir();
String name = list.get(position).getName();
File file = new File(dir);
if (file.list() != null) {
List<String> images = new ArrayList<>();
for (String path : file.list()) {
images.add(list.get(position).getDir() + File.separator + path);
}
if (listener != null) {
listener.onSelected(view, dir, name, images);
}
}
}
});
this.setContentView(view);
this.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
this.setWidth(ViewGroup.LayoutParams.MATCH_PARENT);
this.setFocusable(true);
this.setOutsideTouchable(true);
ColorDrawable bd = new ColorDrawable(0xb0000000);
this.setBackgroundDrawable(bd);
this.setAnimationStyle(R.style.bottom_popup_anim);
}
public interface OnItemSelectedListener {
void onSelected(View view, String dir, String name, List<String> images);
}
}
在该PopupWindow
中有一个OnItemSelectedListener
,主要作用是将选中的路径下的图片的路径列表回调到Activity进行处理。PhotoFolderAdapter
是该RecyclerView
的适配器,具体实现如下:
public class PhotoFolderAdapter extends AbsRecyclerAdapter<PhotoFolder> {
public PhotoFolderAdapter(Context context, List<PhotoFolder> list) {
super(context, list);
}
@Override
protected AbsViewHolder createHolder(ViewGroup parent, int viewType) {
return new ItemViewHolder(mInflater.inflate(R.layout.layout_photo_spinner_item, parent, false));
}
@Override
protected void showViewHolder(AbsViewHolder holder, int position) {
ItemViewHolder viewHolder = (ItemViewHolder) holder;
viewHolder.dir.setText(mData.get(position).getName());
viewHolder.count.setText(mData.get(position).getCount() + "张");
Picasso.with(mContext)
.load(new File(mData.get(position).getFirstPhotoPath()))
.placeholder(R.drawable.ic_place_holder)
.error(R.drawable.ic_load_error)
.config(Bitmap.Config.RGB_565)
.into(viewHolder.image);
}
private static class ItemViewHolder extends AbsViewHolder {
ImageView image;
TextView dir;
TextView count;
ItemViewHolder(View itemView) {
super(itemView);
image = (ImageView) itemView.findViewById(R.id.id_spinner_image);
dir = (TextView) itemView.findViewById(R.id.id_spinner_dir);
count = (TextView) itemView.findViewById(R.id.id_spinner_count);
}
}
}
对应的布局文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/white_item"
android:orientation="horizontal"
android:padding="@dimen/activity_margin">
<ImageView
android:id="@+id/id_spinner_image"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
tools:src="@drawable/ic_profile" />
<TextView
android:id="@+id/id_spinner_dir"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_weight="1"
android:textColor="#2c2c2c"
android:textSize="18sp"
tools:text="WeiXin" />
<TextView
android:id="@+id/id_spinner_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:textSize="16sp"
tools:text="533张" />
</LinearLayout>
至此,所有界面设计完成,接下来就是最核心的数据处理逻辑和功能实现。
三、功能实现
本项目是基于MVP模式实现的,为了简便实现和展示该功能,代码中并不完全符合MVP的设计。
1. 接口定义
公共接口定义:
public interface RequestCallback<T> {
void onSuccess(T t);
void onFailure(String message);
}
获取图片接口定义:
public interface IPhotoPickModel {
void getPhotoes(Context context, RequestCallback<List<PhotoFolder>> callback);
}
具体实现如下:
public class PhotoPickModelImpl implements IPhotoPickModel {
@Override
public void getPhotoes(final Context context, final RequestCallback<List<PhotoFolder>> callback) {
new Thread(new Runnable() {
@Override
public void run() {
final List<PhotoFolder> list = PhotoUtils.getPhotoes(context);
if (list != null) {
if (callback != null) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
callback.onSuccess(list);
}
});
}
} else {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
callback.onFailure("unknown error");
}
});
}
}
}).start();
}
}
因为查找本机图片是一个耗时的操作,所以我把它放到子线程中去处理,当获取到结果时,通过Handler
把数据回调到主线程。
2. 数据处理
由于不完全按照MVP设计来,为了演示方便,并没有设计Presenter层去关联View和Model层,这里直接在View层使用Model层的接口,也就是在Activity中直接调用Model的方法。具体代码如下:
private void loadImage() {
IPhotoPickModel model = new PhotoPickModelImpl();
model.getPhotoes(BasicApplication.getApplication(), new RequestCallback<List<PhotoFolder>>() {
@Override
public void onSuccess(List<PhotoFolder> photoFolders) {
LogUtils.i("getPhotoList");
mPhotoFolderList.clear();
mPhotoFolderList.addAll(photoFolders);
mPhotoFolderAdapter.notifyDataSetChanged();
// 设置默认显示
String dir = photoFolders.get(0).getDir();
String name = photoFolders.get(0).getName();
mSpinnerButton.setText(name);
File file = new File(dir);
if (file.list() != null) {
List<String> images = new ArrayList<>();
for (String path : file.list()) {
images.add(dir + File.separator + path);
}
mPhotoPickAdapter.setCurrentFolder(dir, images);
}
}
@Override
public void onFailure(String message) {
ToastUtils.showShort(BasicApplication.getApplication(), message);
}
});
}
Bean
类的设计是保存文件夹路径和文件夹下第一张图片的路径,这样做是为了把路径和图片分开,提高效率。Model层回调的数据是PopupWindow
中的RecyclerView
展示所需要的数据,所以要把数据填充到PhotoFolderAdapter
中,然后默认取第一个文件夹的图片展示到界面上。
接下来我遇到了一个坑,一个没注意到的细节。因为Android6.0系统的特性,某些权限需要动态申请,而获取手机图片就是一个读取用户隐私信息的行为,需要用户授权方可继续。这时候我又去学习了一波动态权限申请的知识,然后顺利解决了这个问题。直接上代码:
private static final int EXTERNAL_STORAGE_PERMISSION_CODE = 1000;
private void getPermission() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(PhotoPickActivity.this,
new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
EXTERNAL_STORAGE_PERMISSION_CODE);
} else {
loadImage();
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == EXTERNAL_STORAGE_PERMISSION_CODE) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
loadImage();
} else {
showMessage("未授权");
}
}
}
数据处理大致就到这里了,接下来是介绍一些逻辑处理,如选取不同文件夹的逻辑处理,图片选择个数的逻辑处理。
3. 逻辑处理
因为文件夹的选取是在PopupWindow
中处理的,所以这里的逻辑主要是在PopupWindow
中。具体看代码:
private void initPhotoWindow() {
mPhotoFolderList = new ArrayList<>();
mPhotoFolderAdapter = new PhotoFolderAdapter(this, mPhotoFolderList);
mPhotoSpinnerWindow = new PhotoSpinnerWindow(this, mPhotoFolderList, new PhotoSpinnerWindow.OnItemSelectedListener() {
@Override
public void onSelected(View view, String dir, String name, List<String> images) {
mSpinnerButton.setText(name);
mPhotoPickAdapter.setCurrentFolder(dir, images);
mPhotoSpinnerWindow.dismiss();
}
});
}
因为在PopupWindow中做了数据的出来,回调的数据就是要显示到界面上的数据,所以将数据填充到adapter中,即调用PhotoPickAdapter.setCurrentFolder(dir, images)
方法。
对于图片个数的限制,主要是对CheckBox监听回调的处理。先看代码:
mPhotoPickAdapter.setOnItemSelectedListener(new PhotoPickAdapter.OnItemSelectedListener() {
@Override
public void onChecked(CompoundButton compoundButton, String image) {
if (check(compoundButton)) {
mSelectedPhotos.add(image);
}
checkSelectedPhotoCount();
}
@Override
public void onRemoved(String image) {
mSelectedPhotos.remove(image);
checkSelectedPhotoCount();
}
});
在监听回调中有两个判断方法,主要就是处理选取张数的逻辑,check()
的作用是控制CheckBox状态,checkSelectedPhotoCount()
控制预览按钮的可用以及选取的张数个数的显示。具体代码如下:
private void checkSelectedPhotoCount() {
if (mSelectedPhotos == null) return;
if (mSelectedPhotos.size() == 0) {
mPreviewButton.setText("预览");
mPreviewButton.setEnabled(false);
} else {
mPreviewButton.setEnabled(true);
mPreviewButton.setText(String.format(Locale.getDefault(), "预览(%d)", mSelectedPhotos.size()));
}
}
private boolean check(CompoundButton compoundButton) {
if (mSelectedPhotos.size() + 1 > mSelectedCount) {
compoundButton.setChecked(false);
showMessage(String.format(Locale.getDefault(), "您最多能选择%d张图片", mSelectedCount));
return false;
}
return true;
}
图片可选数量由mSelectedCount控制,该参数由启动该Activity的Activity觉得,该Activity向外提供一个方法进行调用:
public static void startActivityForResult(Activity context, int requestCode, int resultCode, int selectedCount) {
mResultCode = resultCode;
mSelectedCount = selectedCount;
Intent intent = new Intent(context, PhotoPickActivity.class);
context.startActivityForResult(intent, requestCode);
}
至此,图片选择的功能和核心代码已经介绍完毕,接下来一篇博客是介绍预览界面的实现。