Better Custom Views with Delegates

Introduction

Reusable UI components are all the rage these days. Everyone and their dog has their own design system and their set of components that their apps are built up from - especially tech giants.

These give you several benefits:

  • Well-defined, consistent branding across your application.
  • Excellent code reuse, as you can implement these components once, and then just drop them into a screen whenever you need them.
  • A predefined set of building blocks, which gives your designers a clear idea of what they can use when dreaming up a screen.

In this article, we'll take a look at implementing custom components easily by using Kotlin's delegates. In addition to the points above, we'll also focus on one last crucial part of implementing custom components: providing your fellow developers an easy to use API.

Specification

Our example component will be a card that can display an icon, a title, and some content text. All of these three pieces of data will be customizable by clients using the component:

The component in action.

Additionally, all of these will be optional, and blank by default. The layout will adapt dynamically in case one of them is missing:

The component with some data omitted.

With that, let's jump into it!

Basic View implementation

We'll create a custom View called InfoCard. Our layout hierarchy will be the following:

The layout hierarchy of the custom View.

Our InfoCard itself will be a FrameLayout, which contains the MaterialCardView that gives us the card style that we're looking for. Inside that, a ConstraintLayout lays out our various content Views.

Why isn't InfoCard a MaterialCardView? By making it a FrameLayout that wraps the card, we can add margins to the card, which will be contained within our component. These margins are very important in this case, as without them, the edges of the card and the shadows generated by its elevation would be easily cut off. Here's a comparison of creating this component with (top) or without (bottom) these built-in margins:

A comparison of implementing the custom View with margins included or not.

So we'll need to subclass FrameLayout. Even though we need several constructors here, we'll implement them by hand, and not use @JvmOverloads.

class InfoCard : FrameLayout {

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    init {
        inflate(context, R.layout.view_info_card, this)
    }

}

The XML layout we're inflating into the FrameLayout is simple enough:

<?xml version="1.0" encoding="utf-8"?>  
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"  
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="4dp"
    android:orientation="vertical">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="12dp">

        <ImageView
            android:id="@+id/infoCardImage"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:src="@mipmap/ic_launcher"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/infoCardTitleText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:text="Title"
            android:textSize="18sp"
            android:textStyle="bold"
            app:layout_constraintBottom_toTopOf="@+id/infoCardContentText"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/infoCardImage"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_chainStyle="packed" />

        <TextView
            android:id="@+id/infoCardContentText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:text="Lorem ipsum dolor sit amet..."
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/infoCardImage"
            app:layout_constraintTop_toBottomOf="@+id/infoCardTitleText" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</com.google.android.material.card.MaterialCardView>  

Let's review it quickly:

  • It has a 4dp margin around the MaterialCardView which is the root of the layout, for the reasons shown above.
  • The two TextViews are in a packed chain, so that they stay together, vertically centered on the card.
  • For now, this layout contains hardcoded values for src and text - we'll change this later. First, let's just see if it works as expected.

Placing an instance of this in our MainActivity will do the trick:

<hu.autsoft.customviewsarticle.infocard.InfoCard  
    android:id="@+id/infoCard"
    android:layout_width="300dp"
    android:layout_height="wrap_content" />

Yay! Let's move on to filling it with real data.

Configuration from code

First, we'll implement the customization of the card from Kotlin/Java code, at runtime. The interface for this on the component will be provided by three properties, representing the title, content, and icon, respectively. All of these properties will be implemented by using Delegates.observable from the Kotlin Standard Library:

var title: String? by Delegates.observable<String?>(null) { _, _, newTitle ->  
    infoCardTitleText.text = newTitle
}

This delegate takes a lambda as a parameter, which will be invoked every time the value of the property changes. We can use this callback to set the same value on the corresponding View. This can be viewed as a basic, manual form of data binding.

Note that these aren't just setters that will change the state of the UI. These properties have backing fields, where they actually store the data you set them to. This means that you can easily read the current String value of the title of the card, for example:

Log.d("VALUE", "Card's title is: ${infoCard.title}")  

To handle hiding the views when their content is empty, we can use isVisible from android-ktx, after setting the new values:

var title: String? by Delegates.observable<String?>(null) { _, _, newTitle ->  
    infoCardTitleText.text = newTitle
    infoCardTitleText.isVisible = !newTitle.isNullOrEmpty()
}

A side quest for typing

You might notice that there's a lot of typing involved in this delegate's declaration. Not in terms of hitting keys, but in terms of specifying the type of the delegate - twice. If we just omitted both declarations of String?, we'd be in trouble. The compiler would have to infer the type of the property solely from the initial value being passed in (null), which has the type Nothing?. Without going into too much detail (you can learn more about Nothing here), this would mean that we could never set the property to any value other than null!

Something else has to be done. Omitting the first type and leaving the type parameter of the observable call would work, but it places the type of the property further down the line than where it usually is, making it harder to find at a glance:

var content by Delegates.observable<String?>(null) { ... }  

The other way would be much neater, declaring the type at the start of the line. However, this won't compile, as type inference unfortunately fails to propagate that type into the observable call:

var content: String? by Delegates.observable(null) { ... }  

There is a fix here, however. A new type inference algorithm for Kotlin has been in the works for some time now. You can learn more about it in this video from KotlinConf 2018 and you'll also find it mentioned in the recent(ish) release notes of Kotlin 1.40. This algorithm is able to resolve types in many complex scenarios that the old one couldn't deal with - and it happens to do the trick in our situation as well.

To enable it, the following compiler flag has to be set in build.gradle:

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {  
    kotlinOptions {
        freeCompilerArgs += "-XXLanguage:+NewInference"
    }
}

With this, the final version of the three properties can look like this:

var title: String? by Delegates.observable(null) { _, _, newTitle ->  
    infoCardTitleText.text = newTitle
    infoCardTitleText.isVisible = !newTitle.isNullOrEmpty()
}

var content: String? by Delegates.observable(null) { _, _, newContent ->  
    infoCardContentText.text = newContent
    infoCardContentText.isVisible = !newContent.isNullOrEmpty()
}

var icon: Drawable? by Delegates.observable(null) { _, _, newIcon ->  
    infoCardImage.setImageDrawable(newIcon)
    infoCardImage.isVisible = newIcon != null
}

Now, using Kotlin Android Extensions, we can set these values from code simply:

infoCard.icon = getDrawable(R.drawable.ic_leave)  
infoCard.title = "Time to leave!"  
infoCard.content = "If you leave now, you'll be right on time for you next appointment."  

Configuration from XML

Custom views are often set up from XML, using custom attributes. Let's create attributes for each of our content views, by adding the following in attrs.xml:

<?xml version="1.0" encoding="utf-8"?>  
<resources>  
    <declare-styleable name="InfoCard">
        <attr name="ic_title" format="string" />
        <attr name="ic_content" format="string" />
        <attr name="ic_icon" format="reference" />
    </declare-styleable>
</resources>  

Before, we've put hardcoded values for all of these in our layout XML. It's a good time to remove these at this point, and use the tools: prefix for them instead:

tools:src="@mipmap/ic_launcher"  
...
tools:text="Title"  
...
tools:text="Lorem ipsum dolor sit amet..."  

To process the attributes we've added, we'll forward the AttributeSet received in the constructors to an initView method:

constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {  
    initView(attrs)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {  
    initView(attrs)
}

This method will be very simple. After obtaining the attribute values for our custom View, all we need to do is set the values of our existing properties, which already know how to display this data on the UI:

private fun initView(attrs: AttributeSet?) {  
    attrs ?: return

    val attributeValues = context.obtainStyledAttributes(attrs, R.styleable.InfoCard)
    with(attributeValues) {
        try {
            icon = getDrawable(R.styleable.InfoCard_ic_icon)
            title = getString(R.styleable.InfoCard_ic_title)
            content = getString(R.styleable.InfoCard_ic_content)
        } finally {
            recycle()
        }
    }
}

To use these attributes from XML, we can add the following to an InfoCard element:

app:ic_icon="@drawable/ic_ok"  
app:ic_title="Success"  
app:ic_content="Purchase completed. We'll prepare your package soon."  

Custom delegates

If you create lots of components like this, you might want to consider extracting the logic contained in the observable delegates into your own, custom delegate implementation, so that you don't have to reimplement it every time.

There are many ways to design the API of such a delegate, especially regarding how you pass the TextView that it needs to manage to the delegate class. You can pass in the TextView itself if you're careful enough, or you can opt to pass in an ID, or a lambda that can produce the TextView...

If you don't get this right, you can face issues due to View lookups not being available at constructor time, if you haven't inflated your layout yet.

Here's one of the simpler implementations, which will ask for a TextView reference directly in its constructor. It also provides an optional Boolean parameter to control whether you want to hide the TextView when it's set to display nothing.

class TextViewDelegate(  
    private val textView: TextView,
    private val hideWhenEmpty: Boolean = true
) : ReadWriteProperty<View, String?> {

    private var value: String? = null

    override fun getValue(thisRef: View, property: KProperty<*>): String? {
        return value
    }

    override fun setValue(thisRef: View, property: KProperty<*>, value: String?) {
        this.value = value
        textView.text = value
        textView.isGone = hideWhenEmpty && value.isNullOrEmpty()
    }
}

To use such a delegate, you'll need to make sure that you inflate your layout before these properties are initialized. This means placing the initializer block before the property declarations. (If you want to know more about how class initialization happens in Kotlin, read this article.) This isn't a perfect solution, but it's the simplest one, and any misuse of the delegates will show up immediately as a crash the first time you try to instantiate it.

init {  
    inflate(context, R.layout.view_info_card, this)
}

var title by TextViewDelegate(infoCardTitleText)  

With these changes, setting the values of properties will continue to modify the UI as before, but you don't need to specify how a String is to be bound to a TextView every time.

Conclusion

That's it! We hope you found this method of creating custom Views useful, and can adopt it in your own custom components. You can find all the code for this article on GitHub, which we encourage you to check out, and play around with. Take a look at the commit history for a step-by-step evolution of the project.

We are @autsoftltd on Twitter, where you can follow us for more content like this, or to ask questions.

To learn even more about how custom delegates work, check out this article where we discuss how we've designed our library Krate, a simple SharedPreferences wrapper.