Horizontal Number Picker [Android]

S A Z I B
4 min readAug 25, 2020

Implementing Horizontal picker in Android using RecyclerView and LinearLayoutManager with kotlin.

Its a number picker which has the below functionality.

>left to right, right to left scrolling, snapping

>middle item will be selected, clicking on item will also be selected.

github link is below:

  1. Create an android studio project
  2. Add an adapter and a recyclerView in xml
<?xml version="1.0" encoding="utf-8"?>
<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="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/dp_6"
android:paddingTop="@dimen/dp_5"
android:paddingRight="@dimen/dp_6"
android:paddingBottom="@dimen/dp_5">

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
style="@style/TextStyle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="10"
android:textSize="@dimen/sp_36"
/>

</androidx.constraintlayout.widget.ConstraintLayout>
this is the recycler view in main_activity.xml<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_horizontal_picker"
android:layout_width="@dimen/dp_0"
android:layout_height="wrap_content"
android:clipToPadding="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="5"
tools:listitem="@layout/row_view_slider_item"
/>

3. Copy the code for pickerViewHolder class

class PickerItemViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView!!) {

val tvItem: TextView? = itemView?.findViewById(com.sazib.mypicker.R.id.tv_item)
}
and the picker row_view_picker_item.xml is below<?xml version="1.0" encoding="utf-8"?>
<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="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/dp_6"
android:paddingTop="@dimen/dp_5"
android:paddingRight="@dimen/dp_6"
android:paddingBottom="@dimen/dp_5">

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
style="@style/TextStyle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="10"
android:textSize="@dimen/sp_36"
/>

</androidx.constraintlayout.widget.ConstraintLayout>

4. Write the below code for picker adapter

class PickerAdapter : RecyclerView.Adapter<PickerItemViewHolder>() {

private val data: ArrayList<String> = ArrayList()
var callback: Callback? = null
private val clickListener = View.OnClickListener { v -> v?.let { callback?.onItemClicked(it) } }
private var selectedItem: Int? = -1
private var ctx: Context? = null

override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): PickerItemViewHolder {

ctx = parent.context

val itemView: View = LayoutInflater.from(parent.context)
.inflate(R.layout.row_view_slider_item, parent, false)

itemView.setOnClickListener(clickListener)
return PickerItemViewHolder(itemView)
}

override fun getItemCount(): Int {
return data.size
}

override fun onBindViewHolder(
holder: PickerItemViewHolder,
position: Int
) {
holder.tvItem?.text = data[position]

when (selectedItem) {
position -> {
holder.tvItem?.setTextColor(ContextCompat.getColor(ctx!!, R.color.colorBlack))
selectedItem = -1
}
else -> holder.tvItem?.setTextColor(ContextCompat.getColor(ctx!!, R.color.text_color))
}
}

fun setSelectedItem(position: Int) {
selectedItem = position
notifyDataSetChanged()
}

fun setData(data: ArrayList<String>) {
this.data.clear()
this.data.addAll(data)
notifyDataSetChanged()
}

interface Callback {
fun onItemClicked(view: View)
}
}

5. ScreenUtils.kt for measuring the screeen width. It is needed too measure the middle point to the screen.

class ScreenUtils {

companion object {

fun getScreenWidth(context: Context): Int {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val dm = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(dm)
return dm.widthPixels
}

fun dpToPx(
context: Context,
value: Int
): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics
)
.toInt()
}
}
}

6. The magic class PickerLayoutManager.kt

class PickerLayoutManager(context: Context?) : LinearLayoutManager(context) {

init {
orientation = HORIZONTAL
}

var callback: OnItemSelectedListener? = null
private lateinit var recyclerView: RecyclerView

override fun onAttachedToWindow(view: RecyclerView?) {
super.onAttachedToWindow(view)
recyclerView = view!!

// snapping
LinearSnapHelper().attachToRecyclerView(recyclerView)
}

override fun onLayoutChildren(
recycler: RecyclerView.Recycler?,
state: RecyclerView.State
) {
super.onLayoutChildren(recycler, state)
scaleDownView()
}

override fun scrollHorizontallyBy(
dx: Int,
recycler: RecyclerView.Recycler?,
state: RecyclerView.State?
): Int {
return if (orientation == HORIZONTAL) {
val scrolled = super.scrollHorizontallyBy(dx, recycler, state)
scaleDownView()
scrolled
} else {
0
}
}

private fun scaleDownView() {
val mid = width / 2.0f
for (i in 0 until childCount) {

// Calculating the distance of the child from the center
val child = getChildAt(i)
val childMid = (getDecoratedLeft(child!!) + getDecoratedRight(child)) / 2.0f
val distanceFromCenter = abs(mid - childMid)

// The scaling formula
val scale = 1 - sqrt((distanceFromCenter / width).toDouble()).toFloat() * 0.66f

// Set scale to view
child.scaleX = scale
child.scaleY = scale
}
}

override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)

// When scroll stops we notify on the selected item
if (state == RecyclerView.SCROLL_STATE_IDLE) {

// Find the closest child to the recyclerView center --> this is the selected item.
val recyclerViewCenterX = getRecyclerViewCenterX()
var minDistance = recyclerView.width
var position = -1
for (i in 0 until recyclerView.childCount) {
val child = recyclerView.getChildAt(i)
val childCenterX =
getDecoratedLeft(child) + (getDecoratedRight(child) - getDecoratedLeft(child)) / 2
val newDistance = abs(childCenterX - recyclerViewCenterX)
if (newDistance < minDistance) {
minDistance = newDistance
position = recyclerView.getChildLayoutPosition(child)
}
}

// Notify on item selection
callback?.onItemSelected(position)
}
}

private fun getRecyclerViewCenterX(): Int {
return (recyclerView.right - recyclerView.left) / 2 + recyclerView.left
}

interface OnItemSelectedListener {
fun onItemSelected(layoutPosition: Int)
}
}

7. init the adapter and set the layout manager in MainActivity.kt

class MainActivity : AppCompatActivity() {

private val data = (1..10).toList()
.map { it.toString() } as ArrayList<String>
private lateinit var rvHorizontalPicker: RecyclerView
private lateinit var sliderAdapter: PickerAdapter

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

setHorizontalPicker()
}

private fun setHorizontalPicker() {
rvHorizontalPicker = findViewById(R.id.rv_horizontal_picker)

// Setting the padding such that the items will appear in the middle of the screen
val padding: Int = ScreenUtils.getScreenWidth(this) / 2 - ScreenUtils.dpToPx(this, 40)
rvHorizontalPicker.setPadding(padding, 0, padding, 0)

// Setting layout manager
rvHorizontalPicker.layoutManager = PickerLayoutManager(this).apply {
callback = object : PickerLayoutManager.OnItemSelectedListener {
override fun onItemSelected(layoutPosition: Int) {
sliderAdapter.setSelectedItem(layoutPosition)
Log.d("selected text", data[layoutPosition])
Toast.makeText(this@MainActivity, data[layoutPosition], Toast.LENGTH_SHORT).show()
}
}
}

// Setting Adapter
sliderAdapter = PickerAdapter()
rvHorizontalPicker.adapter = sliderAdapter.apply {
setData(data)
callback = object : PickerAdapter.Callback {
override fun onItemClicked(view: View) {
rvHorizontalPicker.smoothScrollToPosition(
rvHorizontalPicker.getChildLayoutPosition(
view
)
)
}
}
}
}
}

A deep dive into it:

>formula for padding of the each item:

// Setting the padding such that the items will appear in the middle //of the screen
val padding: Int = ScreenUtils.getScreenWidth(this) / 2 - ScreenUtils.dpToPx(this, 40)
rvHorizontalPicker.setPadding(padding, 0, padding, 0)

>For snapping

LinearSnapHelper().attachToRecyclerView(recyclerView)

>adapter callback for click handling

// Setting Adapter
sliderAdapter = PickerAdapter()
rvHorizontalPicker.adapter = sliderAdapter.apply {
setData(data)
callback = object : PickerAdapter.Callback {
override fun onItemClicked(view: View) {
rvHorizontalPicker.smoothScrollToPosition(
rvHorizontalPicker.getChildLayoutPosition(
view
)
)
}
}
}

>scaling: the item in the center has scale 1F, and further items are scaled according to their distance from the center.

val scale = 1 - sqrt((distanceFromCenter / width).toDouble()).toFloat() * 0.66f

> lastly I edited the code and style from this link given below:

github.com/nbtk123/Horizontal-Vertical-Slider-Picker

Bonus: There is a another picker using textView in the below repository. You can use it and modify it if you wish.

https://github.com/sazibislam/MyHorizontal-Picker

Download my app from play store:

https://play.google.com/store/apps/details?id=com.sazib.my_feedback

Ping me: Linkedin

--

--