For very long lists, use a searchable list view

Stephen Paul Weber created

Change summary

src/cheogram/res/layout/command_search_list_field.xml           | 47 +
src/main/java/eu/siacs/conversations/entities/Conversation.java | 77 ++
2 files changed, 123 insertions(+), 1 deletion(-)

Detailed changes

src/cheogram/res/layout/command_search_list_field.xml 🔗

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <LinearLayout
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:paddingBottom="16dp"
+        android:orientation="vertical">
+
+        <TextView
+            android:id="@+id/label"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:paddingLeft="13dp"
+            android:paddingRight="8dp"
+            android:paddingBottom="8dp"
+            android:textAppearance="@style/TextAppearance.Conversations.Subhead"
+            android:textColor="?attr/edit_text_color" />
+
+        <EditText
+            android:id="@+id/search"
+            style="@style/Widget.Conversations.EditText"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="8dp"
+            android:layout_marginRight="8dp"
+            android:ems="10"
+            android:imeOptions="actionNext"
+            android:minLines="1" />
+
+        <ListView
+            android:id="@+id/list"
+            android:layout_width="match_parent"
+            android:layout_height="200dp"
+            android:choiceMode="singleChoice" />
+
+        <TextView
+            android:id="@+id/desc"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:paddingLeft="16dp"
+            android:paddingRight="8dp"
+            android:textAppearance="@style/TextAppearance.Conversations.Status"
+            android:textColor="?android:textColorSecondary" />
+
+    </LinearLayout>
+</layout>

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -57,6 +57,7 @@ import java.util.List;
 import java.util.ListIterator;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
 import java.util.Timer;
 import java.util.TimerTask;
 
@@ -71,6 +72,7 @@ import eu.siacs.conversations.databinding.CommandResultCellBinding;
 import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
 import eu.siacs.conversations.databinding.CommandProgressBarBinding;
 import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
+import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
 import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
 import eu.siacs.conversations.databinding.CommandTextFieldBinding;
 import eu.siacs.conversations.databinding.CommandWebviewBinding;
@@ -1534,6 +1536,72 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 }
             }
 
+            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
+                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
+                    super(binding);
+                    binding.search.addTextChangedListener(this);
+                }
+                protected Element mValue = null;
+                List<Option> options = new ArrayList<>();
+                protected ArrayAdapter<Option> adapter;
+                protected boolean open;
+
+                @Override
+                public void bind(Item item) {
+                    Field field = (Field) item;
+                    setTextOrHide(binding.label, field.getLabel());
+                    setTextOrHide(binding.desc, field.getDesc());
+
+                    if (field.error != null) {
+                        binding.desc.setVisibility(View.VISIBLE);
+                        binding.desc.setText(field.error);
+                        binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Design_Error);
+                    } else {
+                        binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Status);
+                    }
+
+                    mValue = field.getValue();
+
+                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
+                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
+                    setupInputType(field.el, binding.search, null);
+
+                    options = field.getOptions();
+                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
+                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
+                        if (open) binding.search.setText(mValue.getContent());
+                    });
+                    search("");
+                }
+
+                @Override
+                public void afterTextChanged(Editable s) {
+                    if (open) mValue.setContent(s.toString());
+                    search(s.toString());
+                }
+
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+
+                @Override
+                public void onTextChanged(CharSequence s, int start, int count, int after) { }
+
+                protected void search(String s) {
+                    List<Option> filteredOptions;
+                    final String q = s.replaceAll("\\W", "").toLowerCase();
+                    if (q == null || q.equals("")) {
+                        filteredOptions = options;
+                    } else {
+                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
+                    }
+                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
+                    binding.list.setAdapter(adapter);
+
+                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
+                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
+                }
+            }
+
             class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
                 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
                     super(binding);
@@ -1821,7 +1889,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                             viewType = TYPE_CHECKBOX_FIELD;
                         } else if (fieldType.equals("list-single")) {
                             Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
-                            if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
+                            if (Option.forField(el).size() > 9) {
+                                viewType = TYPE_SEARCH_LIST_FIELD;
+                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
                                 viewType = TYPE_RADIO_EDIT_FIELD;
                             } else {
                                 viewType = TYPE_SPINNER_FIELD;
@@ -1873,6 +1943,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             final int TYPE_RADIO_EDIT_FIELD = 8;
             final int TYPE_RESULT_CELL = 9;
             final int TYPE_PROGRESSBAR = 10;
+            final int TYPE_SEARCH_LIST_FIELD = 11;
 
             protected boolean loading = false;
             protected Timer loadingTimer = new Timer();
@@ -2122,6 +2193,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                         CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
                         return new CheckboxFieldViewHolder(binding);
                     }
+                    case TYPE_SEARCH_LIST_FIELD: {
+                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
+                        return new SearchListFieldViewHolder(binding);
+                    }
                     case TYPE_RADIO_EDIT_FIELD: {
                         CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
                         return new RadioEditFieldViewHolder(binding);