The making of AccordionView using ConstraintLayout

Tan Jun Rong avatar

Tan Jun Rong

What is Accordion View?

Accordion View is a view that consists of a series of titles, and when you click on a specific title, the detailed description will be expanded. What makes it special is that the expanded view will close itself once another view is expanded.

This will ensure that the user can focus on only one opened item at a time.

I got this requirement at work and was wondering how to implement it. After some research, I found out that it can be done by using ConstraintLayout.

It turns out that it isn't too difficult to implement using ConstraintLayout and ConstraintSet. I want to write down how easy it is to achieve this. I hope you can implement something fun using ConstraintLayout and ConstraintSet after reading this post!

This is the implementation that I wrote, uploaded to Github: https://github.com/worker8/AccordionView

Here's a gif showing how it works 👇
Accordion View

Pre-requisite
  • This article requires basic knowledge of ConstraintLayout.
  • Basic understanding of ConstraintSet (read this post)
Naming

When I use small case, for example, constraintLayout, it refers to an object, when I use upper case, like ConstraintLayout, it refers to the class.


The Basic Concept

Let's start with something more simple before jumping into implementing AccordionView directly.

Since it is up to the users to decide how many items they want to have inside the AccordionView, we need to generate all the views dynamically. So instead of using xml to connect things in ConstraintLayout, we need to use ConstraintSet to connect the views programmatically.

To achieve this, it can be divided into 3 steps:

  1. createView()
  2. addView()
  3. applyConstraints()

Step 1: createView()

Let's begin!

We'll start with an empty ConstraintLayout and an empty TitleView.

Empty Constraint Layout
Empty Constraint Layout

The code to make this looks like below:

fun onCreate() {
    // Step 1
    constraintLayout = LayoutInflater.from(this)
        .inflate(R.layout.empty_constraint_layout, constraintLayout, false)
    titleView = LayoutInflater.from(this)
        .inflate(R.layout.title_view, constraintLayout, false)

    setContentView(constraintLayout)
}

Pretty simple view inflation, which is self-explanatory.

At this point, the titleView will not be seen, because we haven't added it into the constraintLayout.

Step 2: addView

Next, we need to add the view to constraintLayout, otherwise, you won't see anything on the screen.

TitleView is added to ConstraintLayout
TitleView is added to ConstraintLayout

The code for this:

fun onCreate() {
    // Step 1
    constraintLayout = LayoutInflater.from(this)
        .inflate(R.layout.empty_constraint_layout, constraintLayout, false)
    titleView = LayoutInflater.from(this)
        .inflate(R.layout.title_view, constraintLayout, false)

    // Step 2
    constraintLayout.addView(titleView)
    setContentView(constraintLayout)
}

At this point, the titleView will be floating, because we haven't add any constraint to it.

note: it should be aligned to the left top corner, but I drew it floating to emphasize my point

Step 3 applyConstraints

Now, we need to apply the constraints to it using ConstraintSet.

It looks like this after we applied the constraints.
Constraints are applied to TitleView

The code:

fun onCreate() {
    // Step 1
    constraintLayout = LayoutInflater.from(this)
        .inflate(R.layout.empty_constraint_layout, constraintLayout, false)
    titleView = LayoutInflater.from(this)
        .inflate(R.layout.title_view, constraintLayout, false)

    // Step 2
    constraintLayout.addView(titleView)

   // Step 3
    val set = ConstraintSet()
    set.clone(constraintLayout)
    set.connect(titleView.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
    set.connect(titleView.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
    set.connect(titleView.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
    set.applyTo(constraintLayout)

    setContentView(constraintLayout)
}

By connecting

  • titleView TOP to parent TOP
  • titleView START to parent START
  • titleView END to parent END

titleView will align properly at the top of constraintLayout, also matching the width of the constraintLayout.


Implementing AccordionView

Now that we understand the basic concept, we can move on to use the same 3 steps to create AccordionView.

Comparison between MockUp vs. Actual Screenshot
Comparison between MockUp vs. Actual Screenshot

Step 1: createViews

Create Views for Titles

In actual case, the number of TitleView can be any number as long as the screen fits. For this tutorial, let's fixed it at 4.

Similarly, we start by creating views, but this time, we need to create Views instead of view.

Create 4 titleViews
Create 4 titleViews

The code:

// ... onCreate()
    // Step 1
    val numberOfTitles = 4
    val titleViewList = mutableListOf()

    for (index in 0 until numberOfTitles) {
        val titleView = LayoutInflater.from(this)
            .inflate(R.layout.title_view, constraintLayout, false)
        titleView.id = View.generateViewId()
        titleViewList.add(titleView)
    }

Since the number of titles can change, we use a loop for it. I'm inflating the TitleView one by one and adding them into titleViewList mutable list.

Take note that I need to use View.generateViewId() to assign a unique id for each TitleView. Otherwise, we cannot apply constraint to them correctly, as we need to use the id to reference to each of them.

Create View for Content

So far I've been talking about the title views, but let's not forget about the ContentView.6_content

The code:

// ... onCreate()
    // Step 1
    val contentView = LayoutInflater.from(this)
        .inflate(R.layout.content_view, constraintLayout, false)

At this point, we have 4 titleViews in a mutable list and contentView, but they are not added to constraintLayout.

Step 2: addViews

Next we need to add all the views to the constraintLayout.
It should look like this when they are added.
TitleViews and ContentView are added to ConstraintLayout

Note that no constraints are being added, so all the views in the picture above are stacked together.

The code:

// ... onCreate()
    // Step 2
    titleViewList.forEach { titleView ->
        constraintLayout.addView(titleView)
    }
    constraintLayout.addView(contentView)

The code for this is pretty self-explanatory. Next, we need to apply some constraints to them.

Step 3 apply constraints

First, we use the same technique to place the first TitleView.

Add constraint for 1st TitleView
Add constraint for 1st TitleView

The code:

// ... onCreate()
   // Step 3
   val set = ConstraintSet()
   set.clone(constraintLayout)

   val tempTitleView1 = titleViewList[0] // obtain from the list

   set.connect(tempTitleView1.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
   set.connect(tempTitleView1.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
   set.connect(tempTitleView1.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)

   set.applyTo(constraintLayout)

Note that it's mostly the same in the "Basic Concept" section above. So no explanation is needed.

Next, we should connect the next TitleView.

Connecting the next TitleView
Connecting the next TitleView

The code:

// ... onCreate()
   // Step 3
   val set = ConstraintSet()
   set.clone(constraintLayout)

   val tempTitleView1 = titleViewList[0] // obtain from the list

   set.connect(tempTitleView1.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
   set.connect(tempTitleView1.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
   set.connect(tempTitleView1.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)

   val tempTitleView2 = titleViewList[1] // obtain from the list
   // Important Line:
   set.connect(tempTitleView2.id, ConstraintSet.TOP, tempTitleView1.id, ConstraintSet.BOTTOM)
   set.connect(tempTitleView2.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
   set.connect(tempTitleView2.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)

   set.applyTo(constraintLayout)

The important line to take note is this:

set.connect(tempTitleView2.id, ConstraintSet.TOP, tempTitleView1.id, ConstraintSet.BOTTOM)

Instead of connecting to the TOP of the parent, we connect it to the BOTTOM of previous TitleView.

Since this is repetitive, we can move it inside a loop, but I will not show it to keep this post short.

By using the same method, we can connect everything including the contentView.

And it will finally look like this:

When all views are connected
When all views are connected

Changing Constraints

So far I assumed that the last content is opened, let's consider if the content is opened in the middle.

In this case, we will need to change the constraint.

When the content in the middle is opened
When the content in the middle is opened

Since it became a little complicated, I will start using pseudocode.

// Top down
1. connect TitleView1 to the TOP of parent
2. connect TitleView2 to the BOTTOM of TitleView1

// Bottom up
3. connect TitleView4 to the BOTTOM of parent
4. connect TitleView3 to the TOP of TitleView4

// Middle
5. connect ContentView to BOTTOM of TitleView2
6. connect ContentView to TOP of TItleView3

Following this pseudo-code above, we can achieve the layout shown in the screenshot above.

Since the number of items can change too, we need to make this code more flexible.

We'll change it to use a loop.

// Top down
1. connect first `TitleViews` until the selected item (e.g. TitleView2) in a loop

// Bottom up
2. connect the last `TitleViews` upwards until selected item + 1 (e.g. TitleView3) in a loop

// Middle
3. connect ContentView to the row above
4. connect ContentView to the row underneath

Once you are done connecting, you need to use applyTo() to make the view re-render itself. To enable the moving animation, this method can be used: TransitionManager.beginDelayedTransition(constraintLayout).

One thing that we should be more careful is to clear the constraint.

When TitleView4 moved, we need to clear the constraint
When TitleView4 moved, we need to clear the constraint

Looking at the picture above, when titleView4 moved the bottom most position, we need to clear it's previous TOP constraint that is connected to titleView3.

The code:

val set = ConstraintSet()
   set.clone(constraintLayout)
   set.clear(titleView4.id, ConstraintSet.TOP)
   set.applyTo(constraintLayout)

The Adapter

Right now, we are almost done, let's recap what we did:

  1. creating the views
  2. adding the views
  3. connecting them together

However, we are not done yet, because the data is hard coded, and we have no way to change it. To solve this problem, we can use the "adapter pattern" similar to RecyclerView.

Here's a few things that we need the adapter to do for us:

  1. creating the views
    • instead of inflating the views directly, we ask the adapter to do that
  2. binding the views
    • after the views are created, we ask the adapter to bind the views, for example, setting up the text views, or setting up onClickListeners
  3. providing the number of data
    • instead of hardcoding the to size 4, as we did in this post, we ask the adapter for the number of data.

As such, I've come up with the following interface:

interface AccordianAdapter {
    fun onCreateViewHolderForTitle(parent: ViewGroup): AccordionView.ViewHolder
    fun onCreateViewHolderForContent(parent: ViewGroup): AccordionView.ViewHolder
    fun onBindViewForTitle(viewHolder: AccordionView.ViewHolder, position: Int, arrowDirection: ArrowDirection)
    fun onBindViewForContent(viewHolder: AccordionView.ViewHolder, position: Int)
    fun getItemCount(): Int
}

I have 2 extra methods because we need to ask the adapter to provide for the contents too, instead of just titles.

This is actually very close to the real code: AccordionAdapter.kt (Github)

Finally, we need to replace every places where we create our TitleViews and our ContentViews with adapter.onCreateViewHolderForTitle(...) and adapter.onCreateViewHolderForContent(...) respectively.

Then, we will run adapter.onBindViewForTitle() and adapter.onBindViewForContent respectively.

Then, we will replace the hardcoded value, 4 with adapter.getItemCount().

You will have the final code that looks like this: AccordionView.kt.

The AccordionView library

Of course I've left out many other edge cases and details I had encounter along the way, you can read the source code to find out. I hope my post help you to understand ConstraintSet and ConstraintLayout more.

I've put all the logic I discussed in this post into a class and named it AccordionView, and publish it in Github using jitpack.io.

Finally, it is quite simple to use AccordionView because it feels like using RecyclerView.

Here's the code for RandomAdapter.kt that I wrote as an example:

class RandomAdapter(val dataArray: List) : AccordionAdapter {
    override fun onCreateViewHolderForTitle(parent: ViewGroup): AccordionView.ViewHolder {
        return TitleViewHolder.create(parent)
    }

    override fun onCreateViewHolderForContent(parent: ViewGroup): AccordionView.ViewHolder {
        return ContentViewHolder.create(parent)
    }

    override fun onBindViewForTitle(viewHolder: AccordionView.ViewHolder, position: Int, arrowDirection: AccordionAdapter.ArrowDirection) {
        val dataModel = dataArray[position]
        (viewHolder as TitleViewHolder).itemView.apply {
            titleTextView.text = dataModel.title
            when (arrowDirection) {
                AccordionAdapter.ArrowDirection.UP -> titleArrowIcon.text = "▲"
                AccordionAdapter.ArrowDirection.DOWN -> titleArrowIcon.text = "▼"
                AccordionAdapter.ArrowDirection.NONE -> titleArrowIcon.text = ""
            }
        }
    }

    override fun onBindViewForContent(viewHolder: AccordionView.ViewHolder, position: Int) {
        val dataModel = dataArray[position]
        (viewHolder as ContentViewHolder).itemView.apply {
            contentTextView.text = dataModel.desc
        }
    }

    override fun getItemCount() = dataArray.size
}

class TitleViewHolder(itemView: View) : AccordionView.ViewHolder(itemView) {
    companion object {
        fun create(parent: ViewGroup): TitleViewHolder {
            return TitleViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.row_title, parent, false))
        }
    }
}
Random Fact Example using AccordionView
Random Fact Example using AccordionView

Wrapping Up

The Github link to this project is here: https://github.com/worker8/AccordionView

Bonus picture of me playing Accordion
Bonus picture of me playing Accordion

Hope you enjoy the post and got interested in playing with ConstraintSet.

See you next time! 👋🐸

Tan Jun Rong avatar
Written By

Tan Jun Rong

Android Programmer who likes writing blogs, reading, coffee, snowboarding.
Published in Android
Enjoyed the post?

Clap to support the author, help others find it, and make your opinion count.