Android 文件保存对话框
一个通用的Android文件保存弹出窗口。
引言
Android 最初设计用于资源有限且很少或根本不需要文件操作的设备,因此 API 中未包含标准文件保存和打开对话框等功能。然而,在某些应用程序中,保存文件并允许用户指定文件在设备上的保存位置会很方便,并且在保存文件后,提供一种访问它们的方式也是一个不错的主意。
这两个弹出窗口都实现为单个类,每个类都在代码中定义其 UI,而不是使用 XML 布局文件。只需将所需的类(复数)放入项目(复数)的适当 src 子目录中,更新文件中的包路径,然后从需要文件保存或选择功能的 Activity(复数)中调用它。
背景
在 Android 中创建自定义弹出窗口的标准方法是使用 AlertDialog builder,它提供了一个两按钮的正面/负面框架(例如,确定/取消)和一个三按钮的正面/负面/中性框架(例如,确定/取消/应用)。基本知识在这里得到解释
Android 对话框 - 背景AlertDialog
屏幕截图
这些屏幕截图来自模拟器。
文件保存

显示空目录的屏幕截图
- 返回父目录的箭头
- 当前目录的路径。
- 用于输入文件名的编辑区域。
- 要创建的文件的默认扩展名。

显示子目录的目录屏幕截图
文件选择

显示已过滤选择的屏幕截图
- 目录和 XML 文件。
- 所选文件的路径。
使用代码
Activity 签名
任何需要文件保存/选择功能的 Activity 都必须实现文件片段的回调接口。例如// Activity requiring save and select.
public class MainActivity
extends Activity
implements FileSaveFragment.Callbacks,
FileSelectFragment.Callbacks {
FileSaveFragment 回调
方法 | Returns | 参数 | 注释 |
onCanSave |
布尔值 | String absolutePath String filename |
在用户点击 [正面] 按钮时,在 onConfirmSave 之前立即调用。使用此方法验证用户输入。如果返回 false,则不会调用 onConfirmSave 。 |
onConfirmSave |
void | String absolutePath String filename |
如果用户点击 [负面] 按钮,则 path 和 name 都为 null。 |
FileSelectFragment 回调
方法 | Returns | 参数 | 注释 |
isValid |
布尔值 | String absolutePath String filename |
在用户点击 [正面] 按钮时,在 onConfirmSelect 之前立即调用。此回调可用于在弹出窗口仍可见时检查所选文件是否合适。如果返回 false,则不会调用 onConfirmSelect 。 |
onConfirmSelect |
void | String absolutePath String filename |
如果用户点击 [负面] 按钮,则 path 和 name 都为 null。在目录选择模式下,name 始终为 null。 |
文件保存
实例化
实例化方法接受一个默认扩展名字符串和一些资源 ID。显然,您必须提供适当的资源。
Param | 备注 |
defaultExtension |
一个字符串。可以为 null。 |
resourceID_OK |
OK(正面)按钮标题的字符串资源 ID。 |
resourceID_Cancel |
Cancel(负面)按钮标题的字符串资源 ID。 |
resourceID_Title |
弹出窗口标题栏的字符串资源 ID。 |
resourceID_EditHint |
文件名编辑控件的字符串资源 ID。 |
resourceID_Icon |
弹出窗口标题栏图标的可绘制资源 ID。 |
String xml = getResources().getString(R.string.file_extension_xml);
String fragTag = getResources().getString(R.string.tag_fragment_FileSave);
// Get an instance supplying a default extension, captions and
// icon appropriate to the calling application/activity.
FileSaveFragment fsf = FileSaveFragment.newInstance(xml,
R.string.alert_ok,
R.string.alert_cancel,
R.string.alert_file_save_as,
R.string.hint_filename_unadorned,
R.drawable.ic_launcher);
fsf.show(getFragmentManager(), fragTag);
验证辅助方法
这很棘手。您是在弹出窗口中提供验证还是仅提供回调钩子?我选择了回调,因为它允许客户端程序员进行所需程度的验证,并且不规定如何向用户显示反馈。作为一种折衷,FileSaveFragment
中添加了一些静态辅助方法,以减少处理 onCanSave()
回调时所需的工作量。请参阅下面的示例。
方法 | Params | Returns | 备注 |
FileExists |
String absolutePath String filename |
布尔值 | 简单的“存在还是不存在?”测试。 |
IsAlphaNumeric |
String filename |
布尔值 | 如果文件名仅包含字母数字字符,则返回 True。以禁止使用连字符、空格和下划线为代价,简化了对无效字符的检查。 |
Extension |
String filename |
字符串 | 文件名的最后一个句点后面的字符。 |
NameNoExtension |
String filename |
字符串 | 文件名直到(但不包括)最后一个句点。 |
回调
// Act on a validated [positive] button click or a [negative] button
// click. On [negative] click path and name are both null.
public void onConfirmSave(String absolutePath, String fileName) {
if (absolutePath != null && fileName != null) {
// Recommend that file save for large amounts of data is handled
// by an AsyncTask.
mySaveMethod(absolutePath, fileName);
}
}
// Example validation showing use of provided helper methods.
public boolean onCanSave(String absolutePath, String fileName) {
boolean canSave = true;
// Catch the really stupid case.
if (absolutePath == null || absolutePath.length() ==0 ||
fileName == null || fileName.length() == 0) {
canSave = false;
showToast(R.string.alert_supply_filename, Toast.LENGTH_SHORT);
}
// Do we have a filename if the extension is thrown away?
if (canSave) {
String copyName = FileSaveFragment.NameNoExtension(fileName);
if (copyName == null || copyName.length() == 0 ) {
canSave = false;
showToast(R.string.alert_supply_filename, Toast.LENGTH_SHORT);
}
}
// Allow only alpha-numeric names. Simplify dealing with reserved path
// characters.
if (canSave) {
if (!FileSaveFragment.IsAlphaNumeric(fileName)) {
canSave = false;
showToast(R.string.alert_bad_filename_chars, Toast.LENGTH_SHORT);
}
}
// No overwrite of an existing file.
if (canSave) {
if (FileSaveFragment.FileExists(absolutePath, fileName)) {
canSave = false;
showToast(R.string.alert_file_exists, Toast.LENGTH_SHORT);
}
}
return canSave;
}
文件或目录选择
实例化
实例化方法接受一个选择模式枚举和一些资源 ID。显然,您必须提供适当的资源。还可以指定文件名过滤器。
Param | 备注 |
selectionMode |
Mode.FileSelector 或 Mode.DirectorySelector 。在目录选择模式下,仅显示目录。在这两种模式下,任何提供的 FilenameFilter 都将得到遵守。 |
resourceID_OK |
OK(正面)按钮标题的字符串资源 ID。 |
resourceID_Cancel |
Cancel(负面)按钮标题的字符串资源 ID。 |
resourceID_Title |
弹出窗口标题栏的字符串资源 ID。 |
resourceID_Icon |
弹出窗口标题栏图标的可绘制资源 ID。 |
resourceID_Directory |
用于指示目录的可绘制资源 ID。 |
resourceID_File |
用于指示文件的可绘制资源 ID。 |
方法 | Params | 备注 |
newInstance |
参见上文。 | |
setFilter |
FilenameFilter |
可选。文件名过滤器的实例,根据应用程序的要求过滤要显示的文件/目录。静态辅助方法 ::FileTypeFilter 提供简单的文件扩展名过滤。 |
String fragTag = getResources().getString(R.string.tag_fragment_FileSelect);
// Set up a selector for file selection rather than directory selection.
FileSelectFragment fsf = FileSelectFragment.newInstance(Mode.FileSelector,
R.string.alert_ok,
R.string.alert_cancel,
R.string.alert_file_select,
R.drawable.ic_launcher,
R.drawable.ic_dir,
R.drawable.ic_file);
// Restrict selection to *.xml files
ArrayList<String> allowedExtensions = new ArrayList<String>();
allowedExtensions.add(".xml");
fsf.setFilter(FileSelectFragment.FiletypeFilter(allowedExtensions));
fsf.show(getFragmentManager(), fragTag);
回调
public void onConfirmSelect(String absolutePath, String fileName) {
if (absolutePath != null && fileName != null) {
// Recommend that long/intensive file processes be handled by an
// Async task.
myFileProcess(absolutePath, fileName);
}
}
// Check that the file's content is acceptable.
public boolean isValid(String absolutePath, String fileName) {
return fileHeaderCheck(absolutePath, fileName);
}
提供 isValid
回调是为了,当用户从具有通用类型但内部布局不同的文件(例如 XML)中进行选择时,应用程序可以运行一个快速的健全性检查,然后允许用户继续。
另一种方法是提供一个自定义的 FilenameFilter
,它只显示具有正确内部格式的文件,而不考虑文件名或扩展名;可以通过 MIME 类型或检查内容来实现。使用此方法时必须小心,避免阻塞 UI 线程。
结构
文件保存和文件选择之间只有很小的差异,所以我们只在这里讨论文件保存。
当前目录的内容显示在一个 listview 中,我们可以滚动查看列表。我们需要以下用户界面元素
- 一个整体的弹出窗口布局
- 一个数组适配器,用于显示每个目录内容的列表。
- 一种在用户选择目录中的某一项时更新 listview 内容的方法。
整体布局
在 Android 中创建用户界面的标准(也是首选)方法是使用 XML 布局文件,但由于我们希望将所需文件数量降至最少(即每个弹出窗口一个),因此 UI 在弹出窗口的 onCreateDialog
方法中以代码形式定义。概要如下
- 创建一个垂直布局的根视图
- 将 listview 和分隔线添加到根视图
- 将用于文件输入区域的水平布局添加到根视图
- 将路径、编辑和默认扩展名控件添加到水平布局视图。
- listview 和其他控件的初始填充。
- 使用
AlertDialog.Builder
使用根视图创建弹出窗口。
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
// Set up the container view.
LinearLayout.LayoutParams rootLayout = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
0.0F);
root = new LinearLayout(getActivity());
root.setOrientation(LinearLayout.VERTICAL);
root.setLayoutParams(rootLayout);
// Set up initial sub-directory list.
currentDirectory = Environment.getExternalStorageDirectory();
directoryList = getSubDirectories(currentDirectory);
DirectoryDisplay displayFormat = new DirectoryDisplay(getActivity(), directoryList);
/* Now set up the directory listview.
* Fix the height of the listview at 150px, enough to show 3 or 4 entries at a time.
* Don't want the popup shrinking and growing all the time. Tried it.
* Most disconcerting.
* */
LinearLayout.LayoutParams listViewLayout = new LinearLayout.LayoutParams(ViewGroup.
LayoutParams.MATCH_PARENT,
150,
0.0F);
directoryView = new ListView(getActivity());
directoryView.setLayoutParams(listViewLayout);
directoryView.setAdapter(displayFormat);
directoryView.setOnItemClickListener(this);
root.addView(directoryView);
View horizDivider = new View(getActivity());
horizDivider.setBackgroundColor(Color.CYAN);
root.addView(horizDivider,
new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 2));
/*
* Now set up the filename entry area.
*
* {current path}/ [Enter Filename ] {default extension}
*
* */
LinearLayout nameArea = new LinearLayout(getActivity());
nameArea.setOrientation(LinearLayout.HORIZONTAL);
nameArea.setLayoutParams(rootLayout);
root.addView(nameArea);
currentPath = new TextView(getActivity());
currentPath.setText(currentDirectory.getAbsolutePath() + "/");
nameArea.addView(currentPath);
/*
* We want the filename input area to be as large as possible, but still leave
* enough room to show the path and any default extension that may be supplied
* so we give it a weight of 1.
* */
LinearLayout.LayoutParams fileNameLayout = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
1.0F );
fileName = new EditText(getActivity());
fileName.setHint(resourceID_EditHint);
fileName.setGravity(Gravity.LEFT);
fileName.setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
fileName.setLayoutParams(fileNameLayout);
nameArea.addView(fileName);
// Display the default extension if one has been supplied.
if (defaultExtension != null ) {
TextView defaultExt = new TextView(getActivity());
defaultExt.setText(defaultExtension);
defaultExt.setGravity(Gravity.LEFT);
defaultExt.setPadding(2, 0, 6, 0);
nameArea.addView(defaultExt);
}
// Use the standard AlertDialog builder to create the popup.
// Usuall Android custom and practice is to chain calls from the builder, but
// this can become an unreadable and unmaintainable mess very quickly so I don't.
Builder popupBuilder = new AlertDialog.Builder(getActivity());
popupBuilder.setView(root);
popupBuilder.setIcon(resourceID_Icon);
popupBuilder.setTitle(resourceID_Title);
popupBuilder.setPositiveButton(resourceID_OK,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
// Empty method. Method defined in onStart();
// See later notes in this article.
}
});
popupBuilder.setNegativeButton(resourceID_Cancel,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
mCallbacks.onConfirmSave(null, null);
}
});
// Hand out an instance of our popup.
return popupBuilder.create();
}
显示适配器
这是一个标准的适配器,它为源列表中的每个 File 实例显示一个包含目录名称的 TextView。
private class DirectoryDisplay
extends ArrayAdapter<File> { ...
选择目录
我们需要捕获 listview 中的项目选择。我们通过让弹出窗口类实现 OnItemClickListener
来做到这一点,这将允许它处理用户在 listview 中的按下/点击操作。
public class FileSaveFragment extends DialogFragment
implements OnItemClickListener {
项目点击处理程序的链接在 onCreateDialog
方法中建立。
directoryView.setOnItemClickListener(this);
弹出窗口的 onItemClick
回调处理程序检索所选目录的子目录,将列表与新的显示适配器关联,并将其分配给 listview。
@Override
public void onItemClick(AdapterView<?> arg0, View list, int pos, long id )
{
File selected = null;
if (pos >= 0 || pos < directoryList.size()) {
selected = directoryList.get(pos);
String name = selected.getName();
// Are we going up or down?
if (name.equals(PARENT)) {
currentDirectory = currentDirectory.getParentFile();
}
else {
currentDirectory = selected;
}
// Refresh the listview display for the newly selected directory.
directoryList = getSubDirectories(currentDirectory);
DirectoryDisplay displayFormatter = new DirectoryDisplay(getActivity(), directoryList);
directoryView.setAdapter(displayFormatter);
// Update the path TextView widget. Tell the user where he or she is.
String path = currentDirectory.getAbsolutePath();
if (currentDirectory.getParent() != null) {
path += "/";
}
currentPath.setText(path);
}
}
在验证期间保留视图
正面按钮的 onClickListener
的设置移至 AlertDialog::onStart
重写。这允许我们根据验证回调的返回值调用 AlertDialog::dismiss
方法或不调用。
在 onCreate
中,按钮以 NOP 监听器创建,因为无法从那里抑制 dismiss 的自动调用。
popupBuilder.setPositiveButton(resourceID_OK,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
// Empty method. Method defined in onStart();
}
});
onStart
重写用一个可以抑制 dismiss 调用的监听器替换了 NOP 监听器。
@Override
public void onStart() {
super.onStart();
AlertDialog d = (AlertDialog)getDialog();
if(d != null)
{
Button positiveButton = (Button) d.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String absolutePath = currentDirectory.getAbsolutePath();
String filename = fileName.getText().toString();
if (mCallbacks.onCanSave(absolutePath, filename)) {
dismiss();
mCallbacks.onConfirmSave(absolutePath, filename);
}
}
});
}
}
文件选择差异
主要区别在于目录打开和内容填充被移至 onItemLongClick
处理程序,而 onItemClick
处理程序则选择目录中的文件。这使得区分文件选择和更改目录的请求变得容易。
文件保存弹出窗口不使用目录图标,因为没有必要区分目录和文件。如果您想在文件保存中显示目录图标,您需要按照下面的说明修改 DirectoryDisplay::getView
方法。TextView 控件使得将图像与文本结合变得非常容易。
String name = fileList.get(position).getName();
textview.setText(name);
int iconID = resourceID_File;
if (fileList.get(position).isDirectory()) {
iconID = resourceID_Dir;
}
// We don't show an icon for the parent.
if (name.equals(PARENT)) {
iconID = -1;
}
// Icon to the left of the text.
if (iconID > 0 ){
Drawable icon = getActivity().getResources().getDrawable( iconID );
textview.setCompoundDrawablesWithIntrinsicBounds(icon,null, null, null );
}
注释
用户必须点击 [正面] 按钮来确认保存和选择,尽管应该可以在 itemclick
处理程序中自动选择并关闭弹出窗口。这可以防止用户因手指粗糙和/或屏幕不够灵敏的设备而导致错误选择问题。
父目录用左指向的三角形 \u25C0 标记。这样做是为了最小化使用弹出窗口时所需的设置量。最近的阅读表明,此代码点可能并非在所有 Android 实现中都受支持。另一种选择是在宿主应用程序中提供一个可绘制资源,并在弹出窗口创建时传入资源 ID,就像为弹出窗口的标题图标所做的那样。
在文件选择中,我使用了 holo light 下载中的 4_collections_view_as_grid 和 4_collections_view_as_list 图标作为目录和文件的图标。我认为它们效果很好,并且它们以所有必需的尺寸提供,无需额外工作。懒惰?我?
listview 的高度设置为 150px,因为 MATCH_PARENT 会导致弹出窗口的高度因目录而异。这个值似乎对 7 英寸平板电脑和 3 英寸手机格式在模拟中效果很好,但我很想听听任何更好的固定高度的方法。
尽管这两个类有大量共同的代码,但我认为将它们保持完全独立会简化重用。如果您不同意,请随时将它们合并为一个类。
虽然打算用于 Android V4 及更高版本,但应能将这些类移植到任何具有 fragment 支持库的版本。这留给感兴趣的读者作为练习。
历史
日期 | 版本 | 注释 |
2014年1月 | 3 | 添加文件选择后重写。 |
2014年1月 | 2 | 如果文件保存中的验证失败,弹出窗口不会关闭。 添加了文件保存验证辅助方法。 在文件保存中为文件名编辑控件禁用了拼写检查。 |
2013年12月 | 1 | 版本 1 发布 |