Create a convenient OpenFileDialog for Android

Probably, like many developers for Android , the other day I encountered the need to implement a user’s file selection in their application. Since initially there was no such functionality in Android , I turned to the great and terrible . It seemed strange to me, but from a bunch of questions on stackoverflow and a small number of domestic forums, there are only three main sources:
  1. Android File Dialog - almost all links from stackoverflow lead here. In principle, a good solution, but implemented through a separate activity , but I wanted something in the spirit of OpenFileDialog from the .Net .
  2. In this article we are talking generally about a specific file manager, and glean some ideas could not be out of it.
  3. From here I liked the idea very much, however, as it seemed to me to realize all this can be somewhat more beautiful.

As a result, starting to implement my decision, I ran into some difficulties to solve which seemed very interesting. And therefore, I decided to describe in this article not just a ready-made solution, but all the steps that led to it. Those who want to pass them together -
So let's get started! In any familiar environment (I use IntelliJ IDEA ) create a new application. On the main activity , we will place one single button and write to it, while empty, the click handler:
publicvoidOnOpenFileClick(View view){
    }

Create a new class with a constructor:
import android.app.AlertDialog;
import android.content.Context;
publicclassOpenFileDialogextendsAlertDialog.Builder{
    publicOpenFileDialog(Context context){
        super(context);
        setPositiveButton(android.R.string.ok, null)
                .setNegativeButton(android.R.string.cancel, null);
    }
}

and in the button handler we call the dialog:
OpenFileDialog fileDialog = new OpenFileDialog(this);
fileDialog.show();

Buttons appeared, now it would be necessary to find the files themselves. We start the search with the root sdcard , for which we define the field:
private String currentPath = Environment.getExternalStorageDirectory().getPath();

and implement the following method:
    private String[] getFiles(String directoryPath){
        File directory = new File(directoryPath);
        File[] files = directory.listFiles();
        String[] result = newString[files.length];
        for (int i = 0; i < files.length; i++) {
            result[i] = files[i].getName();
        }
        return result;
    } 

(since the main requirement for the class - work directly from any developer, without additional libraries - then no google-collections will not use, and need to work with arrays the old fashioned way), but in the constructor to call setNegativeButton add .setItems (getFiles ( currentPath), null) .

Well, not bad, but the files are not sorted. We will implement Adapter for this case as an inner class, replace setItems with setAdapter and rewrite getFiles a bit :
privateclassFileAdapterextendsArrayAdapter<File> {
        publicFileAdapter(Context context, List<File> files){
            super(context, android.R.layout.simple_list_item_1, files);
        }
        @Overridepublic View getView(int position, View convertView, ViewGroup parent){
            TextView view = (TextView) super.getView(position, convertView, parent);
            File file = getItem(position);
            view.setText(file.getName());
            return view;
        }
    }

.setAdapter(new FileAdapter(context, getFiles(currentPath)), null)

private List<File> getFiles(String directoryPath){
        File directory = new File(directoryPath);
        List<File> fileList = Arrays.asList(directory.listFiles());
        Collections.sort(fileList, new Comparator<File>() {
            @Overridepublicintcompare(File file, File file2){
                if (file.isDirectory() && file2.isFile())
                    return -1;
                elseif (file.isFile() && file2.isDirectory())
                    return1;
                elsereturn file.getPath().compareTo(file2.getPath());
            }
        });
        return fileList;
    }

Even better, but we need to go inside by clicking on the folder. You can reach the built-in listview , but I just replaced it with my own (this will come in handy later). Plus, adapter's changes inside the listview handler caused an exception , and the list of files had to be moved to a separate field:
private List<File> files = new ArrayList<File>();
    publicOpenFileDialog(Context context){
        super(context);
        files.addAll(getFiles(currentPath));
        ListView listView = createListView(context);
        listView.setAdapter(new FileAdapter(context, files));
        setView(listView)
                .setPositiveButton(android.R.string.ok, null)
                .setNegativeButton(android.R.string.cancel, null);
    }
    privatevoidRebuildFiles(ArrayAdapter<File> adapter){
        files.clear();
        files.addAll(getFiles(currentPath));
        adapter.notifyDataSetChanged();
    }
    private ListView createListView(Context context){
        ListView listView = new ListView(context);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @OverridepublicvoidonItemClick(AdapterView<?> adapterView, View view, int index, long l){
                final ArrayAdapter<File> adapter = (FileAdapter) adapterView.getAdapter();
                File file = adapter.getItem(index);
                if (file.isDirectory()) {
                    currentPath = file.getPath();
                    RebuildFiles(adapter);
                } 
            }
        });
        return listView;
    }

Well, just by clicking on the Android folder we get a list of just one data directory , and our window immediately decreases in size.

Perhaps this is normal, but I did not like it, and I began to look for ways to save the size. The only option I found is setting setMinimumHeight . Setting this property for listview caused additional problems, but they decided to wrap it in LinearLayout :
publicOpenFileDialog(Context context){
        super(context);
        LinearLayout linearLayout = createMainLayout(context);
        files.addAll(getFiles(currentPath));
        ListView listView = createListView(context);
        listView.setAdapter(new FileAdapter(context, files));
        linearLayout.addView(listView);
        setView(linearLayout)
                .setPositiveButton(android.R.string.ok, null)
                .setNegativeButton(android.R.string.cancel, null);
    }
    private LinearLayout createMainLayout(Context context){
        LinearLayout linearLayout = new LinearLayout(context);
        linearLayout.setOrientation(LinearLayout.VERTICAL);
        linearLayout.setMinimumHeight(750);
        return linearLayout;
    }

The result, anyway, turned out to be a little different than we would like: at startup the dialogue is expanded to full screen, and after going to the Android directory it decreases to 750px . Moreover, the screens of different devices have different heights. We will solve both of these problems at once by setting setMinimumHeight to the maximum possible for the current screen:
privatestatic Display getDefaultDisplay(Context context){
        return ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
    }
    privatestatic Point getScreenSize(Context context){
        Point screeSize = new Point();
        getDefaultDisplay(context).getSize(screeSize);
        return screeSize;
    }
    privatestaticintgetLinearLayoutMinHeight(Context context){
        return getScreenSize(context).y;
    }
    private LinearLayout createMainLayout(Context context){
        LinearLayout linearLayout = new LinearLayout(context);
        linearLayout.setOrientation(LinearLayout.VERTICAL);
        linearLayout.setMinimumHeight(getLinearLayoutMinHeight(context));
        return linearLayout;
    }

No need to be afraid that we set the full size of the screen to setMinimumHeight , the system itself will reduce the value to the maximum allowable.
Now there is the problem of understanding the user in which directory he is currently located, and returning up. Let's deal with the first one. Everything seems to be easy - set the title value to currentPath and change it when the latter changes. Add a setTitle (currentPath) call to the constructor and RebuildFiles method .

Everything seems to be fine. Let's go to the Android directory :

But no - the title has not changed. Why setTitle does not work after showing a dialog, the documentation is silent. However, we can fix this by creating our own header and replacing it with the standard one:
private TextView title;
publicOpenFileDialog(Context context){
        super(context);
        title = createTitle(context);
        LinearLayout linearLayout = createMainLayout(context);
        files.addAll(getFiles(currentPath));
        ListView listView = createListView(context);
        listView.setAdapter(new FileAdapter(context, files));
        linearLayout.addView(listView);
        setCustomTitle(title)
                .setView(linearLayout)
                .setPositiveButton(android.R.string.ok, null)
                .setNegativeButton(android.R.string.cancel, null);
    }
    privateintgetItemHeight(Context context){
        TypedValue value = new TypedValue();
        DisplayMetrics metrics = new DisplayMetrics();
        context.getTheme().resolveAttribute(android.R.attr.rowHeight, value, true);
        getDefaultDisplay(context).getMetrics(metrics);
        return (int)TypedValue.complexToDimension(value.data, metrics);
    }
    private TextView createTitle(Context context){
        TextView textView = new TextView(context);
        textView.setTextAppearance(context, android.R.style.TextAppearance_DeviceDefault_DialogWindowTitle);
        int itemHeight = getItemHeight(context);
        textView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeight));
        textView.setMinHeight(itemHeight);
        textView.setGravity(Gravity.CENTER_VERTICAL);
        textView.setPadding(15, 0, 0, 0);
        textView.setText(currentPath);
        return textView;
    }
    privatevoidRebuildFiles(ArrayAdapter<File> adapter){
        files.clear();
        files.addAll(getFiles(currentPath));
        adapter.notifyDataSetChanged();
        title.setText(currentPath);
    }

And again, not everything is all right: if you go far enough, then the line will not fit into the header

The solution with setting setMaximumWidth is not true, as the user will only see the beginning of a long path. I don’t know how true my decision is, but I did this:
publicintgetTextWidth(String text, Paint paint){
        Rect bounds = new Rect();
        paint.getTextBounds(text, 0, text.length(), bounds);
        return bounds.left + bounds.width() + 80;
    }
    privatevoidchangeTitle(){
        String titleText = currentPath;
        int screenWidth = getScreenSize(getContext()).x;
        int maxWidth = (int) (screenWidth * 0.99);
        if (getTextWidth(titleText, title.getPaint()) > maxWidth) {
            while (getTextWidth("..." + titleText, title.getPaint()) > maxWidth)
            {
                int start = titleText.indexOf("/", 2);
                if (start > 0)
                    titleText = titleText.substring(start);
                else
                    titleText = titleText.substring(2);
            }
            title.setText("..." + titleText);
        } else {
            title.setText(titleText);
        }
    }

Now we will solve the problem with the return. This is easy enough considering that we have LinearLayout . Add another TextView to it and refactor the code a bit:
private ListView listView;
    publicOpenFileDialog(Context context){
        super(context);
        title = createTitle(context);
        changeTitle();
        LinearLayout linearLayout = createMainLayout(context);
        linearLayout.addView(createBackItem(context));
        files.addAll(getFiles(currentPath));
        listView = createListView(context);
        listView.setAdapter(new FileAdapter(context, files));
        linearLayout.addView(listView);
        setCustomTitle(title)
                .setView(linearLayout)
                .setPositiveButton(android.R.string.ok, null)
                .setNegativeButton(android.R.string.cancel, null);
    } 
    private TextView createTextView(Context context, int style){
        TextView textView = new TextView(context);
        textView.setTextAppearance(context, style);
        int itemHeight = getItemHeight(context);
        textView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeight));
        textView.setMinHeight(itemHeight);
        textView.setGravity(Gravity.CENTER_VERTICAL);
        textView.setPadding(15, 0, 0, 0);
        return textView;
    }
    private TextView createTitle(Context context){
        TextView textView = createTextView(context, android.R.style.TextAppearance_DeviceDefault_DialogWindowTitle);
        return textView;
    }
        private TextView createBackItem(Context context){
        TextView textView = createTextView(context, android.R.style.TextAppearance_DeviceDefault_Small);
        Drawable drawable = getContext().getResources().getDrawable(android.R.drawable.ic_menu_directions);
        drawable.setBounds(0, 0, 60, 60);
        textView.setCompoundDrawables(drawable, null, null, null);
        textView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        textView.setOnClickListener(new View.OnClickListener() {
            @OverridepublicvoidonClick(View view){
                File file = new File(currentPath);
                File parentDirectory = file.getParentFile();
                if (parentDirectory != null) {
                    currentPath = parentDirectory.getPath();
                    RebuildFiles(((FileAdapter) listView.getAdapter()));
                }
            }
        });
        return textView;
    } 


The ability to go back one step can lead the user to directories to which access is denied, therefore we will change the RebuildFiles function :
privatevoidRebuildFiles(ArrayAdapter<File> adapter){
        try{
            List<File> fileList = getFiles(currentPath);
            files.clear();
            files.addAll(fileList);
            adapter.notifyDataSetChanged();
            changeTitle();
        } catch (NullPointerException e){
            Toast.makeText(getContext(), android.R.string.unknownName, Toast.LENGTH_SHORT).show();
        }
    }

(The message is not very informative so far, but soon we will add to the developer the ability to fix this).
No OpenFileDialog can do without a filter. Add it as well:
private FilenameFilter filenameFilter;
    public OpenFileDialog setFilter(final String filter){
        filenameFilter = new FilenameFilter() {
            @Overridepublicbooleanaccept(File file, String fileName){
                File tempFile = new File(String.format("%s/%s", file.getPath(), fileName));
                if (tempFile.isFile())
                    return tempFile.getName().matches(filter);
                returntrue;
            }
        };
        returnthis;
    }

List<File> fileList = Arrays.asList(directory.listFiles(filenameFilter));

new OpenFileDialog(this).setFilter(".*\\.txt");

Note that the filter accepts a regular expression. It would seem that everything is fine, but the first selection of files will work in the constructor, before the filter is assigned. Transfer it to the redefined show method :
publicOpenFileDialog(Context context){
        super(context);
        title = createTitle(context);
        changeTitle();
        LinearLayout linearLayout = createMainLayout(context);
        linearLayout.addView(createBackItem(context));
        listView = createListView(context);
        linearLayout.addView(listView);
        setCustomTitle(title)
                .setView(linearLayout)
                .setPositiveButton(android.R.string.ok, null)
                .setNegativeButton(android.R.string.cancel, null);
    }
    @Overridepublic AlertDialog show(){
        files.addAll(getFiles(currentPath));
        listView.setAdapter(new FileAdapter(getContext(), files));
        returnsuper.show();
    }

It remains just a little bit: return the selected file. Again, I still don’t understand why you need to set CHOICE_MODE_SINGLE , and then still write extra code to highlight the selected item when it (the code) will work without CHOICE_MODE_SINGLE anyway , so we can do without it:
privateint selectedIndex = -1;

@Overridepublic View getView(int position, View convertView, ViewGroup parent){
            TextView view = (TextView) super.getView(position, convertView, parent);
            File file = getItem(position);
            view.setText(file.getName());
            if (selectedIndex == position)
                view.setBackgroundColor(getContext().getResources().getColor(android.R.color.holo_blue_light));
            else
                view.setBackgroundColor(getContext().getResources().getColor(android.R.color.background_dark));
            return view;
        }

privatevoidRebuildFiles(ArrayAdapter<File> adapter){
        try{
            List<File> fileList = getFiles(currentPath);
            files.clear();
            selectedIndex = -1;
            files.addAll(fileList);
            adapter.notifyDataSetChanged();
            changeTitle();
        } catch (NullPointerException e){
            Toast.makeText(getContext(), android.R.string.unknownName, Toast.LENGTH_SHORT).show();
        }
    }
    private ListView createListView(Context context){
        ListView listView = new ListView(context);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @OverridepublicvoidonItemClick(AdapterView<?> adapterView, View view, int index, long l){
                final ArrayAdapter<File> adapter = (FileAdapter) adapterView.getAdapter();
                File file = adapter.getItem(index);
                if (file.isDirectory()) {
                    currentPath = file.getPath();
                    RebuildFiles(adapter);
                } else {
                    if (index != selectedIndex)
                        selectedIndex = index;
                    else
                        selectedIndex = -1;
                    adapter.notifyDataSetChanged();
                }
            }
        });
        return listView;
    }

And create a listener interface:
publicinterfaceOpenDialogListener{
        publicvoidOnSelectedFile(String fileName);
    }
    private OpenDialogListener listener;
    public OpenFileDialog setOpenDialogListener(OpenDialogListener listener){
        this.listener = listener;
        returnthis;
    }

…
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                    @OverridepublicvoidonClick(DialogInterface dialog, int which){
                        if (selectedIndex > -1 && listener != null) {
                            listener.OnSelectedFile(listView.getItemAtPosition(selectedIndex).toString());
                        }
                    }
                })
…

Well, let's change the call:
    OpenFileDialog fileDialog = new OpenFileDialog(this)
                    .setFilter(".*\\.csv")
                    .setOpenDialogListener(new OpenFileDialog.OpenDialogListener() {
                        @OverridepublicvoidOnSelectedFile(String fileName){
                            Toast.makeText(getApplicationContext(), fileName, Toast.LENGTH_LONG).show();
                        }
                    });
            fileDialog.show();

A few improvements in the end:
private Drawable folderIcon;
    private Drawable fileIcon;
    private String accessDeniedMessage;
    public OpenFileDialog setFolderIcon(Drawable drawable){
        this.folderIcon = drawable;
        returnthis;
    }
    public OpenFileDialog setFileIcon(Drawable drawable){
        this.fileIcon = drawable;
        returnthis;
    }
    public OpenFileDialog setAccessDeniedMessage(String message){
        this.accessDeniedMessage = message;
        returnthis;
    }
    privatevoidRebuildFiles(ArrayAdapter<File> adapter){
        try{
            List<File> fileList = getFiles(currentPath);
            files.clear();
            selectedIndex = -1;
            files.addAll(fileList);
            adapter.notifyDataSetChanged();
            changeTitle();
        } catch (NullPointerException e){
            String message = getContext().getResources().getString(android.R.string.unknownName);
            if (!accessDeniedMessage.equals(""))
                message = accessDeniedMessage;
            Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show();
        }
    }

@Overridepublic View getView(int position, View convertView, ViewGroup parent){
            TextView view = (TextView) super.getView(position, convertView, parent);
            File file = getItem(position);
            view.setText(file.getName());
            if (file.isDirectory()) {
                setDrawable(view, folderIcon);
            } else {
                setDrawable(view, fileIcon);
                if (selectedIndex == position)
                    view.setBackgroundColor(getContext().getResources().getColor(android.R.color.holo_blue_dark));
                else
                    view.setBackgroundColor(getContext().getResources().getColor(android.R.color.transparent));
            }
            return view;
        }
        privatevoidsetDrawable(TextView view, Drawable drawable){
            if (view != null){
                if (drawable != null){
                    drawable.setBounds(0, 0, 60, 60);
                    view.setCompoundDrawables(drawable, null, null, null);
                } else {
                    view.setCompoundDrawables(null, null, null, null);
                }
            }
        }

There are several problems that I still could not solve, and I would be grateful for any help:
  1. Highlighting the click on the “Up” item. It seems to be solved by setting setBackgroundResource to the value of android.R.drawable.list_selector_background , but this is the style of android 2.x , not holo !
  2. The color of the file selection depending on the theme chosen by the user.

I also look forward to any comments and suggestions. Full code here .

Also popular now: