Creating Custom LayoutInflater

Originally published here.

Preface

I think LayoutInflater is one of the most under-researched and under-appreciated components of the Android framework. Which is ironic, in a way, since it’s used in pretty much every application ever written.

Anyway, in this short article I intend to show a simple example of custom LayoutInflater, discuss a few specifics of its inner workings, and hopefully, show why it’s worth using in your application!

Just give me the code

If you don’t really want to follow the article and would rather just poke the code by yourself here’s the repo with the sample to play with.

What is LayoutInflater

While LayoutInflater is used pretty much in every UI component on Android platform, more often than not it’s hidden under the hood (Activity’s getLayoutInflater) and when it’s used explicitly (RecyclerView’s Adapter) - it’s used as black-box.

So what does it do? Well, it’s quite straightforward - it translates XML layout into a View object. And also sometimes it swaps default implementation of views to their AppCompat counter-parts (not directly tho, i.e. there is no AppCompatLayoutInflater, see androidx/appcompat/app/AppCompatDelegateImpl.java instead). And also it uses reflection to create objects (and if you ever wondered why the creation of layout from XML is much slower than the creation of layout in code, here’s the part of the reason why). And also it has factories inside, so you won’t get away with just subclassing LayoutInflater.

Well in order to get it all together let’s concentrate on the practical example. In this article, we will focus on the creation of LayoutInflater which will allow us to define a custom attribute to parse and the behaviour based on the value of this attribute. In this specific example, we will make a “color” attribute which will apply colours from our custom theme, but obviously, you can come up with any other attributes and behaviours you want.

Defining Theme

This will be really straightforward, let’s define a data class with few colours inside:

gist

import android.graphics.Color
	

data class CustomTheme(
    val darkPurple: Int = Color.parseColor("#330C2F"),
	val maximumPurple: Int = Color.parseColor("#7B287D"),
	val violetBlueCrayola: Int = Color.parseColor("#7067CF"),
	val lavenderBlue: Int = Color.parseColor("#B7C0EE"),
	val aeroBlue: Int = Color.parseColor("#CBF3D2")
)

Defining layout

We will start with defining custom namespace like this:

xmlns:custom="https://syllogismobile.wordpress.com/"

Value is totally up to you, just make sure it starts with “https://” otherwise Android Studio will complain and throw a warning.

We will use attribute named “color” which value will be the name of colour defined in our custom theme above like this:

custom:color="darkPurple"

Our example layout will look like this:

gist

	<?xml version="1.0" encoding="utf-8"?>
	<LinearLayout
        xmlns:android="https://schemas.android.com/apk/res/android"
	    xmlns:app="https://schemas.android.com/apk/res-auto"
	    xmlns:tools="https://schemas.android.com/tools"
	    xmlns:custom="https://syllogismobile.wordpress.com/"
	    android:layout_width="match_parent"
	    android:layout_height="match_parent"
	    android:orientation="vertical"
	    tools:context=".MainActivity"
	    custom:color="darkPurple">
	

	    <TextView
	        android:layout_width="match_parent"
	        android:layout_height="wrap_content"
	        android:text="Hello World!"
	        android:textSize="36sp"
	        android:gravity="center"
	        app:layout_constraintBottom_toBottomOf="parent"
	        app:layout_constraintLeft_toLeftOf="parent"
	        app:layout_constraintRight_toRightOf="parent"
	        app:layout_constraintTop_toTopOf="parent"
	        custom:color="lavenderBlue"/>
	

	    <View
	        android:layout_width="match_parent"
	        android:layout_height="50dp"
	        custom:color="aeroBlue"/>
	

	    <TextView
	        android:layout_width="match_parent"
	        android:layout_height="wrap_content"
	        android:text="Hello World!"
	        android:textSize="36sp"
	        android:gravity="center"
	        app:layout_constraintBottom_toBottomOf="parent"
	        app:layout_constraintLeft_toLeftOf="parent"
	        app:layout_constraintRight_toRightOf="parent"
	        app:layout_constraintTop_toTopOf="parent"
	        custom:color="violetBlueCrayola"/>

	</LinearLayout>

Note that custom namespace is not strictly required, you can use it like this:

color="violetBlueCrayola"

However, Android Studio will highlight it with two warnings (yes, two). As long as you don’t mind it, you can just don’t use a custom namespace. 

Implementing custom LayoutInflater

We will implement a very generic LayoutInflater which will accept a definition of attributes from the outside. Let’s start with the class definition.

Constructor

gist

class CustomLayoutInflater(
	    context: Context,
	    private val parent: LayoutInflater = LayoutInflater.from(context)
	
): LayoutInflater(parent, context)

Note that by default we use the factory method from LayoutInflater, but we also allow to provide a custom value (for example, mock parent for tests).

Holding appliers

Next, we define a map that will hold appliers: lambdas that will apply the value of an attribute to the view:

private val registeredAppliers: MutableMap<String, (View, String) -> Unit> = mutableMapOf()

Injecting Factory2

Now we need to inject our implementation of Factory2 into inflater like this:

gist

init {
    factory2 = WrapperFactory(factory2)
	
}

WrapperFactory

WrapperFactory is defined like this:

gist

inner class WrapperFactory(private val originalFactory: Factory2?) : Factory2 {
	    override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
	        return originalFactory?.onCreateView(name, context, attrs)?.apply { runAppliers(this, attrs) }
	    }
	

	    override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
	        return originalFactory?.onCreateView(parent, name, context, attrs)?.apply { runAppliers(this, attrs) }
	    }
	
}

As you can see we simply use the original factory (which is set from the parent) and then run appliers if it returns an actual view.

Overriding onCreateView

Note that factory2 is taking precedence in view creation, however, it’s not going to be able to create the view all the time. For this case we need to override the method in the LayoutInflate itself like this:

gist

override fun onCreateView(
	    name: String?,
	    attrs: AttributeSet?
	): View? {
	    for (prefix in androidPrefixes) {
	        try {
	            val view = createView(name, prefix, attrs)
	            if (view != null) return view.apply { runAppliers(this, attrs) }
	        } catch (e: ClassNotFoundException) { }
	    }
	

	    return super.onCreateView(name, attrs)?.apply { runAppliers(this, attrs) }
	
}

Where androidPrefixes are:

gist

private val androidPrefixes = listOf(
	    "android.widget.",
	    "android.webkit.",
	    "android.app."
	
)

As you could’ve guessed, default Factory2 implementation won’t instantiate most usual Android views like LinearLayout, which is weird, but well, what can you do. 

Implementing runAppliers

Now let’s implement runAppliers:

gist

private fun runAppliers(view: View, attrs: AttributeSet?) {
	    if (attrs == null) return
	

	    for (registeredTag in registeredAppliers.keys) {
	        attrs.getAttributeValue(NAMESPACE, registeredTag)?.let { value ->
	            registeredAppliers[registeredTag]?.let {
	                it(view, value)
	            }
	        }
	    }
	
}

Note that here we verify that attribute has a proper namespace. If you opted into approach without custom namespace - you can pass null instead of the namespace.

Putting it all together

Here’s whole code for CustomLayoutInflater:

gist

import android.content.Context
	import android.util.AttributeSet
	import android.view.LayoutInflater
	import android.view.View
	

	class CustomLayoutInflater(
	    context: Context,
	    private val parent: LayoutInflater = LayoutInflater.from(context)
	) : LayoutInflater(parent, context) {
	    private val registeredAppliers: MutableMap<String, (View, String) -> Unit> = mutableMapOf()
	

	    init {
	        factory2 = WrapperFactory(factory2)
	    }
	

	    fun registerApplier(tag: String, applier: (view: View, value: String) -> Unit): CustomLayoutInflater {
	        registeredAppliers[tag] = applier
	        return this
	    }
	

	    override fun cloneInContext(newContext: Context): LayoutInflater {
	        return CustomLayoutInflater(newContext, this)
	    }
	

	    override fun onCreateView(
	        name: String?,
	        attrs: AttributeSet?
	    ): View? {
	        for (prefix in androidPrefixes) {
	            try {
	                val view = createView(name, prefix, attrs)
	                if (view != null) return view.apply { runAppliers(this, attrs) }
	            } catch (e: ClassNotFoundException) { }
	        }
	

	        return super.onCreateView(name, attrs)?.apply { runAppliers(this, attrs) }
	    }
	

	    private fun runAppliers(view: View, attrs: AttributeSet?) {
	        if (attrs == null) return
	

	        for (registeredTag in registeredAppliers.keys) {
	            attrs.getAttributeValue(NAMESPACE, registeredTag)?.let { value ->
	                registeredAppliers[registeredTag]?.let {
	                    it(view, value)
	                }
	            }
	        }
	    }
	

	    inner class WrapperFactory(private val originalFactory: Factory2?) : Factory2 {
	        override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
	            return originalFactory?.onCreateView(name, context, attrs)?.apply { runAppliers(this, attrs) }
	        }
	

	        override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
	            return originalFactory?.onCreateView(parent, name, context, attrs)?.apply { runAppliers(this, attrs) }
	        }
	    }
	

	    companion object {
	        private val androidPrefixes = listOf(
	            "android.widget.",
	            "android.webkit.",
	            "android.app."
	        )
	

	        private const val NAMESPACE = "https://syllogismobile.wordpress.com/"
	        fun from(context: Context): CustomLayoutInflater {
	            return CustomLayoutInflater(context)
	        }
	    }
	
}

Using inflater in Activity

This is very simple, so I’ll just show the code since it doesn’t have anything worth explaining:

gist

import android.os.Bundle
	import android.view.View
	import android.widget.TextView
	import androidx.appcompat.app.AppCompatActivity
	

	class MainActivity : AppCompatActivity() {
	    private val theme = CustomTheme()
	

	    override fun onCreate(savedInstanceState: Bundle?) {
	        super.onCreate(savedInstanceState)
	        setContentView(
	            CustomLayoutInflater
	                .from(this)
	                .registerApplier(COLOR_ATTRIBUTE, this::applyColorAttributeValue)
	                .inflate(R.layout.activity_main, null)
	        )
	    }
	

	    private fun applyColorAttributeValue(view: View, colorValue: String) {
	        val color = when(colorValue) {
	            "darkPurple" -> theme.darkPurple
	            "maximumPurple" -> theme.maximumPurple
	            "violetBlueCrayola" -> theme.violetBlueCrayola
	            "lavenderBlue" -> theme.lavenderBlue
	            "aeroBlue" -> theme.aeroBlue
	            else -> error("Unexpected color $colorValue")
	        }
	

	        when(view) {
	            is TextView -> view.setTextColor(color)
	            else -> view.setBackgroundColor(color)
	        }
	    }
	

	    private companion object {
	        const val COLOR_ATTRIBUTE = "color"
	    }
	
}

As you can see we handle only TextView and View here, but you can extend/change it and add new attributes to handle as you wish.

Closing words

I hope this article managed to showcase the power of custom LayoutInflaters. While it is not the approach you would take in every single project, I think it’s worth considering for every project you’re working on!

Good luck and see you in the next articles (hopefully)!

要查看或添加评论,请登录

Ilia Kosynkin的更多文章

社区洞察

其他会员也浏览了