Into the Compose-Verse, A beginner guide to Android Development With Jetpack Compose: The Very Basics

ยท

10 min read

Into the Compose-Verse, A beginner guide to Android Development With Jetpack Compose: The Very Basics

Photo by SpaceX on Unsplash

Part 1: The very basics.

What was

Android development has been largely characterized by the view-based system of building android User interfaces. This approach involved defining UI elements and layouts as views in XML files, then rendering these views by inflating them, thus granting the user the ability to interact with them. The created views usually hold some state and views change the state depending on the user's interaction.

The above system seems like a good way to build applications, why then did it have to change, well the view-based approach has a couple of problems today:

  1. It's very old. The Android view system was created over 10 years ago, and as a result, its APIs have started to show signs of ageing, the programming paradigms which largely influenced the view system are less prominent today. One prominent example that shows the ageing of the view system, is the fact that the View class which represents the basic building block for user interface components is over 30,000 lines long. The android toolkit team has even stated that the amount of code in this class had become unmanageable and as such needed to change.

  2. User expectations for User Interfaces are growing. The various demands users have of user interfaces have largely grown since the time the android view system was introduced. Users prefer UI with animations, motions and these things were not particularly important when the view system was being developed originally.

  3. Complexity. The view system involved writing the User Interface in XML, and the application logic in Java or Kotlin. This inevitably led to unintended complexity in managing the application. In addition to this, due to the nature of XML, we had to write longer lines of code to accomplish little things for example something as simple as changing the shape of a FAB or applying a gradient to a button would involve creating a drawable XML file and manually defining the gradient before applying it to our button.

  4. No one likes XML ( I don't ๐Ÿ˜‰)

What is

To solve the issues, the Android UI team introduced Jetpack Compose. Jetpack compose is Android's modern toolkit for building native UI. It simplifies and accelerates UI development on android and allows us to quickly bring our app to life with less code and intuitive Kotlin APIs.

Jetpack Compose is written entirely with Kotlin and offers us faster, simpler development in a single language, with fewer lines of code. It also comes with support with android studio and other jetpack items. Thus, our development speed is greatly increased. Jetpack Compose offers a declarative UI approach

What problems does compose solve?

  1. Separation of Concerns: When using the view system, we usually have a tight coupling of various elements such as the application logic and the layout files. This can lead to unnecessary complexity in our code. When using jetpack compose both our layout and application logic is defined in the same language, the dependencies between our elements become more obvious as a result we can easily refactor and reduce the coupling of our elements together. Jetpack compose provides us with tools that allow us to better perform separation of concerns.

  2. Brings the power of the kotlin language to our UI elements. Since Jetpack Compose is purely kotlin, it allows us to use language-level primitives that kotlin provides to do things dynamically eg using if-else statements to render parts of the UI, or easily executing complicated UI logic. While this was possible using the View system, it was inherently more complicated, as it involved firstly defining all the UI elements in the XML file then finding those elements via Id or view binding in an activity or fragment then performing our control flow logic there. In jetpack compose, we can do all of this in a single file and with less code.

  3. Favors Composition over Inheritance. The view system was built upon inheritance, all UI elements inherit from the view class, and we could extend inbuilt views to create our custom elements, but this gives rise to all the issues associated with inheritance. In Compose, there is no single parent that we have to inherit from, all of our Ui elements are made up of other UI elements which we can switch or modify as we like.

Views vs Compose

Now that some of the theory is out of the way, let's look at a practical example of building a UI with the view system versus Jetpack Compose. We will be recreating the UI below with both the view system and Jetpack compose and then we can compare which one is more ideal, so let's get ready to rumble . . .

Screenshot (7).png

View System

To build the above UI using the view system, we will employ the following,

  • Recycler View
  • A custom View Adapter
  • Google material card view
  • Google Material Button

Step 1: Layouts

In our main activity XML, we add our recycler view and the necessary constraints

<?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="match_parent" 
    tools:context=".MainActivity"> 
    <androidx.recyclerview.widget.RecyclerView 
        android:id="@+id/main_recycler_view" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        app:layout_constraintBottom_toBottomOf="parent" 
        app:layout_constraintLeft_toLeftOf="parent" 
        app:layout_constraintRight_toRightOf="parent" 
        app:layout_constraintTop_toTopOf="parent" /> 
</androidx.constraintlayout.widget.ConstraintLayout>

Next, create a layout for each card that will be on our list

<?xml version="1.0" encoding="utf-8"?> 
<androidx.cardview.widget.CardView 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:id="@+id/meal_card" 
    android:layout_width="match_parent" 
    android:layout_height="wrap_content" 
    app:cardCornerRadius="10dp" 
    app:cardElevation="5dp" 
    app:cardUseCompatPadding="true"> 
    <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        android:orientation="vertical"> 
        <ImageView 
            android:id="@+id/meal_image" 
            android:layout_width="match_parent" 
            android:layout_height="150dp" 
            android:scaleType="centerCrop" 
            tools:src="@drawable/pizza" /> 
        <TextView 
            android:id="@+id/meal_name" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:paddingStart="10dp" 
            android:text="Hello world" 
            android:textSize="20sp" 
            android:textStyle="bold" /> 
        <LinearLayout 
            android:layout_width="match_parent" 
            android:layout_height="wrap_content" 
            android:gravity="bottom" 
            android:orientation="horizontal" 
            android:padding="10dp"> 
            <TextView 
                android:id="@+id/meal_time" 
                android:layout_width="wrap_content" 
                android:layout_height="wrap_content" 
                android:text="5mins" 
                android:textSize="20sp" /> 
            <View 
                android:layout_width="10dp" 
                android:layout_height="match_parent" /> 
            <TextView 
                android:id="@+id/meal_ingredients" 
                android:layout_width="wrap_content" 
                android:layout_height="wrap_content" 
                android:text="4 ingredients" 
                android:textSize="20sp" /> 
            <View 
                android:layout_width="15dp" 
                android:layout_height="match_parent" /> 
            <com.google.android.material.button.MaterialButton 
                style="@style/Widget.MaterialComponents.Button.OutlinedButton" 
                android:layout_width="wrap_content" 
                android:layout_height="wrap_content" 
                android:background="@android:color/white" 
                android:drawableLeft="@drawable/ic_baseline_play_arrow" 
                android:drawableTint="@color/green" 
                android:text="cook" 
                android:textColor="@color/green" 
                android:textSize="15sp" 
                app:strokeColor="@color/green" 
                app:strokeWidth="20dp" /> 
        </LinearLayout> 
    </LinearLayout> 
</androidx.cardview.widget.CardView>

Step 2: Adapters

Create a custom adapter that will be used for our recycler view

class MealAdapter() : RecyclerView.Adapter<MealAdapter.ViewHolder>() { 
    private var meals = emptyList<Meal>() 
    class ViewHolder(binding: MealCardBinding) : RecyclerView.ViewHolder(binding.root) { 
        val image = binding.mealImage 
        val name = binding.mealName 
        val time = binding.mealTime 
        val noIngredients = binding.mealIngredients 
    } 
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 
        val binding: MealCardBinding = MealCardBinding.inflate( 
            LayoutInflater.from(parent.context), 
            parent, 
            false 
        ) 
        return ViewHolder(binding) 
    } 
    @SuppressLint("SetTextI18n") 
    override fun onBindViewHolder(holder: ViewHolder, position: Int) { 
        val meal = meals[position] 
        holder.image.setImageResource(meal.image) 
        holder.name.text = meal.name 
        holder.time.text = "${meal.time} minutes" 
        holder.noIngredients.text = "${meal.ingredients} ingredients" 
    } 
    override fun getItemCount(): Int { 
        return meals.size 
    } 
    @SuppressLint("NotifyDataSetChanged") 
    fun updateMeals(meals: List<Meal>) { 
        Log.i("test", meals.toString()) 
        this.meals = meals 
        notifyDataSetChanged() 
    } 
}

Step 3: View Models and State

We need a view model which will hold the data that will be displayed in our UI as well as a state class that for convenience, we will preload with some meal objects

The Meal model

data class Meal (val name: String, val time: Int, val ingredients: Int, val image: Int)

The Meal state

data class MealState(
    val meals: List<Meal> = listOf(
        Meal(
            name = "Cooked Coconut Mussels",
            time = 5,
            ingredients = 4,
            image = R.drawable.cookedcoconutmussels
        ),
        Meal(
            name = "Banana and Mandarin Buns",
            time = 45,
            ingredients = 6,
            image = R.drawable.bananaandmandarinbuns
        ),
        Meal(name = "Strawberry Meal", time = 59, ingredients = 3, image = R.drawable.strawberries),
        Meal(
            name = "Pizza",
            time = 50,
            ingredients = 7,
            image = R.drawable.pizza
        )

    )
)

The Meal View model

class MealViewModel : ViewModel() { 
    private val _state = MutableLiveData(MealState()) 
    val state: LiveData<MealState> 
        get() = _state 
}

Step 4: Putting it all together

In our main activity class, we set up our recycler view with our adapter, and observe our note state

class MainActivity : AppCompatActivity() { 
    private lateinit var binding: ActivityMainBinding 
    private val mealViewModel: MealViewModel by viewModels() 
    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        binding = ActivityMainBinding.inflate(layoutInflater) 
        setContentView(binding.root) 
        val adapter: MealAdapter = MealAdapter() 
        binding.mainRecyclerView.adapter = adapter 
        binding.mainRecyclerView.layoutManager = LinearLayoutManager(this) 
        mealViewModel.state.observe(this, Observer { mealState -> 
            adapter.updateMeals(mealState.meals) 
        }) 
    } 
}

The resulting UI looks like this

Screenshot_1643308165.png

Screenshot_1643308176.png

Jetpack Compose

To create a project with jetpack compose, open up Android Studio and select a new project and from the templates provided, select empty compose activity. This will generate a compose project for us with all the necessary dependencies setup.

Jetpack compose allows us to create our User interface with Composables. These are simple functions that are annotated with @Composable.

Once we have a composable function we can style or modify its behaviour with Modifiers. Modifiers are parameters we pass into the composable function which help us change how a composable is presented. We can use modifiers to do the following:

  1. Change the composable's behaviour and appearance
  2. Add information like accessibility labels
  3. Add high-level interactions like making an element clickable, scrollable, draggable or zoomable. They are quite similar to layout parameters in view based layouts.

To build our UI in jetpack compose, first add the following dependencies:

  1. Jetpack compose View model dependencies, to manage our UI data
  2. Material Icons
  3. Glide to handle images easily

Step 1: Add dependencies

In the project, the Gradle file add the following to the build script

    ext { 
        compose_version = '1.0.1' 
    }

Next in the application Gradle file

  //compose dependencies 
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "com.github.skydoves:landscapist-glide:1.4.5"

Step 2: View models and State

data class Meal( 
    val name: String, 
    val image: Int, 
    val ingredients: Int, 
    val time: Int 
)

data class MealState( 
    val recipes: List<Meal> = listOf( 
        Meal( 
            "Banana and Mandarin buns", 
            R.drawable.bananaandmandarinbuns, 
            5, 
            20 
        ), 
        Meal( 
            "Cooked Coconut Mussels", 
            R.drawable.cookedcoconutmussels, 
            7, 
            11 
        ), 
        Meal( 
            "Fancy", 
            R.drawable.feedimage2, 
            9, 
            11, 
        ), 
        Meal( 
            "Italian", 
            R.drawable.pizza, 
            4, 
            20 
        ), 
    ), 
)

class MealViewModel : ViewModel(){ 
    private val _state = mutableStateOf(MealState()) 
    val state = _state 
}

Note: In the view model, we are using mutable state instead of Live-data, this is because jetpack compose gives us a new state API which is similar to the live data API, however, we do not need to observe this state directly, Jetpack compose behind the scenes, subscribe any composable that reads from that state to any write operations emanating from that state. Thus, any composable that reads from the state object will be notified and redrawn with new data when modified.

Step 3: Create The Meal Card Composable

@Composable 
fun MealCard( 
    meal: Meal, 
) { 
    Card(modifier = Modifier.height(250.dp)) {

        Column(modifier = Modifier.fillMaxSize()) { 
            Box( 
                modifier = Modifier 
                    .fillMaxHeight(fraction = 0.5f) 
                    .fillMaxWidth() 
            ) { 
                GlideImage( 
                    imageModel = meal.image, 
                    contentDescription = meal.name, 
                    modifier = Modifier 
                        .matchParentSize() 
                        .clip( 
                            RoundedCornerShape( 
                                topStart = 5.dp, 
                                topEnd = 5.dp 
                            ) 
                        ), 
                    contentScale = ContentScale.Crop 
                ) 
            } 
            Spacer(modifier = Modifier.width(10.dp)) 
            Column( 
                modifier = Modifier 
                    .fillMaxWidth() 
                    .padding(10.dp), 
                ) { 
                Text( 
                    text = meal.name, 
                    fontWeight = FontWeight(600), 
                    fontSize = 18.sp 
                ) 
                Spacer(modifier = Modifier.height(10.dp)) 
                Row( 
                    modifier = Modifier.fillMaxWidth(), 
                    horizontalArrangement = Arrangement.SpaceBetween, 
                    verticalAlignment = Alignment.CenterVertically 
                ) { 
                    Row( 
                    ) { 
                        Text(text = "${meal.time} minutes", color = Color.Black) 
                        Spacer(modifier = Modifier.width(10.dp)) 
                        Text(text = "${meal.ingredients} Ingredients", color = Color.Black) 
                    } 
                    OutlinedButton( 
                        onClick = {}, 
                        border = BorderStroke(1.dp, Color.Green) 
                    ) { 
                        Row(verticalAlignment = Alignment.CenterVertically) { 
                            Icon( 
                                imageVector = Icons.Outlined.PlayArrow, 
                                contentDescription = null, 
                                tint = Color.Green 
                            ) 
                            Text(text = "Cook", color = Color.Green) 
                        } 
                    } 
                } 
            } 
        } 
    } 
}

In the Meal Card above, we use the inbuilt composables provided to build up our meal card composable, we also employ modifiers extensively to shape and style our custom composable to our desires. Some of the composables and modifiers we used above include

  1. Card: This is an inbuilt composable that comes with some default elevation and corner radius.
  2. Column: This arranges its content in a vertical column
  3. GlideImage: This the image composable from the glide library and it helps us display and style images
  4. Spacer: This simply creates some space between elements
  5. Row: This arranges its content in a horizontal row

Now to the best part, with Jetpack Compose you do not need to worry about creating recycler views or adapters whenever you need to render dynamically sized lists, you can simply use the lazy column or lazy row composables as shown below

@Composable 
fun App( 
    mealViewModel: MealViewModel = viewModel(), 
) { 
    val state = mealViewModel.state.value 
    LazyColumn(modifier = Modifier 
        .fillMaxSize() 
        .padding(20.dp)) { 
        items(state.recipes) { item: Meal -> 
            MealCard(meal = item) 
            Spacer(modifier = Modifier.height(16.dp)) 
        } 
    } 
}

Step 4: Put it all together in the main activity

class MainActivity : ComponentActivity() { 
    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        setContent { 
            JetpackComposeExampleTheme { 
                App() 
            } 
        } 
    } 
}

The resulting UI

Screenshot_1643311501.png

Screenshot_1643311507.png

Verdict

As we can see Jetpack compose offers us a way to build declarative UIs with a single language. It allows us to leverage the power of Kotlin to create complex UIs with fewer lines of code and frees us to focus on other aspects of our application. The composable annotation, lets us build out our UI with distinct functions which we can swap out and manipulate as we please, and the state API provided with Jetpack compose provides us with an easier and more intuitive way of managing state within our applications.

The Code samples used in this article be found here: Source Code

ย