The making of AccordionView using ConstraintLayout
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
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:
createView()
addView()
applyConstraints()
Step 1: createView()
Let's begin!
We'll start with an empty ConstraintLayout
and an empty TitleView
.
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.
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.
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 parentTOP
- titleView
START
to parentSTART
- titleView
END
to parentEND
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
.
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
.
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
.
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.
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
.
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
.
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:
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.
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.
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:
- creating the views
- adding the views
- 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:
- creating the views
- instead of inflating the views directly, we ask the adapter to do that
- 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
- after the views are created, we ask the adapter to bind the views, for example, setting up the text views, or setting up
- 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))
}
}
}
Wrapping Up
The Github link to this project is here: https://github.com/worker8/AccordionView
Hope you enjoy the post and got interested in playing with ConstraintSet
.
See you next time!
Tan Jun Rong
Clap to support the author, help others find it, and make your opinion count.