65.9K
CodeProject 正在变化。 阅读更多。
Home

Android 文件保存对话框

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.67/5 (21投票s)

2014年1月1日

CPOL

9分钟阅读

viewsIcon

54666

downloadIcon

1340

一个通用的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.FileSelectorMode.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 发布
© . All rights reserved.