Search code examples
androidlayout-inflater

How to inflate Views like in Activity, so that TextView will become MaterialTextView, for example?


Background

I work on an app that doesn't always have an Activity (or AppCompatActivity to be precise). Sometimes it has a floating UI instead (using SAW permission), so it has only ApplicationContext.

The problem

Some of the UI is inflated there, and it could be nice to use the latest material libraries.

One example is MaterialTextView, which on normal situations it's inflated to replace TextView in the layout file:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent" tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView" android:layout_width="wrap_content"
        android:layout_height="wrap_content" android:layout_gravity="center"
        android:text="Hello World!" app:drawableStartCompat="@drawable/customringtone" />

</FrameLayout>

For this example alone, I will demonstrate it in an Activity. This would let the TextView to show its drawable:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val inflater = LayoutInflater.from(this)
        val binding = ActivityMainBinding.inflate(inflater, null, false)
        setContentView(binding.root)
    }
}

But, to show you how it works outside, use it a different inflater, which doesn't use the Activity:

val inflater =
    LayoutInflater.from(android.view.ContextThemeWrapper(applicationContext, R.style.AppTheme))

And the theme:

<style name="AppTheme" parent="@style/Theme.Material3.Light.NoActionBar">
</style>

In this case, the drawable of the TextView doesn't show up, and indeed if I check it in code (using compoundDrawablesRelative and compoundDrawables on it), I can see it has no drawables.

So, currently the workaround I have is to use MaterialTextView on my own, or use the older attributes and let the IDE ignore the suggestion to use app:drawableStartCompat.

EDIT: later when talking with Google, they wrote me:

This is already available via the AppCompatViewInflater API.

You install the AppCompatViewInflater API by creating an implementation of the LayoutInflater.Factory2 interface, overriding its onCreateView APIs to call AppCompatViewInflater's createView API. You can then install the factory onto your LayoutInflater via LayoutInflaterCompat.setFactory2(layoutInflater, yourFactory). See the source code for AppCompatDelegateImpl for an example.

Thing is, this solution has multiple issues:

  1. A lot of private functions
  2. A lot of code to copy
  3. very fragile as it depends on a library's implementation and relation between quite inner classes, but it won't work because it fails to be built.

Still, wanted to try (wrote about it here):


import android.content.Context
import android.content.res.TypedArray
import android.os.Build
import android.util.*
import android.view.*
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatViewInflater
import androidx.appcompat.widget.VectorEnabledTintResources
import androidx.core.view.LayoutInflaterCompat
object MaterialInflater {

    fun getMaterialInflater(context: Context): LayoutInflater {
        val factory = object : LayoutInflater.Factory2 {
            private var mAppCompatViewInflater: AppCompatViewInflater? = null

            override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
                return createView(parent, name, context, attrs);
            }

            override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
                return onCreateView(null, name, context, attrs);
            }

            fun createView(parent: View?, name: String?, context: Context,
                attrs: AttributeSet): View? {
                var appCompatViewInflater = mAppCompatViewInflater
                if (appCompatViewInflater == null) {
                    val a: TypedArray =
                        context.obtainStyledAttributes(androidx.appcompat.R.styleable.AppCompatTheme)
                    val viewInflaterClassName =
                        a.getString(androidx.appcompat.R.styleable.AppCompatTheme_viewInflaterClass)
                    a.recycle()
                    appCompatViewInflater = if (viewInflaterClassName == null) {
                        // Set to null (the default in all AppCompat themes). Create the base inflater
                        // (no reflection)
                        AppCompatViewInflater()
                    } else {
                        try {
                            val viewInflaterClass: Class<*> =
                                context.classLoader.loadClass(viewInflaterClassName)
                            viewInflaterClass.getDeclaredConstructor()
                                .newInstance() as AppCompatViewInflater
                        } catch (t: Throwable) {
                            //                            Log.i(TAG, "Failed to instantiate custom view inflater "
                            //                                    + viewInflaterClassName + ". Falling back to default.", t)
                            AppCompatViewInflater()
                        }
                    }
                    mAppCompatViewInflater = appCompatViewInflater
                }

                return appCompatViewInflater.createView(parent, name!!, context, attrs, false,
                    false,  /* Only read android:theme pre-L (L+ handles this anyway) */
                    true,  /* Read read app:theme as a fallback at all times for legacy reasons */
                    VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
                )
            }
        }
        val layoutInflater = LayoutInflater.from(context)!!
        LayoutInflaterCompat.setFactory2(layoutInflater, factory)
        return layoutInflater
    }
}

It shows an error due to using 2 private functions:

  • appCompatViewInflater.createView
  • VectorEnabledTintResources.shouldBeUsed()

The error is:

e: file:///C:/Users/User/Desktop/MyApplication/app/src/main/java/com/lb/myapplication/Foo.kt:64:46 Cannot access 'createView': it is package-private in 'AppCompatViewInflater'

And that's before I even try to use this new code...

The questions

How can I make it work like on Activity, so that the inflater will replace all Views to the material version of them?


Solution

  • The modifiers for AppcompatViewInflator#createView changed from final View createView to public final View createView between AppCompat versions 1.5.1 and 1.6.0. Although Android Studio complains about VectorEnabledTintResources.shouldBeUsed(), it can still be successfully called.

    A Material LayoutInflater can be built as follows for AppCompat versions 1.6.0 and later.

    private fun getMaterialInflater(context: Context, @StyleRes theme: Int): LayoutInflater {
        val inflater = ContextThemeWrapper(context, theme).run {
            getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
        }
    
        val factory = object : LayoutInflater.Factory2 {
            val myInflater = MaterialComponentsViewInflater()
    
            override fun onCreateView(
                parent: View?,
                name: String,
                context: Context,
                attrs: AttributeSet
            ): View? {
    
                return myInflater.createView(
                    parent,
                    name,
                    context,
                    attrs,
                    false, // "true" to use the parent's context
                    false, // androidTheme: Emulate the android:theme attribute for devices before L.
                    true, // appTheme: Emulate the android:theme attribute for devices before L.
                    VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
                )
            }
    
            override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
                return onCreateView(null, name, context, attrs)
            }
    
        }
        LayoutInflaterCompat.setFactory2(inflater, factory)
        return inflater
    }
    

    Here is how we can inflate a layout using our MaterialInflater.

    getMaterialInflater().inflate(R.layout.overlay_view, null)
    

    I have placed the above code into a Service and inflated the following layout as a screen overlay:

    <FrameLayout 
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <TextView
            android:id="@+id/textView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:drawableTint="?colorAccent"
            android:text="I'm a Material witness."
            android:textSize="24sp"
            app:drawableStartCompat="@drawable/baseline_wifi_24" />
    
    </FrameLayout>
    

    Here is the output:

    enter image description here

    Here is the service code I used for the demo:

    class OverlayService : Service() {
        private val mWindowManager by lazy {
            getSystemService(WINDOW_SERVICE) as WindowManager
        }
        private lateinit var mOverlayView: View
    
        override fun onCreate() {
            super.onCreate()
            startForeground(1, getNotification())
            mOverlayView = createOverlayView()
    
            // Click for graceless exit.
            mOverlayView.setOnClickListener {
                stopSelf()
            }
            addOverlayViewToWindow(mOverlayView)
        }
    
        override fun onBind(intent: Intent): IBinder? {
            return null
        }
    
        override fun onStartCommand(intent: Intent, flags: Int, startId: Int) = START_NOT_STICKY
    
        override fun onDestroy() {
            super.onDestroy()
            mWindowManager.removeView(mOverlayView)
        }
    
        private fun getMaterialInflater(context: Context, @StyleRes theme: Int): LayoutInflater {
            val inflater = ContextThemeWrapper(context, theme).run {
                getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
            }
    
            val factory = object : LayoutInflater.Factory2 {
                val myInflater = MaterialComponentsViewInflater()
    
                override fun onCreateView(
                    parent: View?,
                    name: String,
                    context: Context,
                    attrs: AttributeSet
                ): View? {
    
                    return myInflater.createView(
                        parent,
                        name,
                        context,
                        attrs,
                        false, // "true" to use the parent's context
                        false, // androidTheme: Emulate the android:theme attribute for devices before L.
                        true, // appTheme: Emulate the android:theme attribute for devices before L.
                        VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
                    )
                }
    
                override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
                    return onCreateView(null, name, context, attrs)
                }
    
            }
            LayoutInflaterCompat.setFactory2(inflater, factory)
            return inflater
        }
    
        @SuppressLint("InflateParams")
        private fun createOverlayView(): View {
            return getMaterialInflater(
                this, // The service's context
                R.style.Theme_MyMaterialTheme_Service // Theme to use for inflation
            ).inflate(R.layout.overlay_view, null)
        }
    
        private fun addOverlayViewToWindow(overlayView: View) {
            val params = LayoutParams(
                LayoutParams.MATCH_PARENT,
                LayoutParams.MATCH_PARENT,
                LayoutParams.TYPE_APPLICATION_OVERLAY,
                LayoutParams.FLAG_NOT_FOCUSABLE or
                        LayoutParams.FLAG_NOT_TOUCH_MODAL or
                        LayoutParams.FLAG_LAYOUT_IN_SCREEN,
                PixelFormat.TRANSLUCENT
            )
    
            mWindowManager.addView(overlayView, params)
        }
    
        private fun getNotification(): Notification {
            val channel = NotificationChannel(
                NOTIFICATION_CHANNEL_ID,
                CHANNEL_NAME,
                NotificationManager.IMPORTANCE_MIN
            )
            val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            manager.createNotificationChannel(channel)
            val notificationBuilder =
                NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
                    .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE)
            return notificationBuilder.setOngoing(true)
                .setContentTitle("Demo overlay app is running")
                .setContentText("Displaying over other apps")
                .setSmallIcon(R.drawable.baseline_wifi_24)
                .setPriority(NotificationManager.IMPORTANCE_MIN)
                .setCategory(Notification.CATEGORY_SERVICE)
                .build()
        }
    
        private companion object {
            const val NOTIFICATION_CHANNEL_ID = "demoapp.notouch"
            const val CHANNEL_NAME = "Foreground Service"
        }
    }