Building a Horizontal Picker or Spinner in Kotlin

A “picker” is a small scrollable list of defined values that looks like a combination lock with dials. It’s native to Swift. I think “spinner” would be a better name, because it looks like it spins. (Android has a “spinner,” but it’s really just a drop-down list. Annoying.)

In this post, I will be showing you how to make a month picker/spinner in Kotlin.

Before we get started, make sure to add these to your dependencies.


    implementation 'androidx.appcompat:appcompat:1.0.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
    implementation 'androidx.recyclerview:recyclerview:1.0.0'

1. Main Layout

First, you need a layout for your items/rows/cells. This project is simple enough; we just need a TextView inside a ConstraintLayout. There’s not much reason to show code for this, but just make sure to include an ID for both the ConstraintLayout and the TextView. You will also want to set the width for the ConstraintLayout to something like 150dp.

month_item

For the main fragment layout, we want a RecyclerView flanked by two buttons. Here, I’ve specified the layout manager to use and added tools:listitem to see what my month_items will look like in the view. I’ve set the orientation to horizontal. You can use what you’d like for the arrows on the buttons.


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

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/decrement_month"
        android:layout_width="30dp"
        android:layout_height="0dp"
        android:background="@drawable/arrow"
        app:layout_constraintBottom_toBottomOf="@+id/month_list"
        app:layout_constraintEnd_toStartOf="@+id/month_list"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@+id/month_list" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/month_list"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintEnd_toStartOf="@+id/increment_month"
        app:layout_constraintStart_toEndOf="@+id/decrement_month"
        app:layout_constraintTop_toTopOf="parent"
        tools:listitem="@layout/month_item" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/increment_month"
        android:layout_width="30dp"
        android:layout_height="0dp"
        android:background="@drawable/arrow"
        android:rotation="180"
        app:layout_constraintBottom_toBottomOf="@+id/month_list"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/month_list"
        app:layout_constraintTop_toTopOf="@+id/month_list" />

</androidx.constraintlayout.widget.ConstraintLayout>

main layout

2. Adding a Gradient

So far, this looks decent, but it would be nice to fade the edges of the RecyclerView. To do this, we need a drawable. An individual shape won’t work because we could only use a single gradient. Instead, we’ll use a layer-list.


<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape>
            <gradient android:startColor="@android:color/white"
                android:centerX="33%"
                android:centerColor="#00FFFFFF" />
        </shape>
    </item>
    <item>
        <shape>
            <gradient android:centerColor="#00FFFFFF"
                android:centerX="66%"
                android:endColor="@android:color/white" />
        </shape>
    </item>
</layer-list>

Note that the gradient will always span the full view. To compensate for this, we can set the center color to have no alpha and then offset it. This will create a three-color band with a transparent center.

gradient overlay

Next, we can add this to our layout on top of the RecyclerView. (This will be the last item in the constraint layout.)


<androidx.appcompat.widget.AppCompatImageView
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toBottomOf="@+id/month_list"
    app:layout_constraintEnd_toEndOf="@+id/month_list"
    app:layout_constraintStart_toStartOf="@+id/month_list"
    app:layout_constraintTop_toTopOf="@+id/month_list"
    app:srcCompat="@drawable/gradient_overlay" />

3. A Simple View Model

Now we need a class that extends ViewModel. It will hold our data for the recycler view and the item we are viewing.


class MainViewModel : ViewModel() {
    private val _selectedMonth = MutableLiveData<LocalDate>()
    val selectedMonth: LiveData<LocalDate> = _selectedMonth

    fun setSelectedMonth(date: LocalDate) {
        _selectedMonth.value = date
    }
}

If you aren’t used to live data, this might look a bit odd. However, this setup will allow us to watch for changes of the selected month. This could be used to update other parts of our view.

When it comes to data, we need to generate it. This may seem like overkill if you just need the months, but if you need specific date manipulation, this is what you would do.

Note that the first and last dates in the list are epoch dates. For this spinner, the first and last dates need to be dummy values so that the items can be centered properly. Depending on the size of your items, you may be able to see more than three items at a time in the spinner. If that’s the case, add more dummy values as needed.


val months: List<LocalDate>
    get() {
        val now = LocalDate.now().withDayOfMonth(1)

        val muteMonths = mutableListOf<LocalDate>()
        muteMonths.add(LocalDate.ofEpochDay(0))
        muteMonths.addAll((1..12).map { index ->
            now.minusMonths(now.monthValue - index.toLong())
        })
        muteMonths.add(LocalDate.ofEpochDay(0))
        return muteMonths
    }

4. Recycler Adapter

The next step is to create the recycler adapter. This will create each item in the recycler when it is needed.

Extend RecyclerView.Adapter, and provide a ViewHolder. You are required to override getItemCount, onCreateViewHolder, and onBindViewHolder. The first two require no explanation. The onBindViewHolder provides a way to set our data from the view model to the items in the RecyclerView.

Because of our extra first and last item, we need to make sure that we set the text to empty strings and reset the onClickListener. This is important because the RecyclerView will reuse items in the list when they fall out of view.


class MainAdapter(private val items : List<LocalDate>, private val context: Context?,
                  private val monthClickCallback: ((LocalDate) -> Unit)?)
    : RecyclerView.Adapter<ViewHolder>() {
    override fun getItemCount(): Int {
        return items.size
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(LayoutInflater.from(context).inflate(R.layout.month_item, parent, false))
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (position in 1 until (itemCount - 1)) {
            val month = items[position].month.getDisplayName(TextStyle.FULL, Locale.US)
            holder.monthLabel.text = month
            holder.monthLabel.contentDescription = month
            holder.itemView.setOnClickListener {
                monthClickCallback?.invoke(items[position])
            }
        } else {
            holder.monthLabel.text = ""
            holder.monthLabel.contentDescription = ""
            holder.itemView.setOnClickListener {}
        }
    }
}

class ViewHolder (view: View) : RecyclerView.ViewHolder(view) {
    val monthLabel: TextView = view.month_label
}

Notice that I pass in a function for the onClickListener for the items in the list. That way, clicking on a month will scroll to that month in the list in the same way that the increment/decrement buttons will work.

5. Main Fragment

The final part we’ll create is the fragment. The onCreateView is easy. All you need to do is inflate the layout and instantiate the LayoutManager.


class MainFragment : Fragment() {
    private lateinit var mainViewModel: MainViewModel
    private lateinit var layoutManager: LinearLayoutManager

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_main, container, false)

        layoutManager = LinearLayoutManager(context)
        layoutManager.orientation = LinearLayoutManager.HORIZONTAL

        return view
    }
}

We may want to have our RecyclerView start at a specific item, like the current month. To do this, we need to set the selected month and scroll to it before the view starts, using a method like this:


private fun initializeSelectedMonth() {
    if (mainViewModel.selectedMonth.value == null) {
        val now = mainViewModel.months[LocalDate.now().monthValue]
        mainViewModel.setSelectedMonth(now)
        scrollToMonth(now)
    }
}

If the selected month isn’t set, we can get the current month, set it, then call some scrollToMonth method. But scrolling to the correct month isn’t as simple as it would appear.

You could use the layoutManager.scrollToPosition method, but this poses a problem. Because it scrolls to the beginning–not the center–of the item, it won’t be centered.

To center the item, we need to use scrollToPositionWithOffset, which takes the position of the item and an offset in pixels. The offset will be half the width of our RecyclerView, minus half the width of the item.


private fun scrollToMonth(month: LocalDate) {
    var width = month_list.width

    if (width > 0) {
        val monthWidth = month_item.width
        layoutManager.scrollToPositionWithOffset(mainViewModel.months.indexOf(month), width / 2 - monthWidth / 2)
    }
}

First, we check that the list has a width greater than zero, then scroll with the offset. It’s important to check the width because the RecyclerView might not have a width until the view actually loads.

So what happens if the first time the view loads, the conditional fails? The RecyclerView will start on the first item, but the first item is essentially a dummy blank space.

To account for this scenario, we should wait for the layout to finish loading and then scroll. We can do this with a globalLayoutListener on the viewTreeObserver of the RecyclerView. The code will look like what you see below, though this time we need to get the month_item width from resources because none have been created yet.


} else {
    val vto = month_list.viewTreeObserver
    vto.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            month_list.viewTreeObserver.removeOnGlobalLayoutListener(this)
            width = month_list.width
            context?.resources?.getDimensionPixelSize(R.dimen.month_item_width)?.let { monthWidth ->
                layoutManager.scrollToPositionWithOffset(mainViewModel.months.indexOf(month), width / 2 - monthWidth / 2)
            }
        }
    })
}

Great! We can now hook up the initialization and the clickListeners on our items. In the onActivityCreated, we will also initialize the view model and adapter, which is where we can pass in a function to scroll on a month that’s clicked.


override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)

    mainViewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)

    month_list.adapter = MainAdapter(mainViewModel.months, context) { month ->
        mainViewModel.setSelectedMonth(month)
        scrollToMonth(month)
    }
    month_list.layoutManager = layoutManager

    initializeSelectedMonth()
}

Hooking up our two buttons is simple as well. Take the selectedMonth and subtract one. If this month exists in our list, we set to it and scroll. For the increment month button, we only need to change minusMonths to plusMonths.


decrement_month.setOnClickListener {
    mainViewModel.selectedMonth.value?.let { month ->
        val previousMonth = month.minusMonths(1)
        if (mainViewModel.months.indexOf(date) >= 0) {
            mainViewModel.setSelectedMonth(previousMonth)
            scrollToMonth(previousMonth)
        }
    }
}

That’s it! Our spinner/picker now works…well, mostly. Swiping is still too fast, can stop on our dummy items, and doesn’t set the selected month when it stops.

Swipe Problems

6. Fixing a Few Small Problems

To handle the last two issues I mentioned, we’ll use an onScrollListener.


month_list.addOnScrollListener(object: RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        super.onScrollStateChanged(recyclerView, newState)
        if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
            val offset = (month_list.width / month_item.width - 1) / 2
            val position = layoutManager.findFirstCompletelyVisibleItemPosition() + offset
            if (position in 0 until mainViewModel.months.size &&
                mainViewModel.months[position] != mainViewModel.selectedMonth.value) {
                when (position) {
                    0 -> {
                        mainViewModel.setSelectedMonth(mainViewModel.months[1])
                        scrollToMonth(mainViewModel.months[1])
                    }
                    mainViewModel.months.size - 1 -> {
                        mainViewModel.setSelectedMonth(mainViewModel.months[position - 1])
                        scrollToMonth(mainViewModel.months[position - 1])
                    }
                    else -> mainViewModel.setSelectedMonth(mainViewModel.months[position])
                }
            }
        }
    }
})

This checks that scrolling has stopped, checks that the position we stopped on is valid, and adjusts the selected month accordingly. If the visible item was the first or last, it scrolls forward or backward by one position.

Why do we need a position offset? If our items are not very wide, we will see more on the screen, but we still want the center one to be selected. So we offset from the first visible item by the amount required.

To help the swiping feel more controlled, we need a LinearSnapHelper. It will look like this CustomLinearSnapHelper. This code mostly came from Jag Saund. I won’t take the time to explain everything that’s going on since he’s done that already.

Lastly, you can add these two lines to the onActivityCreated for the fragment.


val snapHelper = CustomLinearSnapHelper(context)
snapHelper.attachToRecyclerView(month_list)

Kotlin Picker/Spinner

That’s it! It took a lot of code to get a picker/spinner working in Kotlin, but hopefully, you will be able to make good use of it.