Categories UI & UX

How to implement Chips with autocomplete text view for Android

23 Mar. 2019
2
0
28 minutes

Material Design is a set of guides for visual, interaction and motion design that enhance your engineering workflow. Google provides developers with many Material Design Components to build intuitive and beautiful applications. Material Design Chips are part of these components. These components are compact elements that represent an action, attribute, or input. Material Design Chips should represent discrete information with a clear relationship with the context. These components should make tasks easier to complete and enhance the User Experience.

This post describes how to implement Material Design Chips alongsideAutoCompleteTextView without external libraries. The most obvious use case is in text fields that contain contact chips. Indeed, a lot of communication apps, including Gmail app and SMS apps, have this feature.

This course contains advanced notions related to Material Design Chips. Therefore, it is recommended to read the course devoted to the implementation of Material Design Chips first:

Demonstration

This post focuses on the implementation of Contact Chips with an autocomplete text view.

Contact Chips with AutoCompleteTextView

Video

Setup

First and foremost, you need to add Material Components library to the dependencies section in your gradle file :

dependencies {
    // ...
    implementation 'com.google.android.material:material:1.1.0-alpha04'
    // ...
  }

This library comes along the newly package Androidx. If your app relies on the original Design Support library, you can migrate your project to Androidx using the option provided by Android Studio.

If you want to keep the original Design Support library, you can also use Material Components through this library :

dependencies {
    // ...
    implementation 'com.android.support:design:28.0.0'
    // ...
  }
You should not use the com.android.support and com.google.android.material dependencies in your app at the same time.

Check you are using AppCompatActivity to ensure that all the Material Components work correctly.

Finally, change your app theme to inherit from a Material Components Theme among the following list :

  • Theme.MaterialComponent
  • Theme.MaterialComponents.NoActionBar
  • Theme.MaterialComponents.Light
  • Theme.MaterialComponents.Light.NoActionBar
  • Theme.MaterialComponents.Light.DarkActionBar

The Theme.MaterialComponents.Light is the theme used in the project.

<style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
</style>

Implementation

The implementation of Contact Chips with an AutoCompleteTextView can be divided in four steps:

  • Data class creation
  • Layout setup
  • AutoCompleteTextView setup
  • Contact Chip creation

Data class creation

We need a data class that represents a Contact. In our project, a contact has a name and an avatar.

data class Contact(val name: String, val avatarResource: Int)
public class Contact {
    private String name;
    private int avatarResource;

    public Contact(String name, int avatarResource) {
        this.name = name;
        this.avatarResource = avatarResource;
    }

    public String getName() {
        return name;
    }

    public int getAvatarResource() {
        return avatarResource;
    }
}

AutoCompleteTextView setup

An AutoCompleteTextView is an editable text view that shows completion suggestions automatically as the user is typing. These suggestions are displayed in a drop-down menu from which the user can select a suggestion to replace the context of the edit box.

AutoCompleteTextView

The problem with AutoCompleteTextView is that it only offers suggestions about the whole sentence so this component is inappropriate to handle multiple contacts. Fortunately, another component is appropriate to this case: MultiAutoCompleteTextView. It offers suggestions for the substring of the text where the user is typing instead of the whole sentence. Substrings can be distinguished using a Tokenizer.

<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <androidx.appcompat.widget.AppCompatTextView
            android:id="@+id/recipient_label"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingStart="@dimen/medium_padding"
            android:paddingLeft="@dimen/medium_padding"
            android:paddingTop="@dimen/big_padding"
            android:paddingBottom="@dimen/big_padding"
            android:paddingRight="@dimen/medium_padding"
            android:text="@string/recipient_label"
            android:textSize="16sp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

    <androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
            android:id="@+id/recipient_auto_complete_text_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:background="@android:color/transparent"
            app:layout_constraintBaseline_toBaselineOf="@id/recipient_label"
            app:layout_constraintLeft_toRightOf="@id/recipient_label"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:paddingTop="@dimen/medium_padding"
            android:paddingBottom="@dimen/medium_padding"
            android:dropDownAnchor="@id/divider"/>

    <androidx.constraintlayout.widget.Barrier
            android:id="@+id/barrier"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:barrierDirection="bottom"
app:constraint_referenced_ids="recipient_label,recipient_auto_complete_text_view"/>

    <View
            android:id="@+id/divider"
            android:layout_width="match_parent"
            android:layout_height="@dimen/divider_thickness"
            android:layout_marginBottom="@dimen/big_margin"
            android:background="@color/divider_color"
            android:paddingBottom="@dimen/big_padding"
            app:layout_constraintTop_toBottomOf="@id/barrier"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Once the layout is created, we can create the adapter that will act as the bridge between the MultiAutoCompleteTextView and the data source.

This adapter is an ArrayAdapter. Five methods must be overriden:

  • getView: Returns a View that displays the data at the specified position in the data set
  • getItem: Returns the data item associated with the specified position in the data set.
  • getCount: Returns the item count represented by this adapter.
  • getItemId: Returns the row id associated with the specified position in the list.
  • getFilter: Returns a filter that can be used to constrain data with a filtering pattern.
class ContactAdapter(context: Context, private val resourceId: Int, private val items: List<Contact>) :
    ArrayAdapter<Contact>(context, resourceId, items) {

    private var tempItems = ArrayList<Contact>(items)
    private var suggestions = ArrayList<Contact>()
    private var query = ""

    /**
     * Custom filter that filters contacts according to their name.
     */
    private val contactFilter: Filter? = object : Filter() {

        override fun convertResultToString(resultValue: Any?): CharSequence {
            val contact = resultValue as Contact
            return contact.name
        }

        override fun performFiltering(constraint: CharSequence?): FilterResults {
            val filterResults = Filter.FilterResults()

            if(constraint != null) {
                query = constraint.toString()
                suggestions.clear()

                for(contact in tempItems) {
                    if(contact.name.toLowerCase().startsWith(query.toLowerCase())) {
                        suggestions.add(contact)
                    }
                }
            }

            filterResults.values = suggestions
            filterResults.count = suggestions.size

            return filterResults
        }

        override fun publishResults(constraint: CharSequence?, results: FilterResults) {
            val contacts = results.values as ArrayList<Contact>

            clear()

            if(results.count > 0 ) {
                for(contact in contacts) {
                    add(contact)
                }
            }

            notifyDataSetChanged()
        }
    }

    /**
     * Get a View that displays the data at the specified position in the data set.
     *
     * @param position position of the item within the adapter's data set of the item whose view we want
     * @param convertView the old view to reuse, if possible
     * @param parent the parent that this view will eventually be attached to
     * @return view corresponding to the data at the specified position
     */
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View? {
        var view = convertView

        if(convertView == null) {
            val inflater = (context as Activity).layoutInflater
            view = inflater.inflate(resourceId, parent, false)
        }

        view?.let { v ->
            val contact = getItem(position)
            val contactNameView = v.contact_name
            val contactAvatarView = v.contact_avatar

            if(contact != null) {
                contactAvatarView.setImageResource(contact.avatarResource)

                // Highlight the typed query in the suggestions
                val builder = SpannableStringBuilder(contact.name)

                builder.setSpan(
                    ForegroundColorSpan(Color.BLACK), 0, query.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
                )

                builder.setSpan(
                    StyleSpan(Typeface.BOLD), 0, query.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
                )

                contactNameView.text = builder
            }
        }

        return view
    }

    /**
     * Returns the data item associated with the specified position in the data set.
     * @param position position of the item within the adapter's data set of the item we want
     * @return item at the specified position
     */
    override fun getItem(position: Int): Contact? {
        return items[position]
    }

    /**
     * Returns the item count represented by this adapter.
     * @return item count
     */
    override fun getCount(): Int {
        return items.size
    }

    /**
     * Returns the row id associated with the specified position in the list.
     * @param position position of the item within the adapter's data set of the item whose id we want
     * @return item id
     */
    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    /**
     *  Returns a filter that can be used to constrain data with a filtering pattern.
     * @return filter
     */
    override fun getFilter(): Filter? {
        return contactFilter
    }
}
public class ContactAdapter extends ArrayAdapter<Contact> {
    private Context context;
    private int resourceId;
    private List<Contact> items, tempItems, suggestions;
    private String query = "";

    public ContactAdapter(@NonNull Context context, int resourceId, List<Contact> items) {
        super(context, resourceId, items);
        this.items = items;
        this.context = context;
        this.resourceId = resourceId;
        tempItems = new ArrayList<>(items);
        suggestions = new ArrayList<>();
    }

    /**
     * Get a View that displays the data at the specified position in the data set.
     *
     * @param position position of the item within the adapter's data set of the item whose view we want
     * @param convertView the old view to reuse, if possible
     * @param parent the parent that this view will eventually be attached to
     * @return view corresponding to the data at the specified position
     */
    @NonNull
    @Override
    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
        View view = convertView;

        if (convertView == null) {
            LayoutInflater inflater = ((Activity) context).getLayoutInflater();
            view = inflater.inflate(resourceId, parent, false);
        }

        Contact contact = getItem(position);
        TextView contactName = view.findViewById(R.id.contact_name);
        ImageView contactAvatar = view.findViewById(R.id.contact_avatar);

        if (contact != null) {
            contactAvatar.setImageResource(contact.getAvatarResource());

            SpannableStringBuilder builder = new SpannableStringBuilder(contact.getName());
            // set foreground color (text color) - optional, you may not want to change the text color too
            builder.setSpan(new ForegroundColorSpan(Color.BLACK), 0, query.length(),
                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            builder.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, query.length(),
                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            contactName.setText(builder);
        }

        return view;
    }

    /**
     * Returns the data item associated with the specified position in the data set.
     * @param position position of the item within the adapter's data set of the item we want
     * @return item at the specified position
     */
    @Nullable
    @Override
    public Contact getItem(int position) {
        return items.get(position);
    }

    /**
     * Returns the item count represented by this adapter.
     * @return item count
     */
    @Override
    public int getCount() {
        return items.size();
    }

    /**
     * Returns the row id associated with the specified position in the list.
     * @param position position of the item within the adapter's data set of the item whose id we want
     * @return item id
     */
    @Override
    public long getItemId(int position) {
        return position;
    }

    /**
     *  Returns a filter that can be used to constrain data with a filtering pattern.
     * @return filter
     */
    @NonNull
    @Override
    public Filter getFilter() {
        return contactFilter;
    }

    /**
     * Custom filter that filters contacts according to their name. 
     */
    private Filter contactFilter = new Filter() {
        @Override
        public CharSequence convertResultToString(Object resultValue) {
            Contact contact = (Contact) resultValue;
            return contact.getName();
        }

        @Override
        protected FilterResults performFiltering(CharSequence charSequence) {
            FilterResults filterResults = new FilterResults();

            if (charSequence != null) {
                query = charSequence.toString();
                suggestions.clear();

                for (Contact contact : tempItems) {
                    if (contact.getName().toLowerCase()
                            .startsWith(charSequence.toString().toLowerCase())) {
                        suggestions.add(contact);
                    }
                }

                filterResults.values = suggestions;
                filterResults.count = suggestions.size();
            }

            return filterResults;
        }

        @Override
        protected void publishResults(CharSequence charSequence, FilterResults filterResults) {
            ArrayList<Contact> contacts = (ArrayList<Contact>) filterResults.values;
            if (filterResults.count > 0) {
                clear();
                for (Contact contact : contacts) {
                    add(contact);
                }
            } else {
                clear();
            }

            notifyDataSetChanged();
        }
    };
}

As for the contact layout that will be displayed in the AutoCompleteTextView drop-down list, it looks like this:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_gravity="top"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:padding="@dimen/medium_padding">

    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/contact_avatar"
        android:layout_width="@dimen/avatar_size"
        android:layout_height="@dimen/avatar_size"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/contact_name"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="@dimen/medium_margin"
        android:layout_marginLeft="@dimen/medium_margin"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="@+id/contact_avatar"
        app:layout_constraintLeft_toRightOf="@+id/contact_avatar"
        app:layout_constraintStart_toEndOf="@+id/contact_avatar"
        app:layout_constraintTop_toTopOf="@+id/contact_avatar" />
</androidx.constraintlayout.widget.ConstraintLayout>

Now that the adapter is set, we need to init a mock list in our MainActivity. Then we have to init the MutliAutoCompleteTextView

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val contacts = object : ArrayList<Contact>() {
        init {
            add(Contact("Adam Ford", R.drawable.adam_ford))
            add(Contact("Adele McCormick", R.drawable.adele_mccormick))
            add(Contact("Alexandra Hollander", R.drawable.alexandra_hollander))
            add(Contact("Alice Paul", R.drawable.alice_paul))
            add(Contact("Arthur Roch", R.drawable.arthur_roch))
        }
    }

    initAutoCompleteTextView(contacts)
}

private fun initAutoCompleteTextView(contacts: List<Contact>) {
    recipient_auto_complete_text_view.setAdapter(
        ContactAdapter(
            this,
            R.layout.contact_layout, contacts
        )
    )
    recipient_auto_complete_text_view.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer())

    recipient_auto_complete_text_view.threshold = 1
    recipient_auto_complete_text_view.setOnItemClickListener { adapterView, view, i, l ->
        val selectedContact = adapterView.getItemAtPosition(i) as Contact
        createRecipientChip(selectedContact)
    }
}
private AppCompatMultiAutoCompleteTextView contactAutoCompleteTextView;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    contactAutoCompleteTextView = findViewById(R.id.recipient_auto_complete_text_view);

    List<Contact> contacts = new ArrayList<Contact>() {{
        add(new Contact("Adam Ford", R.drawable.adam_ford));
        add(new Contact("Adele McCormick", R.drawable.adele_mccormick));
        add(new Contact("Alexandra Hollander", R.drawable.alexandra_hollander));
        add(new Contact("Alice Paul", R.drawable.alice_paul));
        add(new Contact("Arthur Roch", R.drawable.arthur_roch));
    }};

    initAutoCompleteTextView(contacts);
}

private void initAutoCompleteTextView(List<Contact> contacts) {
    contactAutoCompleteTextView.setAdapter(new ContactAdapter(this,
            R.layout.contact_layout, contacts));
    contactAutoCompleteTextView.setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer());
    // Minimum number of characters the user has to type before the drop-down list is shown
    contactAutoCompleteTextView.setThreshold(1);
    contactAutoCompleteTextView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
        @Override
        public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
            Contact selectedContact = (Contact) adapterView.getItemAtPosition(i);
            createRecipientChip(selectedContact);
        }
    });
}

Now the drop-down list containing the suggestions should be displayed as follows:

Drop-down list

Contact Chip creation

The final step consists in adding a Chip to the edit box in place of the typed text. This can be done using Spans. Spans are markup objects that can be used to style parts of a text. They are commonly used to change the color, the background color, the size of a portion of a text, or draw it in a customized way. There are many types of Span: AbsoluteSizeSpan, BackgroundColorSpan , ImageSpan, QuoteSpan , etc. Each Span type meets specific needs.

In our case, we rely on ImageSpan. It allows us to replace a portion of a text with a Drawable. The Drawable can be aligned with the bottom or with the baseline of the text.

ImageSpan

Actually, it is fortunate because Android provides a standalone ChipDrawable that can be used in contexts that require a Drawable. Therefore, we can create an ImageSpan based on a ChipDrawable, then replace the contact search query with the created Span.

We have created a custom ImageSpan that allows vertical and horizontal padding. It is useful to add spacing between Chips. This custom ImageSpan also centers vertically the Drawable inside the edit box.

To use ChipDrawable, we need to create a chip resource in res/xml as follows:

<chip style="@style/Widget.MaterialComponents.Chip.Action"/>

Now we need to inflate it dynamically and create a Span based of this ChipDrawable. Once done, customize the span as you wish, find the substring coresponding to the search query and then, replace it with the created Span that contains the ChipDrawable.

private fun createRecipientChip(selectedContact: Contact) {
    val chip = ChipDrawable.createFromResource(this, R.xml.standalone_chip)

    val span = CenteredImageSpan(chip, SPAN_HORIZONTAL_PADDING, SPAN_VERTICAL_PADDING)
    val cursorPosition = recipient_auto_complete_text_view.selectionStart
    val spanLength = selectedContact.name.length + 2
    val text = recipient_auto_complete_text_view.text

    chip.chipIcon = ContextCompat.getDrawable(this, selectedContact.avatarResource)

    chip.setText(selectedContact.name)
    chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight)
    text.setSpan(span, cursorPosition - spanLength, cursorPosition, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}

companion object {
    const val SPAN_HORIZONTAL_PADDING = 40f
    const val SPAN_VERTICAL_PADDING = 40f
}
private void createRecipientChip(Contact selectedContact) {
    ChipDrawable chip = ChipDrawable.createFromResource(this, R.xml.standalone_chip);

    CenteredImageSpan span = new CenteredImageSpan(chip, 40f, 40f);
    int cursorPosition = contactAutoCompleteTextView.getSelectionStart();
    int spanLength = selectedContact.getName().length() + 2;
    Editable text = contactAutoCompleteTextView.getText();

    chip.setChipIcon(ContextCompat.getDrawable(MainActivity.this,
            selectedContact.getAvatarResource()));
    chip.setText(selectedContact.getName());
    chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight());
    text.setSpan(span, cursorPosition - spanLength, cursorPosition, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
}

Here is our custom ImageSpan:

class CenteredImageSpan(drawable: Drawable,
                        val horizontalPadding: Float,
                        val verticalPadding: Float) : ImageSpan(drawable) {

    override fun getSize(
        paint: Paint, text: CharSequence, start: Int, end: Int,
        fontMetricsInt: Paint.FontMetricsInt?
    ): Int {
        val drawable = drawable
        val rect = drawable.bounds
        if (fontMetricsInt != null) {
            val fmPaint = paint.fontMetricsInt
            val fontHeight = fmPaint.descent - fmPaint.ascent
            val drHeight = (rect.bottom - rect.top + verticalPadding).toInt()
            val centerY = fmPaint.ascent + fontHeight / 2

            fontMetricsInt.ascent = centerY - drHeight / 2
            fontMetricsInt.top = fontMetricsInt.ascent
            fontMetricsInt.bottom = centerY + drHeight / 2
            fontMetricsInt.descent = fontMetricsInt.bottom
        }
        return (rect.right + horizontalPadding).toInt()
    }

    override fun draw(
        canvas: Canvas, text: CharSequence, start: Int, end: Int,
        x: Float, top: Int, y: Int, bottom: Int, paint: Paint
    ) {

        val drawable = drawable
        canvas.save()
        val fmPaint = paint.fontMetricsInt

        val fontHeight = fmPaint.descent - fmPaint.ascent
        val centerY = y + fmPaint.descent - fontHeight / 2
        val transY = centerY - (drawable.bounds.bottom - drawable.bounds.top) / 2

        canvas.translate(x + horizontalPadding / 2, transY.toFloat())
        drawable.draw(canvas)
        canvas.restore()
    }
}
public class CenteredImageSpan extends ImageSpan {

    private float mHorizontalPadding = 40f;
    private float mVerticalPadding = 40f;

    public CenteredImageSpan(Drawable drawable) {
        super(drawable);
    }

    @Override
    public int getSize(Paint paint, CharSequence text, int start, int end,
                       Paint.FontMetricsInt fontMetricsInt) {
        Drawable drawable = getDrawable();
        Rect rect = drawable.getBounds();
        if (fontMetricsInt != null) {
            Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
            int fontHeight = fmPaint.descent - fmPaint.ascent;
            int drHeight = (int) (rect.bottom - rect.top + mVerticalPadding);
            int centerY = fmPaint.ascent + fontHeight / 2;

            fontMetricsInt.ascent = centerY - drHeight / 2;
            fontMetricsInt.top = fontMetricsInt.ascent;
            fontMetricsInt.bottom = centerY + drHeight / 2;
            fontMetricsInt.descent = fontMetricsInt.bottom;
        }
        return (int) (rect.right + mHorizontalPadding);
    }

    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end,
                     float x, int top, int y, int bottom, Paint paint) {

        Drawable drawable = getDrawable();
        canvas.save();
        Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();

        int fontHeight = fmPaint.descent - fmPaint.ascent;
        int centerY = y + fmPaint.descent - fontHeight / 2;
        int transY = centerY - (drawable.getBounds().bottom - drawable.getBounds().top) / 2;

        canvas.translate(x + (mHorizontalPadding / 2), transY);
        drawable.draw(canvas);
        canvas.restore();
    }
}

Now you can search for contacts, select a suggested contact in the drop-down list, then your query will be replaced by a DrawableChip corresponding to the selected contact.

AutCompleteTextView with Chips

Download

  • Kotlin
  • Java

Conclusion

To conclude, we built an AutoCompleteTextView with Chips from scratch without any external library. We relied on Spans which are powerful markup objects used to apply a custom style to a portion of a text. Then we used ChipDrawable alongside ImageSpan to replace portions of text with Chips. Finally, we reproduced the Recipient autocomplete text box thanks to MultiAutoCompleteTextView. Using the right tools at the right time allows us to easily implement complex features such as this one.

Leave a Reply

Your email address will not be published. Required fields are marked *