📜 ⬆️ ⬇️

Creating a convenient OpenFileDialog for Android

Probably, like many Android developers, I encountered the other day with the need to implement in my application file selection by the user. Since initially there is no such functionality in Android , I turned to the great and terrible . It seemed strange to me, but from a pile 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 .Net .
  2. This article is generally about a separate file manager, and it was not possible to get some ideas from it.
  3. I really liked the idea from here , however, it seemed to me to realize all this can be somewhat more beautiful.

As a result, having started to implement my decision, I ran into some difficulties that seemed very interesting to solve. 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 go through them -
So let's get started! In any familiar environment (I use IntelliJ IDEA ) we will create a new application. On the main activity we locate one single button and write to it, while empty, a click handler:
public void OnOpenFileClick(View view) { } 

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

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

The buttons seemed, now it would be necessary to find the files themselves. We start the search from 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 = new String[files.length]; for (int i = 0; i < files.length; i++) { result[i] = files[i].getName(); } return result; } 

(since the main requirement for the class is to work immediately with any developer, without connecting additional libraries, we will not use any google-collections , and we will have to work with arrays in the old fashion), and in the constructor we will add .setItems (getFiles ( currentPath), null) .

Well, not bad, but the files are not sorted. Implement the Adapter as an inner class for this case, replace setItems with setAdapter and rewrite getFiles a bit :
 private class FileAdapter extends ArrayAdapter<File> { public FileAdapter(Context context, List<File> files) { super(context, android.R.layout.simple_list_item_1, files); } @Override public 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>() { @Override public int compare(File file, File file2) { if (file.isDirectory() && file2.isFile()) return -1; else if (file.isFile() && file2.isDirectory()) return 1; else return 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, the 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>(); public OpenFileDialog(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); } private void RebuildFiles(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() { @Override public void onItemClick(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; } 

Great, just by clicking on the Android folder, we get a list of everything from one data directory, and our window is immediately reduced in size.

Perhaps this is normal, but I did not like it, and I began to look for opportunities to keep the size. The only option I found was to set setMinimumHeight . Setting this property for a listview caused additional problems, but they were resolved by wrapping it in LinearLayout :
  public OpenFileDialog(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 still turned out to be a little different from the way I would like: the dialog is maximized when starting, and decreases to 750px after switching to the Android directory. Yes, and 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:
  private static Display getDefaultDisplay(Context context) { return ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); } private static Point getScreenSize(Context context) { Point screeSize = new Point(); getDefaultDisplay(context).getSize(screeSize); return screeSize; } private static int getLinearLayoutMinHeight(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 of the fact that we set the full screen size in setMinimumHeight , the system itself will reduce the value to the maximum allowed.
Now there is a problem of understanding the user in which directory he is now, and returning up. Let's deal with the first. It seems everything is easy - set the value of title in currentPath and change it when the last one changes. Add a call to setTitle (currentPath) in the constructor and in the RebuildFiles method.

It seems all is well. Go to the Android directory:

And no - the title has not changed. Why setTitle does not work after showing the dialog, the documentation is silent. However, we can fix this by creating our own title and replacing it with a standard one:
 private TextView title; public OpenFileDialog(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); } private int getItemHeight(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; } private void RebuildFiles(ArrayAdapter<File> adapter) { files.clear(); files.addAll(getFiles(currentPath)); adapter.notifyDataSetChanged(); title.setText(currentPath); } 

And again, all is not well: if you go far enough, the line in the title will not get

The solution to setting setMaximumWidth is not true, since the user will only see the beginning of a long path. I do not know how correct my decision is, but I did this:
 public int getTextWidth(String text, Paint paint) { Rect bounds = new Rect(); paint.getTextBounds(text, 0, text.length(), bounds); return bounds.left + bounds.width() + 80; } private void changeTitle() { 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); } } 

We now solve the problem with the return. This is fairly easy, given that we have a LinearLayout . Add another TextView to it and rebuild the code a bit:
  private ListView listView; public OpenFileDialog(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() { @Override public void onClick(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 up can lead the user to directories that he is denied access to, so we ’ll change the RebuildFiles function:
  private void RebuildFiles(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, but soon we will add to the developer the opportunity to fix it).
No OpenFileDialog can do without a filter. Add it too:
  private FilenameFilter filenameFilter; public OpenFileDialog setFilter(final String filter) { filenameFilter = new FilenameFilter() { @Override public boolean accept(File file, String fileName) { File tempFile = new File(String.format("%s/%s", file.getPath(), fileName)); if (tempFile.isFile()) return tempFile.getName().matches(filter); return true; } }; return this; } 

 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 assigning the filter. Let's transfer it to the redefined show method:
  public OpenFileDialog(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); } @Override public AlertDialog show() { files.addAll(getFiles(currentPath)); listView.setAdapter(new FileAdapter(getContext(), files)); return super.show(); } 

It remains quite a bit: return the selected file. Again, I still do not understand why you need to install CHOICE_MODE_SINGLE , and then still write extra code to highlight the selected element, when it (the code) will work without CHOICE_MODE_SINGLE , and therefore do without it:
 private int selectedIndex = -1; 

  @Override public 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; } 

  private void RebuildFiles(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() { @Override public void onItemClick(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 the listener interface:
  public interface OpenDialogListener{ public void OnSelectedFile(String fileName); } private OpenDialogListener listener; public OpenFileDialog setOpenDialogListener(OpenDialogListener listener) { this.listener = listener; return this; } 

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

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

A few final improvements:
  private Drawable folderIcon; private Drawable fileIcon; private String accessDeniedMessage; public OpenFileDialog setFolderIcon(Drawable drawable){ this.folderIcon = drawable; return this; } public OpenFileDialog setFileIcon(Drawable drawable){ this.fileIcon = drawable; return this; } public OpenFileDialog setAccessDeniedMessage(String message) { this.accessDeniedMessage = message; return this; } private void RebuildFiles(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(); } } 

  @Override public 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; } private void setDrawable(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 a few problems that I still could not solve, and would be grateful for any help:
  1. Highlighting clicking on the item "Up". It seems to be solved by setting the setBackgroundResource value to android.R.drawable.list_selector_background , but this is the android 2.x style, 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. The full code is here .

')

Source: https://habr.com/ru/post/203884/


All Articles