As Seen In - jetc.dev Newsletter Issue #233

As Seen In - jetc.dev Newsletter Issue #235

With the inclusion of Jetpack Compose in July 2021, the Android Team turned the approach upside down on how to build UIs. Jetpack compose is written with Kotlin, being more declarative and intuitive, with direct access to the Android platform APIs, which means that all you need to do is describe your UI. All these new things are really cool and easy to adopt when we start a new Android project, but considering an existing Android app using Android Views, we could decide to migrate the existing views to Jetpack Compose instead or gradually migrate each component to Compose. Whatever the case is, we need to re-write a bunch of code to use Compose, which in some cases could be tedious or unproductive.

With the interoperability APIs, we can make this a gradual migration, using the power of composables in the existing Android views. But not just that, we can also use the existing Android views in the Composables, which means that Compose and Views will co-exist in your app.

Let’s dive into how we can integrate composables in our existing view layout.

Compose in Views

Let’s start inspecting this existing View layout, which describes a simple login screen with two input fields and a button below.

fragment_login.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="300dp"
    android:layout_gravity="center"
    android:layout_margin="10dp"
    android:background="#EFFAF6"
    android:backgroundTint="@color/celestial_blue"
    android:padding="10dp"
    tools:context="fragments.LoginFragment">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:text="@string/login_title"
            android:textColor="@color/white"
            android:textSize="33sp"
            android:textStyle="bold"
            app:layout_constraintTop_toTopOf="parent" />

        <EditText
            android:id="@+id/email"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:autofillHints=""
            android:hint="@string/app_enter_email"
            android:inputType="textEmailAddress"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <EditText
            android:id="@+id/password"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:autofillHints=""
            android:hint="@string/app_enter_password"
            android:inputType="textPassword"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/email" />

        <Button
            android:background="@drawable/rounded_corner"
            android:backgroundTint="@color/midnight_green"
            android:layout_marginTop="40dip"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Login" />

    </LinearLayout>


</androidx.constraintlayout.widget.ConstraintLayout>

And the Fragment associated to inflate the view:

package co.wawand.fragmentcompose.fragments

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import co.wawand.fragmentcompose.R
import co.wawand.fragmentcompose.composable.AppButton

class LoginFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return inflater.inflate(R.layout.fragment_login, container, false)
    }
}

And the result:

first-result-image

Now, as part of the migration process, we are going to change just the button to illustrate the inclusion of a composable in the View Layout. First of all, remove the button widget. To host Compose content in an existing View layout, use ComposeView, which is an Android View.

fragment_login.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="300dp"
    android:layout_gravity="center"
    android:layout_margin="10dp"
    android:background="#EFFAF6"
    android:backgroundTint="@color/celestial_blue"
    android:padding="10dp"
    tools:context="fragments.LoginFragment">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:text="@string/login_title"
            android:textColor="@color/white"
            android:textSize="33sp"
            android:textStyle="bold"
            app:layout_constraintTop_toTopOf="parent" />

        <EditText
            android:id="@+id/email"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:autofillHints=""
            android:hint="@string/app_enter_email"
            android:inputType="textEmailAddress"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <EditText
            android:id="@+id/password"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:autofillHints=""
            android:hint="@string/app_enter_password"
            android:inputType="textPassword"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/email" />

        <!--To host the Compose content-->
        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/compose_view_login_button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </LinearLayout>


</androidx.constraintlayout.widget.ConstraintLayout>

In the source code (Kotlin in this case), where we were inflating the layout resource, we now need to get the ComposeView using the XML ID, set a Composition strategy, and call setContent() to use Compose.

package co.wawand.fragmentcompose.fragments

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import co.wawand.fragmentcompose.R
import co.wawand.fragmentcompose.composable.AppButton

class LoginFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val view = inflater.inflate(
            R.layout.fragment_login, container, false)

        val composeView =
            view.findViewById<ComposeView>(R.id.compose_view_login_button)

        composeView.apply {
            // Defines when the Composition should be disposed
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )

            setContent {
                MaterialTheme {
                    AppButton(R.string.login)
                }
            }
        }
        return view
    }
}

💡 Alternatively, by enabling the build feature view binding, you can also use it to obtain references to the ComposeView by referencing the generated binding class for your XML layout file.

And the Compose content associated to the new button is this:

package co.wawand.fragmentcompose.composable

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.wawand.fragmentcompose.R

@Composable
fun AppButton(@StringRes textId: Int, onClick: () -> Unit = {}) {
    Button(
        modifier = Modifier.padding(top = 50.dp),
        onClick = { onClick() },
        colors = ButtonDefaults.buttonColors(
            containerColor = colorResource(id = R.color.midnight_green),
            contentColor = Color.White
        )
    ) {
        Text(stringResource(textId))
    }
}

Obtaining as a result:

second-result-image

Now, we can evidence a good approach to start including Compose content in existing Android Views. A progressive way, allowing the introduction of little pieces of UI, adjusting and customizing according to the requirements. Considering a full migration process, this is a good starting point, avoiding breaking changes and making it easy for testing and integrating.

Now, let’s turn around the initial idea. Imagine that we need to include an Android View hierarchy in a Compose UI, and you’re probably asking why. This grasp is useful if you want to use UI elements that are not yet available in Compose, like AdView. This approach also lets you reuse custom views you may have designed before.

Views in Compose

To include a view element, we are going to use AndroidView with view binding. To embed an XML layout, we use the AndroidViewBinding API provided by androidx.compose.ui:ui-viewbinding library, also don’t forget to enable the view binding option in the build features.

Let’s explore the view layout:

info_section.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="300dp"
    android:layout_gravity="center"
    android:layout_margin="10dp"
    android:background="#EFFAF6"
    android:backgroundTint="@color/celestial_blue"
    android:padding="10dp"
    tools:context="fragments.InfoFragment">

    <TextView
        android:id="@+id/text_info"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fontFamily="monospace"
        android:gravity="center"
        android:text=""
        android:textColor="@color/midnight_green"
        android:textSize="20sp"
        android:textStyle="italic" />

</androidx.constraintlayout.widget.ConstraintLayout>

Now, let’s invoke the view layout via AndroidViewBinding function from the Compose function and set some properties:

package co.wawand.fragmentcompose.composable

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.viewinterop.AndroidViewBinding
import co.wawand.fragmentcompose.R
import co.wawand.fragmentcompose.databinding.InfoSectionBinding

@Composable
fun InfoSection() {
    AndroidViewBinding(InfoSectionBinding::inflate) {
        textInfo.text = "Hello Compose from a Fragment"
        textInfo.textSize = 40f
        textInfo.setTextColor(root.context.getColor(R.color.midnight_green))
    }
}

And let’s check the result:

third-result-image

Conclusion

The ability to mix Jetpack Compose with traditional Android Views offers developers a powerful and flexible approach to UI development and migration. This interoperability provides several key advantages:

  1. Gradual Migration: Developers can incrementally adopt Compose in existing projects without the need for a complete rewrite, reducing risk and allowing for a smoother transition.
  2. Best of Both Worlds: Teams can leverage the strengths of both Compose and Views, using each where it makes the most sense in their application.
  3. Reusability: Existing View-based components can be easily integrated into new Compose-based UIs, preserving investments in custom Views and layouts.
  4. Flexibility: Developers can use Views for UI elements not yet available in Compose or for which they have existing, optimized implementations.

As the Android ecosystem continues to evolve, this bridging of Compose and Views empowers developers to embrace modern UI development paradigms while maintaining compatibility with existing codebases. Whether you’re starting a new project or modernizing an existing app, the interoperability between Compose and Views provides a pathway to build more maintainable, expressive, and efficient user interfaces.

References