Android: Spinner customizations

Adam Styrc
TechTalks@Vattenfall
5 min readDec 3, 2019

Implement Android dropdown just the way you want.

Custom Spinner

In this article, I’ll describe two ways to achieve custom spinner for country selection that I had to implement at InCharge app. We officially support now 4 countries with charging stations, each country has specific legal rules, terms & conditions, etc.

The screen mockups I received from our designer were exactly these:

Spinner Design ||| Spinner Design — dropdown

So the design for selection was not looking like a default Android Spinner as it had a header included when going to dropdown mode. Is it possible to implement using Spinner Widget? Let’s see.

Adjusting the Spinner widget

OK, so let’s begin with implementing the layout:

item_country.xml

<RelativeLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/country_item_height"
android:paddingLeft="@dimen/general_margin"
android:paddingRight="@dimen/general_margin">

<ImageView
android:id="@+id/ivCountry"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
tools:src="@drawable/ic_united_kingdom" />

<TextView
android:id="@+id/tvCountry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/general_margin"
android:layout_toRightOf="@id/ivCountry"
android:layout_centerVertical="true"
android:textColor="@color/incharge_dark_grey"
android:textSize="18sp"
tools:text="United Kingdom"/>

<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:src="@drawable/arrow_down_grey"
/>
</RelativeLayout>

And add a Spinner widget:

<Spinner
android:id="@+id/sCountry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
...
android:background="@drawable/blue_outline"
android:popupBackground="@drawable/blue_outline_white_background"
android:spinnerMode="dropdown"
tools:listitem="@layout/item_country"
/>

blue_outline.xml

<shape xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:color="@color/incharge_blue" android:width="1dp"/>
<corners android:radius="@dimen/button_round_radius"/>
</shape>

In this way, we’ve just built a Spinner widget on our UI. Thanks to tools:listitem="@layout/item_country" we can see in Android Studio Designer how the Spinner will look like and adjust paddings/margins properly :)

With property android:background we can add a blue outline when Spinner is in the selected state and with android:popupBackground the background of dropdown view - easy so far.

Adapter

The magic of adding a header will be done in our CountryAdapter class:

CountryAdapter.kt

class CountryAdapter(
context: Context
) : ArrayAdapter<OperatedCountry>(context, 0, OperatedCountry.values()) {
val layoutInflater: LayoutInflater = LayoutInflater.from(context) override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view: View
if (convertView == null) {
view = layoutInflater.inflate(R.layout.item_country, parent, false)
} else {
view = convertView
}
getItem(position)?.let { country ->
setItemForCountry(view, country)
} return view override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view: View
if (position == 0) {
view = layoutInflater.inflate(R.layout.header_country, parent, false)
view.setOnClickListener {
val root = parent.rootView
root.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK))
root.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK))
}
} else {
view = layoutInflater.inflate(R.layout.item_country_dropdown, parent, false)
getItem(position)?.let { country ->
setItemForCountry(view, country)
}
}
return view
}
override fun getItem(position: Int): OperatedCountry? {
if (position == 0) {
return null
}
return super.getItem(position - 1)
}
override fun getCount() = super.getCount() + 1 override fun isEnabled(position: Int) = position != 0 private fun setItemForCountry(view: View, country: OperatedCountry) {
val tvCountry = view.findViewById<TextView>(R.id.tvCountry)
val ivCountry = view.findViewById<ImageView>(R.id.ivCountry)
val countryName = Locale("", country.countryCode).displayCountry
tvCountry.text = countryName
ivCountry.setBackgroundResource(country.icon)
}

And an enum for supported countries is realized like this:

OperatedCountry.kt

enum class OperatedCountry(val countryCode: String, val icon: Int) {
UNITED_KINGDOM("UK", R.drawable.ic_united_kingdom),
NETHERLANDS("NL", R.drawable.ic_netherlands),
GERMANY("DE", R.drawable.ic_germany),
SWEDEN("SE", R.drawable.ic_sweden),
}

There are several parts that need to be explained…

Firstly, let’s see that getView() method will build UI when Spinner is in idle state and getDropDownView() will build particular items when Spinner dropdown gets opened. This is a place where we need to expect position == 0 to build a header with "Select option".

Unfortunately, there is no Spinner public method to programmatically close the dropdown. So how could we close it, when the header item gets clicked? There are couples of hacks to overcome this problem and here is one of them:

val root = parent.rootView
root.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK))
root.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK))

so we pretend as if the BACK button got clicked. Yeah, hacky…

Also to prevent selections for header view and not trigger its Spinner behavior we override isEnabled method to return false in this case.

Spinner after adjustments

Drawbacks

So we were able to implement the expected design somehow. But is it an ultimate solution? We had to use a hack with closing dropdown and make code dirty. Also, what if the designer suddenly changes his mind and wants to apply some animation for example arrow rotation? Built-in popup has entered animation which might interrupt these animations.

Technically android:popupAnimationStyle is applicable to Spinner style but it's from SDK 24, quite high, right?

Would there be another solution?

Custom Spinner implementation

If we look into the Android source code of Spinner, we will see that underneath a PopupWindow is used to render the dropdown. Implementing classes are private so we cannot use them. But how much work could that be to make it yourself? Let's try!

Let’s replace Spinner widget with anything else that could include item_country.xml Now, in setOnClickListener { ... } part (trying to override OnClickListener on Spinner would result in RuntimeException).

vCountry.setOnClickListener {
popupWindow?.dismiss()
if (popupWindow == null) {
provideCountryPopupWindow(it)
}
popupWindow!!.showAsDropDown(it, 0, -it.height)
}
private fun provideCountryPopupWindow(it: View) {
popupWindow = PopupWindow(it.width, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
val backgroundDrawable = activity!!.getDrawable(
R.drawable.blue_outline_white_background)
.apply { }
setBackgroundDrawable(backgroundDrawable)
isOutsideTouchable = true
val listView = layoutInflater.inflate(
R.layout.layout_country_dropdown,
null,
false) as ListView
listView.adapter = countryAdapter
listView.setOnItemClickListener { _, _, position, _ ->
val selectedCountry = countryAdapter.getItem(position)!!
viewModel.setLegalCountry(selectedCountry)
popupWindow?.dismiss()
}
contentView = listView
}
}

where

layout_country_dropdown.xml

<ListView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/lvCountries"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="@null"
tools:listheader="@layout/header_country" />

So here we handle the dropdown view by ourselves. We have full control of its UI! I chose ListView, could be anything else! All rendered by layout_country_dropdown.xml.

Note that you can use the very same CountryAdapter but rename the getDropDownView() method to getView(), and get rid of existing getView().

Thus… Not a lot of work was required to transition 8) That’s what we all love. This solution seems way cleaner, with no dirty hacks — just add popupWindow?.dismiss() when the header clicked. How does it look like?

Spinner — own implementation with PopupWindow

What about animation management? It’s easy. We could set custom animation for a PopupWindow or simply disable it with a parameter:

PopupWindow.animationStyle = 0 // or R.style.YourCountryPopupAnimation

then in CountryAdapter on header item creating add something like:

view.findViewById<ImageView>(R.id.ivArrow)
.animate()
.rotation(180f)
.setDuration(200)
.start()

And the result is:

PopupWindow — animations

Try writing rotation animation on dropdown close by yourself ;)

Cheers!

--

--