In this article we will reproduce fixed gradient background for chat messages. Similar effect can be seen in the Fa¢ebook Messenger application. We will use custom Drawable
, RecyclerView
and RecyclerView.ItemDecoration
.
TL;DR: browse the source code
The inspiration for this article is taken from the css-tricks post.
Before we begin
There are few possible solutions to this problem. First, we could've used a gradient background for our layout. Then we would add non-transparent backgrounds to all the views except our target ones. This way our target views would be the only ones without background. Thus showing underlying parent. Unfortunately this would result in:
- massive GPU overdraw (thus not optimal performance, which can be crucial for large lists)
- tight coupling (every view must have a background set, some of them multiple backgrounds)
- even our target view would have to have background for padding
Instead, let's use RecyclerView.ItemDecoration
to solve this problem. This will give us simplicity, extensibility and performance.
Drawable
Let's start with our gradient drawable. It will be short and simple. But will give us flexibility in future by encapsulating drawing logic.
class GradientDrawable(private val colors: IntArray) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
paint.shader = LinearGradient(
0.0F, 0.0F,
0.0F, bounds.bottom.toFloat(),
colors,
null,
Shader.TileMode.CLAMP
)
}
override fun draw(canvas: Canvas) {
canvas.drawRect(bounds, paint)
}
}
Let's apply it to our layout to validate that it's working
RecyclerView
Let's initialize out list widget - RecyclerView
. I'm going to use Adapt library that simplifies working with lists. But note that its usage is completely optional and not required for the functionality that we are targeting.
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// create adapt instance
val adapt = Adapt.create()
// initialize RecyclerView
findViewById<RecyclerView>(R.id.recycler_view).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
setHasFixedSize(true)
adapter = adapt
}
// set items
adapt.setItems(items())
}
private fun items(): List<Item<*>> {
val fakeMessage = FakeMessage(Random(43L))
return 0.until(100)
.map { fakeMessage.create() }
}
}
I'm not going into detail about FakeMessage
class, you can inspect it's code here. In short — it generates a random chat messages.
Item
is a class from the Adapt
library. It represents a single component that can be displayed in a list. Let's create 2 items for our chat, one for each message type:
class Me(private val message: CharSequence) : Item<Me.Holder>(message.hashCode().toLong()) {
override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder {
return Holder(inflater.inflate(R.layout.item_me, parent, false))
}
override fun render(holder: Holder) {
holder.textView.text = message
}
class Holder(view: View) : Item.Holder(view) {
val textView = requireView<TextView>(R.id.text)
}
}
class You(private val message: CharSequence) : Item<You.Holder>(message.hashCode().toLong()) {
override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder {
return Holder(inflater.inflate(R.layout.item_you, parent, false))
}
override fun render(holder: Holder) {
holder.textView.text = message
}
class Holder(view: View) : Item.Holder(view) {
val textView = requireView<TextView>(R.id.text)
}
}
FakeMessage
generates one of these items in its create
method. We construct a list of these items and send them to an Adapt
instance. Now if we apply GradientDrawable
to the RecyclerView
we will see this:
RecyclerView.ItemDecoration
Let's create YouMessageDecoration
which will draw the gradient background:
class YouMessageDecoration(
recyclerView: RecyclerView,
private val itemViewType: Int
) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
}
}
We will initialize GradientDrawable
in YouMessageDecoration
for the sake of brevity, but generally you would want to accept in via constructor or via Dependency Injection.
class YouMessageDecoration(
recyclerView: RecyclerView,
private val itemViewType: Int
) : RecyclerView.ItemDecoration() {
private val drawable: Drawable
init {
drawable = GradientDrawable(recyclerView.resources.getIntArray(R.array.list_gradient_color))
.also { initDrawableBounds(recyclerView, it) }
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
}
private companion object {
// we must listen for supplied view layout events to change gradient bounds
// as gradient drawable won't be actually added to any view, but will be
// used directly by this item decoration
private fun initDrawableBounds(view: View, drawable: Drawable) {
view.viewTreeObserver.addOnGlobalLayoutListener {
drawable.setBounds(0, 0, view.width, view.height)
}
}
}
}
Now, let's implement the onDraw
method. First, we will need to find all currently visible items of You
type. Please note that a RecyclerView.ItemDecoration
must manually find the views to decorate. We will iterate over RecyclerView
's children and match the views of You
item itemViewType
:
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
// a range of integers representing view indexes
0.until(parent.childCount)
// convert index to view
.map { parent.getChildAt(it) }
// convert to Holder (if found)
.mapNotNull { parent.findContainingViewHolder(it) }
// keep only holders of `You` type
.filter { it.itemViewType == itemViewType }
// cast holders (at this point they must be of `You` type)
.map { it as You.Holder }
.forEach {
// we have them here
}
}
As we want to apply gradient background to a chat message, we will need a reference to a TextView
that holds it. Next we will need an absolute position of the TextView
on the canvas. We will obtain it by calculating relative position of the TextView
to a parent RecyclerView
. I have added a small extension function for this calculation:
private companion object {
// we are dealing with View layer, which is exclusively single-threaded,
// so we can reuse this value for all calculations
private val _POINT = Point()
private fun initDrawableBounds(view: View, drawable: Drawable) {...}
// define a private extension function (can be promoted to some utility class
// if will be used in future)
private fun View.relativeTo(group: ViewGroup): Point {
val point = _POINT
// as we are reusing point instance between multiple calls,
// it's important to clear previous values with new ones on each access
point.set(this.left, this.top)
var parent = this.parent
var view: View?
while (parent != null && parent != group) {
view = parent as View
point.x += view.left
point.y += view.top
parent = view.parent
}
return point
}
// additionally, let's define _destruction_ operators for Point,
// so we can do `val (x, y) = point`
private operator fun Point.component1() = this.x
private operator fun Point.component2() = this.y
}
After we have obtained TextView
and its absolute position we will clip the canvas. Clipping means that only clipped areas will be drawn. So we will clip everything on canvas except our TextView
. As this operation affects how everything is drawn on canvas, we will wrap this operation in save/restore
block. This will allow us to save canvas state, execute our draw operation and revert back to initial state without modifying any other canvas drawing logic. The only thing that is left is to call draw
on our gradient drawable. Drawable is initialized with RecyclerView
bounds. And it will draw itself for the full width and height of RecyclerView
. But because we have clipped everything except TextView
the only thing that will be drawn is the gradient in TextView
bounds:
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
0.until(parent.childCount)
.map { parent.getChildAt(it) }
.mapNotNull { parent.findContainingViewHolder(it) }
.filter { it.itemViewType == itemViewType }
.map { it as You.Holder }
.forEach {
// obtain TextView. We cannot use the whole itemView as it fills the parent
val textView = it.textView
// it's required for us to have x,y coordinates _relative_ to RecyclerView
val (x, y) = textView.relativeTo(parent)
// save current canvas state
with(c.save()) {
// clip our TextView, everything else except clipped area will be ignored
c.clipRect(x, y, x + textView.width, y + textView.height)
// draw our drawable
drawable.draw(c)
// restore canvas state
c.restoreToCount(this)
}
}
}
Finally, let's register our YouMessageDecoration
with RecyclerView
. Adapt
allows us to do it in an Item directly:
class You(private val message: CharSequence) : Item<You.Holder>(message.hashCode().toLong()) {
override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder {
return Holder(inflater.inflate(R.layout.item_you, parent, false))
}
override fun render(holder: Holder) {
holder.textView.text = message
}
override fun recyclerDecoration(recyclerView: RecyclerView): RecyclerView.ItemDecoration? {
return YouMessageDecoration(recyclerView, viewType())
}
class Holder(view: View) : Item.Holder(view) {
val textView = requireView<TextView>(R.id.text)
}
}
Or we can do it in a regular way:
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// create adapt instance
val adapt = Adapt.create()
// initialize RecyclerView
findViewById<RecyclerView>(R.id.recycler_view).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
setHasFixedSize(true)
adapter = adapt
addItemDecoration(YouMessageDecoration(
this,
// we can use _default_ generated itemViewType
// as long as we do not specify in explicitly
Item.generatedViewType(You::class.java)))
}
// set items
adapt.setItems(items())
}
private fun items(): List<Item<*>> {...}
}
If we launch the sample application now we will see the result that we were targeting.
Although we already have working copy of desired functionality let's try to optimize it. We have a lot of cave/clip/draw/restore
operations. Maybe if we could reduce them, we would get better results. For example, we could use Canvas#clipRect(Rect, Region.Op)
method with Region.Op.UNION
, which would allow us to clip all visible items first and then execute single draw operation. Unfortunately this method is deprecated:
/**
* Modify the current clip with the specified rectangle, which is
* expressed in local coordinates.
*
* @param rect The rectangle to intersect with the current clip.
* @param op How the clip is modified
* @return true if the resulting clip is non-empty
* @deprecated Region.Op values other than {@link Region.Op#INTERSECT} and {@link Region.Op#DIFFERENCE} have the ability to expand the clip. The canvas clipping APIs are intended to only expand the clip as a result of a restore operation. This enables a view parent to clip a canvas to clearly define the maximal drawing area of its children. The recommended alternative calls are {@link #clipRect(Rect)} and {@link #clipOutRect(Rect)}; As of API Level API level {@value Build.VERSION_CODES#P} only {@link Region.Op#INTERSECT} and {@link Region.Op#DIFFERENCE} are valid Region.Op parameters.
* @deprecated 8.0 Oreo (26)
*/
@Deprecated
public boolean clipRect(@NonNull Rect rect, @NonNull Region.Op op) {
checkValidClipOp(op);
return nClipRect(mNativeCanvasWrapper, rect.left, rect.top, rect.right, rect.bottom, op.nativeInt);
}
@deprecated 8.0 Oreo (26)
javadoc entry is added by Enhance utility. It processes Android sources and adds@deprecated
and@since
tags with appropriate Android version information
There is another method that can be suitable for us: Canvas#clipPath(Path)
. It allows us to prepare all the clipping and execute single draw operation. Let's try to use it:
private val path = Path()
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
// reset/rewind before (can still hold values from previous iteration)
path.rewind()
0.until(parent.childCount)
.map { parent.getChildAt(it) }
.mapNotNull { parent.findContainingViewHolder(it) }
.filter { it.itemViewType == itemViewType }
.map { it as You.Holder }
.forEach {
val textView = it.textView
val (x, y) = textView.relativeTo(parent)
path.addRect(
x.toFloat(),
y.toFloat(),
x + textView.width.toFloat(),
y + textView.height.toFloat(),
Path.Direction.CCW)
}
// let's check if our path is not empty
if (!path.isEmpty) {
with(c.save()) {
c.clipPath(path)
drawable.draw(c)
c.restoreToCount(this)
}
}
}
Unfortunately quick measurement of the performance shows that clipPath
solution takes longer time to execute. Here are the approximate measurements of each onDraw
call as recorded on a Pixel XL (256 steps measured):
min (ns) | max (ns) | avg (ns) | |
---|---|---|---|
clipRect |
94062 | 1612239 | 305215.92 |
clipPath
| 225208 | 1902917 | 509384.98 |
As you can see both variants have acceptable performance of under 1 millisecond for each onDraw
method call on average. But clipRect
is still a bit faster.
Okay, let's return to our initial version and try optimize it by writing less ideamatic Kotlin. We are using list operations heavily which are translated into multiple iterations in the bytecode. Let's change that:
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
var view: View
var holder: RecyclerView.ViewHolder
// wait, a for-loop?
for (i in 0 until parent.childCount) {
view = parent.getChildAt(i)
holder = parent.findContainingViewHolder(view) ?: continue
if (itemViewType == holder.itemViewType) {
val textView = (holder as You.Holder).textView
val (x, y) = textView.relativeTo(parent)
c.withSave {
clipRect(x, y, x + textView.width, y + textView.height)
drawable.draw(this)
}
}
}
}
I had also added a small extension function for Canvas
to make save/restore
operations less error-prone:
private companion object {
private val _POINT = Point()
private fun initDrawableBounds(view: View, drawable: Drawable) {...}
private fun View.relativeTo(group: ViewGroup): Point {...}
private operator fun Point.component1() = this.x
private operator fun Point.component2() = this.y
private inline fun Canvas.withSave(action: Canvas.() -> Unit) {
val save = this.save()
try {
action()
} finally {
this.restoreToCount(save)
}
}
}
Here are the updated measurement results:
min (ns) | max (ns) | avg (ns) | |
---|---|---|---|
clipRect |
94062 | 1612239 | 305215.92 |
clipPath
| 225208 | 1902917 | 509384.98 |
clipRect (loop) |
47604 | 864532 | 209149.99 |
The same optimization can be also applied to the clipPath
method which will make it perform better. Another few nanoseconds can be shed-off by rewriting YouMessageDecoration
in even less ideamatic Kotlin — in CSS in Java. But the performance gains will be minimal, so consider this a joke :)
What's next
We have covered single use-case when all chat messages have rectangular background. And it shows basic principles of fixed scrolling gradient background for Android. But it's missing one minor detail — rounded corners… We will deal with them in the next article. Stay tuned (part 2).