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 absolutePathString filename | 在用户点击 [正面] 按钮时,在 onConfirmSave之前立即调用。使用此方法验证用户输入。如果返回 false,则不会调用onConfirmSave。 | 
| onConfirmSave | void | String absolutePathString filename | 如果用户点击 [负面] 按钮,则 path 和 name 都为 null。 | 
FileSelectFragment 回调
| 方法 | Returns | 参数 | 注释 | 
| isValid | 布尔值 | String absolutePathString filename | 在用户点击 [正面] 按钮时,在 onConfirmSelect之前立即调用。此回调可用于在弹出窗口仍可见时检查所选文件是否合适。如果返回 false,则不会调用onConfirmSelect。 | 
| onConfirmSelect | void | String absolutePathString 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 absolutePathString 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 发布 | 




