AppCompat View Inflation
Aug 6, 2018 · 10 minute readcode
android
Today, it’s likely that most newly written Activity
s extend AppCompatActivity
. The fact that it adds backwards compatibility has made life significantly easier for us as Android developers. Yet, how does it work? Specifically, how does it replace a TextView
in a xml layout with an AppCompatTextView
? This post aims to do a deep dive into one aspect of AppCompatActivity
- view inflation.
Factory2
In Android, we often write our layouts in xml files. These are bundled with our app (converted to binary xml for performance reasons by aapt/2), and are then inflated at runtime by using LayoutInflater
.
There are two neat little methods present on a LayoutInflater
called setFactory
and setFactory2
- this method’s documentation says:
Attach a custom Factory interface for creating views while using this LayoutInflater. This must not be null, and can only be set once; after setting, you can not change the factory. This is called on each element name as the xml is parsed. If the factory returns a View, that is added to the hierarchy. If it returns null, the next factory default onCreateView(View, String, AttributeSet) method is called.
Note that Factory2 implements Factory
, so for any api 11+ apps, setFactory2
is what should be used. This essentially gives us a way to intercept the creation of every tag (view element) in xml. Let’s see this in practice:
class FactoryActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
layoutInflater.factory2 = object : LayoutInflater.Factory2 {
override fun onCreateView(parent: View?,
name: String,
context: Context,
attrs: AttributeSet): View? {
if (name == "TextView") {
return RedTextView(context, attrs)
}
return null
}
override fun onCreateView(name: String,
context: Context,
attrs: AttributeSet): View? {
return onCreateView(null, name, context, attrs)
}
}
super.onCreate(savedInstanceState)
setContentView(R.layout.factory)
}
Here, we’re just setting a Factory2
on the LayoutInflater
for this Context
. Whenever we find a TextView
, we’re replacing it with our own subclass.
RedTextView
is just a subclass of TextView
with an extra setBackgroundColor
call to set the background to red at the end of each constructor:
class RedTextView : AppCompatTextView {
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet?) :
super(context, attrs) { initialize() }
constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int) :
super(context, attr, defStyleAttr) { initialize() }
private fun initialize() { setBackgroundColor(Color.RED) }
}
In this case, factory.xml
looks like:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Hello" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="World" />
</LinearLayout>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Welcome" />
</LinearLayout>
When we run this and use Android Studio’s Layout Inspector, we find that all our TextView
s are now RedTextView
s - awesome!
AppCompatActivity and Factory2
If we just change the FactoryActivity
above to extend AppCompatActivity
instead, we’ll see that our TextView
s are, indeed, RedTextView
s, but the Button
we added remains a Button
instead of becoming an AppCompatButton
. Why?
The first two lines of AppCompatActivity
’s onCreate
are:
final AppCompatDelegate delegate = getDelegate();
delegate.installViewFactory();
getDelegate()
returns the correct delegate depending on the api version (AppCompatDelegateImplV14
, AppCompatDelegateImplV23
, AppCompatDelegateImplN
, etc). The next method installs the view factory - this code calls setFactory2
when layoutInflater.getFactory
returns null
- it does nothing otherwise.
The fact that our Button
does not change now makes sense since AppCompatActivity
doesn’t install its Factory
when one is already installed.
Note that the setFactory2
in FactoryActivity
is before super.onCreate
- if it isn’t, setFactory2
will throw an exception when the parent is AppCompatActivity
, since it sets its own Factory2
, and as the documentation says, “This must not be null, and can only be set once; after setting, you can not change the factory.”
Playing nice with AppCompatActivity’s Factory2
So what if I want to have my own Factory2
, but I also want to let AppCompatActivity
run its Factory
? Let’s look at some ways to do this.
Delegating to AppCompatDelegate
Inside of AppCompatDelegate
, we can see a method called createView
(not to be confused with onCreateView
that is implementing Factory
and Factory2
):
/**
* This should be called from a
* {@link android.view.LayoutInflater.Factory2 LayoutInflater.Factory2}
* in order to return tint-aware widgets.
* <p>
* This is only needed if you are using your own
* {@link android.view.LayoutInflater LayoutInflater} factory, and have
* therefore not installed the default factory via {@link #installViewFactory()}.
*/
public abstract View createView(@Nullable View parent,
String name,
@NonNull Context context,
@NonNull AttributeSet attrs);
With this information, we can just change our setFactory2
call to delegate to AppCompatDelegate
’s when we don’t want to handle it:
override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
layoutInflater.factory2 = object : LayoutInflater.Factory2 {
override fun onCreateView(parent: View?,
name: String,
context: Context,
attrs: AttributeSet): View? {
if (name == "TextView") {
return RedTextView(context, attrs)
}
// delegate to AppCompatActivity's getDelegate()
return delegate.createView(parent, name, context, attrs)
}
override fun onCreateView(name: String,
context: Context,
attrs: AttributeSet): View? {
return onCreateView(null, name, context, attrs)
}
}
super.onCreate(savedInstanceState)
setContentView(R.layout.factory)
}
Running this, we indeed see that our TextView
s have become RedTextView
s, and that our Button
has become an AppCompatButton
- success!
Overriding the viewInflaterClass
If we look at createView
from AppCompatDelegate
, we’ll see code that it instantiates an AppCompatViewInflater
using reflection if one is not already set. The specific class it instantiates comes from R.styleable.AppCompatTheme_viewInflaterClass
, which is set to AppCompatViewInflater
by default.
By setting the theme for FactoryActivity
to something like:
<style name="FactoryTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="viewInflaterClass">com.cafesalam.experiments.app.ui.CustomViewInflater</item>
</style>
We can have AppCompatDelegate
use our subclass of AppCompatViewInflater
:
class CustomViewInflater : AppCompatViewInflater() {
override fun createTextView(context: Context, attrs: AttributeSet) =
RedTextView(context, attrs)
}
Google’s Material Design Components project actually uses this approach to override the creation of each Button
with a corresponding MaterialButton
that subclasses AppCompatButton
, as can be seen here.
This approach is very powerful, since it allows an app that uses a library like Material Design Components to get tinting and material design buttons by doing nothing except setting the proper theme.
Note that AppCompatViewInflater
also provides a fallback createView
method that can be overridden to override new components that aren’t handled by default (it is called if the base AppCompatViewInflater
doesn’t handle the particular widget type).
Custom LayoutInflater
A third way to do this is by having your own LayoutInflater
that is always returned when getSystemService
is called via a ContextThemeWrapper
(installed by overwriting attachBaseContext
on the Activity
). This custom LayoutInflater
can then wrap setFactory2
method calls to call the underlying Factory2
, and to call its own logic before or after. This method is used by the ViewPump library (which is where I learned about it).
Implementation Details
This section covers some of the nitty gritty implementation details of AppCompatDelegate
’s way of view inflation (since I found it pretty interesting and learned a few things along the way here).
onCreateView
We’d expect the Factory2
’s onCreateView
method just to call directly to createView
(mentioned in Delegating to AppCompatDelegate
above). In fact, it does do that, but looking at the code, there’s an extra bit - a call to callActivityOnCreateView
. In AppCompatDelegateImplV14
this looks like this:
@Override
View callActivityOnCreateView(View parent,
String name,
Context context,
AttributeSet attrs) {
// On Honeycomb+, Activity's private inflater factory will handle
// calling its onCreateView(...)
return null;
}
Looking at LayoutInflater
’s source, we can see that part of createViewFromTag
tries to get the view from the factory, and, upon not finding it, falls back to mPrivateFactory
, and finally falling back to trying to create the class that the tag refers to. mPrivateFactory
is set by Activity
in its constructor. (Interestingly enough, it is this mPrivateFactory
that is responsible for inflating fragment
s as seen here).
On Gingerbread through Honeycomb, LayoutInflater
doesn’t have a notion of mPrivateFactory
to allow the Activity
to run its own fallback logic for creating views. Consequently, callActivityOnCreateView
calls this method to allow that code to run on older APIs. This is mostly irrelevant now since AppCompat is now 14+ anyway, but an interesting thing I learned from this is about Window.Callback.
Window.Callback
is an API from a Window back to its caller. This allows the client to intercept key dispatching, panels and menus, etc. It is wrapped by AppCompatActivity
to allow it to handle certain events like a key being pressed (to handle menu and back button presses) among other things.
createView
At a high level, createView
in AppCompatDelegateImplV9
does 2 things - first, it creates the AppCompatViewInflater
or respective subclass reflectively, reading from the theme as mentioned in Overriding the viewInflaterClass
above. Second, it asks this inflater to create the view.
createView
on AppCompatViewInflater
figures out the correct context
(taking into account app:theme
and android:theme
as necessary along with wrapping the context for tinting as necessary), and thereafter creates the proper AppCompat flavored widget, depending on the name
passed in (i.e. if it’s a TextView
, call createTextView
which returns an AppCompatTextview
, and so on).
Supporting app:theme
On Lollipop and above, app:theme
can be applied to a view to override the theme for that specific view and its children. AppCompat replicates this behavior pre-api 21 by inheriting the context
of the parent view (so long as that parent view is not the root view of the layout being inflated).
Before AppCompat tries to inflate the view, it gets the parent context (assuming it should inherit it as per above), and then tries to create a themed context containing android:theme
if pre-Lollipop (since Lollipop handles android:theme
automatically) and app:theme
otherwise. This makes sure the widget is inflated with the correct context
.
As an aside, AppCompat will also wrap the context with a TintContextWrapper
pre-Lollipop (api 21) if the developer explicitly asked to allow for appcompat vectors to be used from resources (to allow vector drawable usage in things like android:src
) via the disabled by default call to setCompatVectorFromResourcesEnabled
on AppCompatDelegate
.
Creating the View and Fallbacks
Given this information, the code is ready to figure out what view to create - it goes through a list of supported widgets in a switch
statement, handling the common views like TextView
, ImageView
, etc by generating their AppCompat subclasses. If the view is of an unknown type, it calls createView
with the correct Context
(this method returns null
by default, but may have been overwritten by a subclass of AppCompatViewInflater
).
If the view is still null at this point, there is a check to see if the view’s original Context
is different than its parent’s. This happens when android:theme
on the child is different from that on its parent. In order to replicate the behavior of Lollipop and let children inherit the parent’s theme, AppCompat attempts to reflectively create the view with the correct Context
in these cases.
After some code for fixing android:onClick
behavior for ContextWrapper
s, the View
is returned. If this View
is still null
, the LayoutInflater
will try to inflate it by creating the underlying view.
Summary and use cases
In summary, AppCompatActivity
sets a Factory2
on a LayoutInflater
to intercept view creation to handle backwards compatibility (adding tinting support to the widgets, handling android:theme
, etc). It also keeps this expandable so a developer can have custom handling here as well.
Outside of AppCompat, this trick has been used to accomplish many interesting things - among the first I saw were those done by the (now deprecated) Probe library. Using a combination of Factory2
and a Gradle plugin, a developer could easily enable an OvermeasureInterceptor to identify extra measure passes, or a LayoutBoundsInterceptor to highlight the bounds of a View.
The Calligraphy library uses this trick to make it easy to add fonts to TextView
s. It uses the ViewPump library (mentioned earlier), which also lists some potential uses on its recipes wiki.
Finally, Google’s Material Components for Android project installs its own AppCompatViewInflater
to replace Button
s with MaterialButton
s.