Introduction
Android was originally designed to run on devices with limited resources and little or no need for file operations so features such as standard file save and open dialogues were not included in the API. However there are some applications where it would be convenient to save files and to allow the user to specify where on the device the file(s) should be saved and, having saved files, it wouldn't be a bad idea to provide the user with a way of getting at them.
Both popups are implemented as single classes each of which defines its UI in code rather than using an XML layout file. All that is required is to drop the required class(es) into the appropriate src sub-directory of your project(s), update the package path in the file and then call it from the activity, or activities, that require a file save or select feature.
Background
The standard approach to creating custom popups in Android is to use an
AlertDialog builder which provides a two button positive/negative framework (e.g.
OK/Cancel) and a three button positive/negative/neutral framework (e.g. OK/Cancel/Apply). The basics are explained here:
Android Dialogs - Background
AlertDialog
Screenshots
These screenshots are from the emulator.
File Save
Screen shot for an empty directory showing:
- Back arrow to parent directory
- Path to current directory.
- Edit area to enter a filename.
- The default extension for the file to be created.
Screen shot for a directory showing sub-directories
File Select
Screen shot for filtered select showing:
- Directories and XML files.
- The path for the selected file.
Using the code
Activity Signature
Any activity that needs the file save/select features will have to implement the file fragments' callback interfaces.
For example:
public class MainActivity
extends Activity
implements FileSaveFragment.Callbacks,
FileSelectFragment.Callbacks {
FileSaveFragment Callbacks
Method | Returns | Parameters | Comment |
onCanSave | boolean | String absolutePath
String filename | Invoked immediately before onConfirmSave when the user hits the [positive] button. Use this to validate user input. If false is returned onConfirmSave is not called. |
onConfirmSave | void | String absolutePath
String filename | If the user clicks the [negative] button then both path and name are null. |
FileSelectFragment Callbacks
Method | Returns | Parameters | Comment |
isValid | boolean | String absolutePath
String filename | Invoked immediately before onConfirmSelect when the user hits the [positive] button. This callback can be used to check that the selected file is suitable whilst the popup is still in view. If false is returned onConfirmSelect is not called. |
onConfirmSelect | void | String absolutePath
String filename | If the user clicks the [negative] button then both path and name are null.
In directory selection mode name will always be null. |
File Save
Instantiation
The instantiation method takes a default extension string and a number of resource IDs. You will, obviously, have to provide appropriate resources.
Param | Remark |
defaultExtension | A string. Can be null. |
|
resourceID_OK | A string resource ID for the OK (positive) button caption. |
resourceID_Cancel | A string resource ID for the Cancel (negative) button caption. |
resourceID_Title | A string resource ID for the popup's title bar. |
resourceID_EditHint | A string resource ID for the filename edit widget. |
resourceID_Icon | A drawable resource ID for the popup's title bar icon. |
String xml = getResources().getString(R.string.file_extension_xml);
String fragTag = getResources().getString(R.string.tag_fragment_FileSave);
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);
Validation Helper Methods
Tricky this. Do you provide validation in the popup or just a callback hook? I've gone for the callback because it lets the client programmer validate as much or as little as required and doesn't dictate how feedback is displayed to the user. By way of a compromise a few static helper methods have been added to FileSaveFragment
to reduce the workload required when handling the onCanSave()
callback. See the example below.
Method | Params | Returns | Remark |
FileExists | String absolutePath
String filename | Boolean | Simple, "Is it there or not?" test. |
|
IsAlphaNumeric | String filename | Boolean | True if the filename contains only alpha-numeric characters. Simplifies
checking for invalid characters at the cost of forbidding hyphens, spaces and
underscores. |
Extension | String filename | String | The characters following the final full stop in the file's name. |
NameNoExtension | String filename | String | The filename up to, but not including, the final full stop in the name. |
Callbacks
public void onConfirmSave(String absolutePath, String fileName) {
if (absolutePath != null && fileName != null) {
mySaveMethod(absolutePath, fileName);
}
}
public boolean onCanSave(String absolutePath, String fileName) {
boolean canSave = true;
if (absolutePath == null || absolutePath.length() ==0 ||
fileName == null || fileName.length() == 0) {
canSave = false;
showToast(R.string.alert_supply_filename, Toast.LENGTH_SHORT);
}
if (canSave) {
String copyName = FileSaveFragment.NameNoExtension(fileName);
if (copyName == null || copyName.length() == 0 ) {
canSave = false;
showToast(R.string.alert_supply_filename, Toast.LENGTH_SHORT);
}
}
if (canSave) {
if (!FileSaveFragment.IsAlphaNumeric(fileName)) {
canSave = false;
showToast(R.string.alert_bad_filename_chars, Toast.LENGTH_SHORT);
}
}
if (canSave) {
if (FileSaveFragment.FileExists(absolutePath, fileName)) {
canSave = false;
showToast(R.string.alert_file_exists, Toast.LENGTH_SHORT);
}
}
return canSave;
}
File or Directory Select
Instantiation
The instantiation method takes a selection mode enum and a number of resource IDs. You will, obviously, have to provide appropriate resources. A filename filter may also be specified.
Param | Remark |
selectionMode | Mode.FileSelector or Mode.DirectorySelector . In directory selection mode only directories are displayed. In both modes any FilenameFilter supplied will be honoured. |
resourceID_OK | A string resource ID for the OK (positive) button caption. |
resourceID_Cancel | A string resource ID for the Cancel (negative) button caption. |
resourceID_Title | A string resource ID for the popup's title bar. |
resourceID_Icon | A drawable resource ID for the popup's title bar icon. |
resourceID_Directory | A drawable resource ID to indicate a directory. |
resourceID_File | A drawable resource ID to indicate a file. |
Method | Params | Remark |
newInstance | See above. | |
setFilter | FilenameFilter | Optional. An instance of a filename filter that filters files/directories to be
displayed according to application requirements. Static helper method ::FileTypeFilter provides simple file extension filtering. |
|
String fragTag = getResources().getString(R.string.tag_fragment_FileSelect);
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);
ArrayList<String> allowedExtensions = new ArrayList<String>();
allowedExtensions.add(".xml");
fsf.setFilter(FileSelectFragment.FiletypeFilter(allowedExtensions));
fsf.show(getFragmentManager(), fragTag);
Callbacks
public void onConfirmSelect(String absolutePath, String fileName) {
if (absolutePath != null && fileName != null) {
myFileProcess(absolutePath, fileName);
}
}
public boolean isValid(String absolutePath, String fileName) {
return fileHeaderCheck(absolutePath, fileName);
}
The isValid callback is provided so that where a user is selecting from files with a common type but different internal layout (think XML) the application can run a quick sanity check before allowing the user to carry on.
An alternative would be to supply a custom FilenameFilter that only presents users with files of the correct internal format regardless of filename or extension; either by MIME type or by inspection of content. Care has to be taken with this approach to avoid blocking the UI thread.
Anatomy
There are only minor differences between the file save and the file select so we'll only discuss file save here.
The contents of the current directory are shown in a listview which allows us to scroll through the list. We will need the following for the user interface:
- An overall popup layout
- An array adapter that will display the content of each directory.
- A way to update the content of the listview when a user picks an item in a directory.
Overall Layout
The standard (and preferred) way of creating a user interface in Android is to use an XML layout file, but because we want to keep the number of files required to an absolute minimum (i.e. one per popup) the UI is defined in code in the popup's onCreateDialog
method. In outline we have:
- Create a vertical layout root view
- Add a listview and divider to the root view
- Add a horizontal layout for the file entry area to the root view
- Add the path, edit and default extension widgets to the horizontal layout view.
- Initial population of the listview and other widgets.
- Use
AlertDialog.Builder
to create the popup using the root view.
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
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);
currentDirectory = Environment.getExternalStorageDirectory();
directoryList = getSubDirectories(currentDirectory);
DirectoryDisplay displayFormat = new DirectoryDisplay(getActivity(), directoryList);
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));
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);
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);
if (defaultExtension != null ) {
TextView defaultExt = new TextView(getActivity());
defaultExt.setText(defaultExtension);
defaultExt.setGravity(Gravity.LEFT);
defaultExt.setPadding(2, 0, 6, 0);
nameArea.addView(defaultExt);
}
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) {
}
});
popupBuilder.setNegativeButton(resourceID_Cancel,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
mCallbacks.onConfirmSave(null, null);
}
});
return popupBuilder.create();
}
Display Adapter
This is a standard adapter which displays a single textview containing the directory's name for each File instance in the source list.
private class DirectoryDisplay
extends ArrayAdapter<File> { ...
Selecting A Directory
We need to pick up on the item selection in the listview. We do this by having the popup class implement OnItemClickListener
which will allow it to handle the user press/click in the listview.
public class FileSaveFragment extends DialogFragment
implements OnItemClickListener {
The item click handler link is established in the onCreateDialog
method.
directoryView.setOnItemClickListener(this);
The popup's onItemClick
callback handler retrieves the sub-directories for the selected directory, associates the list with a new display adapter and assigns that to the list view.
@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();
if (name.equals(PARENT)) {
currentDirectory = currentDirectory.getParentFile();
}
else {
currentDirectory = selected;
}
directoryList = getSubDirectories(currentDirectory);
DirectoryDisplay displayFormatter = new DirectoryDisplay(getActivity(), directoryList);
directoryView.setAdapter(displayFormatter);
String path = currentDirectory.getAbsolutePath();
if (currentDirectory.getParent() != null) {
path += "/";
}
currentPath.setText(path);
}
}
Retaining View During Validation
The setup for the onClickListener
for the positive button is moved to the AlertDialog::onStart
override. This allows us to call the AlertDialog::dismiss
method or not according to the return value of a validation callback.
In onCreate
the button is created with a NOP listener because it isn't possible to suppress the automatic call of dismiss from there.
popupBuilder.setPositiveButton(resourceID_OK,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
}
});
The onStart
override replaces the NOP listener with a listener which can suppress the dismiss call.
@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);
}
}
});
}
}
File Select Differences
The main difference is that the directory open and content population is moved to an onItemLongClick
handler and the onItemClick
handler selects a file within a directory. This makes it easy to distinguish between file selection and a request to change directory.
The file save popup doesn't bother with a file directory icon as there is no need to distinguish between a directory and a file. If you want to display a directory icon in the file save you will need to modify the DirectoryDisplay::getView
method as outlined below. The TextView widget makes it very easy to combine and image with text.
String name = fileList.get(position).getName();
textview.setText(name);
int iconID = resourceID_File;
if (fileList.get(position).isDirectory()) {
iconID = resourceID_Dir;
}
if (name.equals(PARENT)) {
iconID = -1;
}
if (iconID > 0 ){
Drawable icon = getActivity().getResources().getDrawable( iconID );
textview.setCompoundDrawablesWithIntrinsicBounds(icon,null, null, null );
}
Notes
The user has to click the [positive] button to confirm the selection for both save and select although it ought to be possible to select and dismiss automatically in an itemclick handler. This prevents incorrect selection problems where users have fat fingers and/or devices with less sensitive screens.
A parent directory is marked by a left point triangle, \u25C0. This was done to minimise the amount of set up required when using the popup. Recent reading suggests that this codepoint may not be supported in all Android implementations. An alternative would be to provide a drawable resource in the host application and hand the resource ID in at popup creation as is done for the popup's title icon.
I've used the 4_collections_view_as_grid and 4_collections_view_as_list icons from the holo light download as the icons for directory and file respectively in the file select. I think they work quite well and they are available in all required sizes without extra work. Lazy? Me?
The listview height is set at 150px because MATCH_PARENT gives a popup that varies in height from directory to directory. This value seems to give good results for a 7" tablet and 3" 'phone formats in emulation, but I'd be interested to hear of any better way of fixing the height.
Although both classes have a lot of common code I felt it would simplify re-use if they were kept completely separate. If you disagree please feel free to turn them into a single class.
Although intended for use with Android V4 onwards it should be possible to back port these classes to any version that has the fragment support library. This is left as an exercise for the interested reader.
History
Date | Version | Comment |
Jan. 2014 | 3 | Rewrite on addition of File Select. |
Jan. 2014 | 2 | Popup not dismissed if validation fails in File Save.
File Save
Validation helper methods added.
Spellcheck disabled in File Save for filename edit widget. |
Dec. 2013 | 1 | Version 1 for publication |