MAD Topic 3 : Further Jetpack Compose, including Material Design and Theming

We will look at:

Further introductory Jetpack Compose topics

Units of Measurement

During the lab session last week we looked at how to specify font size, e.g.:

Text("Hello World!", fontSize=24.sp)
What does the font size 24.sp mean? It's not just a number but a number with .sp appended. This is another example of an extension function on Int, as we saw last time, but what does .sp mean?

Before discussing sp, we will discuss the similar dp unit. What is the dp unit?

What then are sp? These are scalable pixels, which are basically the same as density-independent pixels, but they adapt to the user's chosen font size as well as the pixel density and thus should be used for specifying font size.

Modifiers

We can modify the appearance of UI elements with modifiers which allow you to control such things as padding, borders, etc. Modifiers are optional in some cases but compulsory in others. Modifiers are compulsory in the case of the Spacer, which is an element used to provide space between other elements. Here is the previous example with a Spacer:

package com.example.jetpackcompose3 

import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import android.os.Bundle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        setContent {
            TwoTexts()
        }
    }

    @Composable
    fun TwoTexts() {
        Column {
            Text("Hello World!", color = Color.red, fontStyle=FontStyle.Italic, fontSize=24.sp, fontFamily=FontFamily.Serif)
            Spacer(Modifier.height(32.dp))
            Text("Welcome to Android Development", fontWeight=FontWeight.Bold, fontSize=18.sp)
        }
    }
}
This creates a spacer with a height of 32 density independent pixels (see above).

Other modifiers allow us to specify padding (the space between the border of a UI element and its content) or an element's border. For example, this surrounds a GreetingComponent component with a 2 dp wide blue border and with padding of 16dp between the border and the content. Note how Modifier contains many methods to modify different aspects of the element, and note how they can all be chained together.

GreetingComponent(
    Modifier.border(BorderStroke(2.dp, Color.Blue))
            .padding(16.dp)
)
Our GreetingComponent would now need to take a modifier as a parameter. This would then be passed onto the Column which sets the layout within the greeting component.
@Composable
fun GreetingComponent(mod: Modifier) {
    var name by remember { mutableStateOf("") }
    Column(mod) {
        ...
    }
}


Material Design

Material Design (see the website) is a published design philosophy which is adopted as the recommended standard in Android development. It revolves around the idea of screen surfaces being composed of material which has certain visual and behavioural properties, for example the ability to cast shadows when raised, and only UI elements at the top of a stack of elements being able to receive events. Material Design specifies a range of standard UI components, such as buttons, text fields and many others. Following the Material Design philosophy allows the development of clean-looking, usable and intuitive apps.

Material Design has a range of key aspects, with three particularly important components including:

Through colour, typography and shape, Material Design helps you design appealing and consistent UIs. How can it do this?

When designing a UI, you need to think about classifying your UI elements in terms of which should be particularly prominent to the user and which can be less prominent because they are less critical to the functionality of the app. This helps you break down the UI into different classes of component. The idea is that you style components of the same importance class similarly.

So for example prominent UI controls such as buttons should all have the same style (same colour and/or typography). Rather than using a random mix of styles in your app, with multiple different colours and fonts, you define colours and fonts for each class of UI element and apply them appropriately. So for colour for example, you create a palette of colours for your app and apply colours from this palette appropriately for different classes of UI element.

So in summary, by setting the properties of these colour, typography and shape classes you can ensure a consistent look and feel across similar elements, enhancing usability and aesthetics of the app.

More detail on colour roles

Material Design features a number of so-called colour roles. These are described here. Colour roles include, amongst others:

So to design the colour scheme for a Material Design app, you should pick appropriate colours for these colour roles, and ensure you pick the correct colour role for a given component.

More detail on typography

Just as for colour roles, Material Design offers a range of typography classifications. See here and here for more detail. The descriptions below are paraphrased from the second of these two articles.

Within each category, you can specify large, medium or small variants. The concept is again like colour. By defining certain fonts for each typography type, you are ensuring a consistent appearance across UI elements of the same type or class.

Using Material Design in Jetpack Compose

Having considered some of the absolute basics of Material Design, we will now see how we can use Material Design in a Jetpack Compose app.

Setting up Material Design in Jetpack Compose

Jetpack Compose integrates very well with Material Design and in fact supports the latest Material Design standard, version 3. Jetpack Compose allows you to define a theme, which is a collection of colours and typographies which will be applied to your Compose app. Android Studio pre-generates logic to create your theme for you.

Below shows the location of the pre-generated theme files for your project. They are within the ui.theme subpackage:
Compose theme source files

You can edit the theme files to set the theme for your app, in other words you can customise the different colour types (primary, secondary, tertiary, on-primary, etc) and typography. Color.kt contains colour definitions, Type.kt contains typography definitions and Theme.kt manages the theme as a whole.

Using a Theme

The Theme.kt file in the theme package provides a function you can use to apply your theme to your UI. This will be named <project name> plus the string Theme. For example if your project is called ComposeExample2, the theme function will be ComposeExample2Theme. Because this function takes a composable function (usually a lambda) as its argument, you can simply add composables inside it and apply it as follows:

setContent {
    ComposeExample2Theme {
        Text("Some text")
        TextField(...)
    }
}
All composables inside the theme will have the theme applied to them.

Setting theme colours and fonts

Material Design components will use default settings appropriate to that component. You can also, however. access the theme's colour and typography via the MaterialTheme class. This has a colorScheme property to access the colour roles, and a typography property to access the various font styles. For example:

Using a Surface

The most fundamental UI component of Material Design is the surface. The Surface is the UI element which your Material Design theme is applied to, so you should wrap all your other components in a Surface. Surfaces can be styled, for example you can set the shape to specify the extent of curvature at the corners. You can have a surface on top of another surface, and each surface can be styled differently.

To use a Surface, simply wrap it round the other UI elements, e.g.

import androidx.compose.material3.Surface
import your.package.ui.theme.ComposeExample2Theme // replace "your.package" and the project name!

setContent {
    ComposeExample2Theme {
        Surface(modifier=Modifier.fillMaxSize(),shape=MaterialTheme.Shapes.large, shadowElevation=1.dp) {
            Text("Some text")
            TextField(...)
       }
    }
}
This example shows:

Exercise 1

  1. Modify your exercise last week (the feet to metres converter) so that the composable has a 4dp wide red border with a padding of 20dp.
  2. Create a new project and copy the code of your main activity into it. Apply your application's pre-generated theme to it. Add a Surface and place your feet-to-metres converter composable within the surface.
  3. Add a button to your feet-to-metres converter which resets the feet to 0. This will be similar to the button in your greeting component.
  4. A bit harder! Try making the TextField occupy the whole width of the device.
  5. Now try putting the TextField and Button (to clear the value) on the same line. The button should have 8dp padding. Hint: use a Row to contain both elements. It should look something like this:
    Messaging example
  6. Enhance the previous question so that the TextField and Button have a width proportion of 2:1, so that the text field has twice the width of the button. Hint: you can use Modifier.weight() to do this. Give the text field a weight of 2.0f (i.e. a Float) and the button a weight of 1.0f.

Implementing Lists of Data

You may be able to figure out how to create a list of data. Think about how you would create a Compose application which implements a shopping list. It should contain a TextField allowing the user to enter an item, a Button which when pressed adds the item to the list, and then, below that, the shopping list itself should be displayed, with each item on a separate line. There should also be a "Clear" button which clears the list.

Creating Lists

You would implement a list by storing a list of data in state. You might think you could do something like this:

@Composable
fun ShoppingList() {
    var list by remember { mutableStateOf(mutableListOf<String>()) }
    var currentItem by remember { mutableStateOf("") }

    TextField(value=currentItem, onValueChange = { currentItem=it } )
    Button(onClick = { list.add(currentItem) } ) { Text("Add Item") }

    list.forEach {
        Text(it)
    }
}
Note how we have a text field which allows you to enter a shopping list item, which is stored in the currentItem state variable. When the button is clicked, the current item is added to the list. As it's a mutable list, you might think this would work.

However it does not work. The reason is that composables are only re-rendered if the state variables change. Here, when we add a new item to the list, the list becomes one element longer but the actual list variable is the same variable, referring to the same location in memory. To trigger a re-render when a new list item is added, we have to create a new list, add the item to that, and then reset the list state variable to the new list. This will change the state variable and force a re-render. Here is an example which does that:

@Composable
fun ShoppingList() {
    var list by remember { mutableStateOf(listOf<String>()) }
    var currentItem by remember { mutableStateOf("") }

    TextField(value=currentItem, onValueChange = { currentItem=it } )
    Button(onClick = { 
        var tempList = list.toMutableList()
        tempList.add(currentItem) 
        list = tempList
    } ) { Text("Add Item") }

    list.forEach {
        Text(it)
    }
}
Note here how:

Lazy Lists

One issue with a long list of items is that by default, a long list consisting of a series of Text items in a Column will not be memory efficient. Why? Let's say there are 100 items in the list, but only 10 are visible on the screen at any time. The items off the screen are still being rendered, even though they are invisible. This is clearly inefficient.

We can solve this problem through the use of lazy columns. The LazyColumn is designed to hold a series of items (i.e. a list) but is implemented with memory optimisation so that only for the items currently visible are rendered.

Creating a LazyColumn is quite straightforward, you place it in the appropriate place in your layout and then specify a lambda to control how it works. This lambda takes an object of type LazyListScope as its single parameter and this object includes an items method to specify a list of items. For example:

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items

LazyColumn {
    items(listItems) { curItem -> Text(curItem) }
}
Note how items() takes the list of items to render as its first parameter and another lambda as its last parameter. This lambda specifies how each item in the list of data should be transformed into a Compose element. So here, each item in the list is transformed to a Text element containing its details.

Exercise 2 - Lists

Implement the shopping list application described above in full. Ensure there is also a "Clear" button to clear the list.

Advanced exercise

Have a go at implementing a prototype of a simple messaging application using Jetpack Compose (lacking the communication-between-two-users aspect!). It should look something like this:
Messaging example
It should: