Calligraphy 2.0.0

It's been a long time coming but version 2.0 is here. After talking with some friends, I used to work with. Calligraphy was born on the 20th of December, .

"Gosh darn, why is it so hard to add custom fonts to Views in Android!"

I'm sure most of us have thought that once or twice in our Android travels.

Version 1.x was a great stepping stone in a direction of LayoutInflater injection. It achieved what seemed unnatural!
It became clear that the LayoutInflater is far from friendly for composition.
The ActionBar title is a great example. We have to scan the TextView ID for the name: "actionbar_title"||"actionbar_subtitle". Because the ActionBar applies its styling AFTER these inner View's creation!

Lollipop

Along came Lollipop, with AppCompat v23, two stunning additions to the Android development landscape. Inflation changes pursued.
Google themselves are now doing LayoutInflation injection in the AppCompat classes. (Tinting on the stock Android Views if you are wondering).

Version 1.2.1 was built as a quick fix, but Daniel Lew pointed out.
The in-place fix ignored the natural LayoutInflation flow. So 2.0 work commenced and a new injection flow designed.

The LayoutInflater

For those unaware of how LayoutInflation works, its pretty complex flow:

Start with the LayoutInflater.from(context). This should return the most relevant LayoutInflater. But when incorrectly overridden, CalligraphyLayoutInflater gets the wrong Context!

Next, inflate(R.layout.my_layout) will use a custom xml deserializer to parse the compiled layout xml. Returning a String and Attrs.

This will then iterate down to following tree:

  • Factory or Factory2 these are overriden factories set usually by yourselves.

  • Android 3.0+. A private Factory2, this is the onCreateView() callback in the Activity class. The base context sets the private Factory2 onto the LayoutInflater when the base Context is set.

  • Then either onCreateView() or createView() in the LayoutInflater if both the above fail.

See this excerpt below from the base LayoutInflater:

if (mFactory2 != null) {  
  view = mFactory2.onCreateView(parent, name, viewContext, attrs);
} else if (mFactory != null) {
  view = mFactory.onCreateView(name, viewContext, attrs);
} else {
  view = null;
}
if (view == null && mPrivateFactory != null) {  
  view = mPrivateFactory.onCreateView(parent, name, viewContext, attrs);
}
if (view == null) {  
  final Object lastContext = mConstructorArgs[0];
  mConstructorArgs[0] = viewContext;
  try {
    if (-1 == name.indexOf('.')) {
      view = onCreateView(parent, name, attrs);
    } else {
      view = createView(name, null, attrs);
    }
  } finally {
    mConstructorArgs[0] = lastContext;
  }
}

Whats the problem then?

Originally we just called setFactory() on the LayoutInflater. Job done.
That didn't cut it any more. The AppCompat and Fragments rely on calling PrivateFactory#onCreateView(). We needed a hands off approach, only touch the view once it's created. Basically, we created the View before anyone else got a look-in. Not good!!

Problems? Many:

  1. Factory2 provides two methods, in total that is 6 slightly different calls to intercept.

  2. Setting the private factory has to be done via reflection as this is a hidden method. I do semi understand this decision on Google's behalf but it was short sighted.

  3. Can't override createView(), any custom View gets piped to this method by default.

  4. Implementing the createView() required reflection, we don't have access to the constructor context! An appalling influx of field and var calls. (I doubt this code is tested properly by Google!)

Solutions:

Fixing 1. was simple enough, we created hooks for all methods. Wrap the original factories for ours. If the original factory created the View, inject the font, otherwise we keep going.

The hook into Activity#onCreateView() for issue 2 was tricker. We get and set the private factory, wrapping it with our own, like solution 1. Hacky to say the least. This is also set inside inflate, otherwise our factory is overwritten by the Activity.

The createView() implementation held up the rewrite for a long time. After discussion with a few contributors, we came up with the lesser of evils. When we have a custom view and it hasn't been instantiated before PrivateFactory#onCreateView() returns. View creation is intercepted. The View is subsequently created by Calligraphy. As no one else can override createView(), we assume no harm us doing so.
We then create the class as close as possible to how the native impl creates it. See CalligraphyLayoutInflater#createCustomViewInternal(). This injects either the Inflater or View's parent Context into mConstructorArgs field. Then calls the native createView() method. It's pretty imperative this is correct. Alot of the new Lollipop views, such as Toolbar, base there theming and styling off their parent.

The end result? A 3 line implementation for custom fonts!

/build.gradle

compile 'uk.co.chrisjenx:calligraphy:2.0.0'  

/Application.java

@Override
public void onCreate() {  
    super.onCreate();
    CalligraphyConfig.initDefault(new CalligraphyConfig.Builder().setDefaultFontPath("fonts/Roboto-ThinItalic.ttf").build());
}

/BaseActivity.java

@Override
protected void attachBaseContext(Context newBase) {  
 super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase));
}
comments powered by Disqus