Skip to content

Jetpack Compose: Converting Traditional Android Views into Reusable Composables

January 18, 2024

Skyler De Francesca

Are you building a modern Native Android App with Jetpack Compose but struggling to integrate existing views, plugins, or features built with traditional Android Views? In this blog post, we'll show you a solution to this problem by demonstrating how to convert your existing views into reusable composable functions (AKA composables), making it easier to integrate them seamlessly into your Jetpack Compose app.

Note: Throughout this blog post, we'll utilize the Android "TextView" as a demonstration of integrating an Android View into a Jetpack Compose-based project. Though TextView is a basic component, the methods and techniques outlined can be applied to more complex views as well.

The problem

Let’s say we want to use Android’s TextView widget in our Compose-based project. We plan on using the TextView in numerous places. Since it’s not a composable, it would be nice to convert it to one and use that composable throughout our project instead. This allows us to use a simple Compose API instead of having to interface with the Android Views library every time we use the view, which would reduce a lot of boilerplate and duplicate code. Let’s build out this composable!

Building the composable

The first thing we’ll need to do is figure out how to convert a traditional Android View into a composable. Luckily this is quite simple with the use of the “AndroidView” composable from Android’s interoperability API.

/**
 * Example 1
 */
@Composable
fun TextViewComposable() {
    AndroidView(
        factory = { context ->
            TextView(context)
        }
    )
}

This is a very simple implementation that shows the basic usage of AndroidView. The factory block expects a View (android.view.View) to be returned, so here we’re simply returning an empty TextView. But an empty TextView is pretty useless. How can we make it display some text? (Let’s also add some color to the text too).

/**
 * Example 2
 */
@Composable
fun TextViewComposable(text: String, @ColorInt color: Int) {
    AndroidView(
        factory = { context ->
            val textView = TextView(context)
            textView.text = text
            textView.setTextColor(color)
            textView
        }
    )
}

Here, the TextView will display the text and color provided by the parameters of the composable. We hold a reference of the TextView in the factory block so that we can access its attributes and methods needed to alter the view before returning it. Pretty simple!

But wait… What if the text or color is changed in the parent composable? The example above will only show the text and color that were initially used when creating the composable, so when the values are changed in the parent the view will remain the same. How can we fix this?

/**
 * Example 3
 */
@Composable
fun TextViewComposable(text: String, @ColorInt color: Int) {
    val currentText by rememberUpdatedState(newValue = text)
    val currentColor by rememberUpdatedState(newValue = color)

    AndroidView(
        factory = { context ->
            val textView = TextView(context)
            textView.textSize = 30f
            textView
        },
        update = { textView ->
            textView.text = currentText
            textView.setTextColor(currentColor)
        }
    )
}

Two things have been done here to allow for the TextView to be updated every time the inputs (text and color) are changed:

  1. Two new variables “currentText” and “currentColor have been introduced and are used to give this composable a state. The function “rememberUpdatedState()” is used to update these variables whenever their related parameter changes. “rememberUpdatedState()” also makes use of Compose’s “remember” API which allows for these variables’ values to persist through future recompositions, i.e., it gives the composable a state.
  2. The code to set/update the TextView attributes has been moved to the "update" callback so that it is called during every recomposition. This way, when the inputs (text and color) are changed, the TextView will be updated accordingly. We can leave certain things in the factory block that we only want called initially (e.g., we have left the text size being set there if that’s something we want to remain the same).

We now have a simple composable to use in our project whenever we want to use Android’s TextView. As more features from TextView are needed, the composable can be changed to accommodate them (e.g., allowing the textSize to be customized/altered).

 

Adding some complexity

As you can imagine, not all traditional views you’d want converted into a composable will be this simple. All we’ve done so far is provide the ability to set values in the TextView and have them change when the related composable parameter also changes. What if the view you are trying to implement has events that you want to listen to and expose? What if the view has functions that you want to call? Surely, we’d need a way to listen to these events and call these functions from the parent composable, but how can it be done? We’ll pretend the TextView has the following features that we want to be able to control from a parent of TextViewComposable:

Abracadabra

Let's say the TextView has an event called "abracadabra" which changes the TextView's text to a random word whenever it is clicked on. Additionally, there’s a callback available at "TextView.abracadabraListener" that is called every time the abracadabra event is initiated, passing the new text value.

Rotate functionality

There’ll also be the ability to rotate the text using the following functions:

  • TextView.rotateX(): Rotates text on the X axis
  • TextView.rotateY(): Rotates text on Y axis
  • TextView.reset(): Resets rotated text to the original state

How can we enable the TextViewComposable to expose the TextView “abracadabra” event? Fortunately, it's quite simple:

Firstly, we need to create a parameter in “TextViewComposable” that will be used as a callback for when the event is made. This will allow us to transmit the event and value to the parent composable.

@Composable
fun TextViewComposable(
    ...
    onAbracadabra: (String) -> Unit = {}
) {

Next, we must listen for the abracadabra event and call the “onAbracadabra” callback within the listener.

…
factory = { context ->
    val textView = TextView(context)
    textView.abracadabraListener = { newText -> onAbracadabra(newText.toString()) }
    textView
},
…

To trigger the rotate functionality, it’s not as straightforward. One way we could do this is by creating a class that contains functions that represent the controls we’d like to expose. This class will also contain a list of listeners that can be added and removed from an instance of it. The idea here is that the “control” functions (rotateX(), rotateY(), reset()) will call the listeners’ associated function. Therefore, we can initialize the controller in the parent composable and call the control functions from there. The TextView will take this instance of the controller as a parameter and attach a listener to it to translate each controller function call to the associated TextView function call.

The controller class would look like this:

class RemoteTextViewController {
    interface RemoteTextViewControllerListener {
        fun onRotateX()
        fun onRotateY()
        fun onReset()
    }

    private val listeners: MutableMap<String, RemoteTextViewControllerListener> = mutableMapOf()

    fun rotateX() {
        listeners.values.forEach { it.onRotateX() }
    }

    fun rotateY() {
        listeners.values.forEach { it.onRotateY() }
    }

    fun reset() {
        listeners.values.forEach { it.onReset() }
    }

    fun addListener(key: String, value: RemoteTextViewControllerListener) {
        if (!listeners.containsKey(key)) {
            listeners[key] = value
        }
    }

    fun removeListener(key: String) {
        listeners.remove(key)
    }
}

Next, in the “TextViewComposable” we add the controller as a parameter so that the parent composable can provide it. We also want to maintain its state by using the “rememberUpdatedState()” function.

@Composable
fun TextViewComposable(
    ...
    remoteController: RemoteTextViewController
) {
    val currentRemoteController by rememberUpdatedState(newValue = remoteController)
		
 ...
}

Lastly, we’ll create a listener in the AndroidView factory block and add it to the controller. The listener will call the associated TextView functions.

factory = { context ->
    val textView = TextView(context)
    currentRemoteController.addListener(
        "TextViewWrapper",
        object : RemoteTextViewController.RemoteTextViewControllerListener {
            override fun onRotateX() {
                textView.rotateX()
            }

            override fun onRotateY() {
                textView.rotateY()
            }

            override fun onReset() {
                textView.reset()
            }

        })
    textView
},

The full composable with both the “Abracadabra” and “Rotate functionality” features (and a few new customizable attributes) will look like this:

/**
 * Example 4
 */
@Composable
fun TextViewComposable(
    modifier: Modifier = Modifier,
    text: String,
    @ColorInt color: Int = Color.parseColor("#FF0000"),
    textSize: Float = 30F,
    textShadowRadius: Float = 0F,
    textShadowDx: Float = 0F,
    textShadowDy: Float = 0F,
    @ColorInt textShadowColor: Int = Color.parseColor("#0000FF"),
    remoteController: RemoteTextViewController,
    onAbracadabra: (String) -> Unit = {}
) {
    val currentText by rememberUpdatedState(newValue = text)
    val currentColor by rememberUpdatedState(newValue = color)
    val currentTextSize by rememberUpdatedState(newValue = textSize)
    val currentTextShadowRadius by rememberUpdatedState(newValue = textShadowRadius)
    val currentTextShadowDx by rememberUpdatedState(newValue = textShadowDx)
    val currentTextShadowDy by rememberUpdatedState(newValue = textShadowDy)
    val currentTextShadowColor by rememberUpdatedState(newValue = textShadowColor)
    val currentRemoteController by rememberUpdatedState(newValue = remoteController)

    AndroidView(
        modifier = modifier,
        factory = { context ->
            val textView = TextView(context)
            textView.abracadabraListener = { newText -> onAbracadabra(newText.toString()) }
            currentRemoteController.addListener(
                "TextViewWrapper",
                object : RemoteTextViewController.RemoteTextViewControllerListener {
                    override fun onRotateX() {
                        textView.rotateX()
                    }

                    override fun onRotateY() {
                        textView.rotateY()
                    }

                    override fun onReset() {
                        textView.reset()
                    }
                })
            textView
        },
        update = { textView ->
            textView.text = currentText
            textView.textSize = currentTextSize
            textView.setShadowLayer(
                currentTextShadowRadius,
                currentTextShadowDx,
                currentTextShadowDy,
                currentTextShadowColor
            )
            textView.setTextColor(currentColor)
        }
    )

So now, we have a decent composable built-out that allows us full control over the TextView! But there’s still something we can do to improve the code.

Making it maintainable and testable

As we add more functionality and features to our composable, or if we’re converting a much more complex view, the approach above may not be entirely feasible. It can lead to our composable being hundreds of lines long which would not make it very maintainable or easy to test.

One thing that can be done is to delegate the responsibility of updating the TextView to another class and call that class’s functions from the composable when we want to update the view. This way, we can break out the functionality that was once in the composable body into smaller functions that are easier to maintain and test.

Here’s an example of a class that can be used this way:

class TextViewUpdater(
    val textView: TextView,
    remoteTextViewController: RemoteTextViewController? = null,
    var abracadabraListener: (String) -> Unit = {}
) {

    init {
        textView.abracadabraListener = { abracadabraListener(it.toString()) }
        remoteTextViewController?.addListener("TextViewController", object :
            RemoteTextViewController.RemoteTextViewControllerListener {
            override fun onRotateX() {
                rotateX()
            }

            override fun onRotateY() {
                rotateY()
            }

            override fun onReset() {
                reset()
            }
        })
    }

    fun update(
        text: String,
        textSize: Float,
        color: Int,
        textShadowRadius: Float,
        textShadowDx: Float,
        textShadowDy: Float,
        textShadowColor: Int
    ) {
        textView.text = text
        textView.textSize = textSize
        textView.setShadowLayer(
            textShadowRadius,
            textShadowDx,
            textShadowDy,
            textShadowColor
        )
        textView.setTextColor(color)
    }

    private fun rotateX() {
        textView.rotateX()
    }

    private fun rotateY() {
        textView.rotateY()
    }

    private fun reset() {
        textView.reset()
    }
}

The class above is still quite simple, but it allows us to separate out the functionalities for controlling the TextView into separate functions. For a more complex view with more business logic required for certain operations, this can be important since this added modularity allows for easier testing.

The class above can be used in the TextViewComposable:

/**
 * Example 5
 */
@Composable
fun TextViewComposable(
    modifier: Modifier = Modifier,
    text: String,
    @ColorInt color: Int = Color.parseColor("#FF0000"),
    textSize: Float = 30F,
    textShadowRadius: Float = 0F,
    textShadowDx: Float = 0F,
    textShadowDy: Float = 0F,
    @ColorInt textShadowColor: Int = Color.parseColor("#0000FF"),
    remoteController: RemoteTextViewController,
    onAbracadabra: (String) -> Unit = {}
) {
    val context = LocalContext.current

    val textViewUpdater by remember {
        mutableStateOf(TextViewUpdater(TextView(context), remoteController, onAbracadabra))
    }

    val currentText by rememberUpdatedState(newValue = text)
    val currentColor by rememberUpdatedState(newValue = color)
    val currentTextSize by rememberUpdatedState(newValue = textSize)
    val currentTextShadowRadius by rememberUpdatedState(newValue = textShadowRadius)
    val currentTextShadowDx by rememberUpdatedState(newValue = textShadowDx)
    val currentTextShadowDy by rememberUpdatedState(newValue = textShadowDy)
    val currentTextShadowColor by rememberUpdatedState(newValue = textShadowColor)

    AndroidView(
        modifier = modifier,
        factory = {
            textViewUpdater.textView
        },
        update = {
            textViewUpdater.update(
                currentText,
                currentTextSize,
                currentColor,
                currentTextShadowRadius,
                currentTextShadowDx,
                currentTextShadowDy,
                currentTextShadowColor
            )
        }
    )
}

Since we’ve delegated the responsibility of updating the TextView to the TextViewUpdater class, the composable now just calls the functions within that class. Notice how we’ve reduced the length and complexity of the composable, making it more maintainable and readable.

conclusion 

We've demonstrated a method for converting traditional Android views into reusable composables using Jetpack Compose. While there may be other ways to accomplish this, we hope that this serves as a useful starting point for your own projects. If you’d like to test this out yourself, a complete Android project including the example code used in this blog post is available on GitHub at the following link: https://github.com/skyler-de/AndroidViewsInComposeExample

GOT A PROJECT?