Chatter Compose
Cover Page
DUE Wed, 02/07, 2 pm
In this lab you’ll learn how to retrieve textual chatts
from a back-end server and how to post to it. You will familiarize yourselves with the Android app development environment and the basics of the platform. You’ll learn some Kotlin syntax and language features that may be new to you. And you will use Android Jetpack Compose to build UI declaratively. Let’s get started!
Expected behavior
Posting a new chatt:
DISCLAIMER: the video demo shows you one aspect of the app’s behavior. It is not a substitute for the spec. If there are any discrepancies between the demo and the spec, please follow the spec. The spec is the single source of truth. If the spec is ambiguous, please consult the teaching staff for clarification.
Preliminaries
If you don’t have an environment set up for Android development, please read our notes on Getting Started with Android Development first.
Before we start, you’ll need to prepare a GitHub repo for you to submit your labs and for us to communicate your lab grades back to you. Please follow the instructions in Preparing GitHub for EECS 441 Labs and then return here to continue.
Creating an Android Studio project
In the following, replace “YOUR_UNIQNAME” with your uniqname. Google will complain if your Package name
is not globally unique. Using your uniqname is one way to generate a unique Package name
.
Depending on your version of Android Studio, the screenshots in this and subsequent lab specs may not look exactly the same as what you see on screen.
- Click
New Project
in “Welcome to Android Studio” screen (screenshot) - On
Phone and Tablet
tab, selectEmpty Activity
(NOTNo Activity
and NOTEmpty Views Activity
) and clickNext
(screenshot) - Enter
Name
: composeChatter (screenshot, showing all fields below) -
Package name
: edu.umich.YOUR_UNIQNAME.composeChatter 👈👈👈replace
YOUR_UNIQNAME
with yoursAndroid Studio may automatically change all upper case letters in
Name
to lower case inPackage name
. If you prefer to use upper case, just edit thePackage name
directly. -
Save location
: specify the full path where yourcomposeChatter
folder is to be located, which will be 👉👉👉YOUR_LABSFOLDER/chatter/composeChatter/
, whereYOUR_LABSFOLDER
is the name of your 441 GitHub repo clone folder above. -
Minimum SDK
: API 33 (“Tiramisu”; Android 13.0)our labs are backward compatible to API 33 only.
-
Build configuration language
: Kotlin DSL (build.gradle.kts) - Click
Finish
Subsequently in this and other labs, we will use the tag YOUR_PACKAGENAME
to refer to your package name. Whenever you see the tag, please replace it with your chosen package name.
Checking GitHub
Open GitHub Desktop and
- Click on
Current Repository
on the top left of the interface - Click on the 441 GitHub repo you cloned above
- Add Summary to your changes and click
Commit to master
(orCommit to main
) at the bottom of the left pane - If you have a team mate and they have pushed changes to GitHub, you’ll have to click
Pull Origin
and resolve any conflicts, re-commit to master/main, and - Finally click on
Push Origin
to push changes to GitHub
If you are proficient with git, you don’t have to use GitHub Desktop. However, we can only help with GitHub Desktop, so if you use anything else, you’ll be on your own.
Go to the GitHub website to confirm that your folders follow this structure outline:
441
|-- chatter
|-- composeChatter
|-- app
|-- gradle
If the folders in your GitHub repo does not have the above structure, we will not be able to grade your labs and you will get a ZERO.
Android Studio project structure
Set the left or Project
pane of your Android Studio window to show your project structure in Android
view (screenshot) such that your project structure looks like this:
-
/app/manifests/AndroidManifest.xml
: general app settings and activity list -
/app/kotlin+java/
-
YOUR_PACKAGENAME
: source code- MainActivity.kt: we will be modifying this source file later.
While we are not required to call the first activity of the app the
MainActivity
(it can be changed inAndroidManifest.xml
), it is a convention to do so and Android Studio automatically does this when it sets up a new project.
- MainActivity.kt: we will be modifying this source file later.
-
YOUR_PACKAGENAME (androidTest)
: testing code to be run on device -
YOUR_PACKAGENAME (test)
: testing code to be run on development machine -
YOUR_PACKAGENAME.ui.theme
: the files here specify the “theme” (color, shape, typeface, etc.) of your app, it defaults to the Material Design theme.-
Color.kt
: ARGB definition of color -
Theme.kt
: Material Theme with dark and light modes -
Type.kt
: definition of type faces, weights, and sizes used
-
-
Theming
One can easily spend a whole weekend (or longer) getting the theme “just right.” It’s best to just leave this folder alone, most of the time.
We won’t be grading you on how beautiful your UI looks, all the layouts suggested in this and all subsequent labs are suggestions. You’re free to design your UI differently, so long as all indicated UI elements are fully visible on the screen, non overlapping, and functioning as specified.
-
/app/res/
: resource files-
drawable
: vector assets -
mipmap
: bitmap assets at different resolutions -
values
: constants for colors, strings, themes (cannot delete) -
xml
: rules specifications, such as security rules
-
-
/Gradle Scripts
: build scripts (see next section)
loading . . .
If your project pane doesn’t look like the above, wait for Android Studio to finish syncing and building and configuring, your project should then be structured per the above.
While not an industry convention, in this course we name the initial View MainView
.
While Jetpack Compose is highly anticipated by the Android developer community and has become more mature since its introduction in 2019, compared to traditional Android Views, it still lacks common features such as pullRefresh
in Material Design 3.
Gradle build setup
Gradle scipt is the build script for your Android project. Staying in your Project
pane, open the file /Gradle Scripts/build.gradle.kts (Project: composeChatter)
and update "org.jetbrains.kotlin.android"
version to “1.9.21”:
id("org.jetbrains.kotlin.android") version "1.9.21" apply false
Next, open the /Gradle Scripts/build.gradle.kts (Module:app)
file.
this is the Module gradle file, listed second in /Gradle Scripts/
, not the first listed Project gradle file /Gradle Scripts/build.gradle.kts (Project: composeChatter)
above. Subsequently, we will refer to /Gradle Scripts/build.gradle.kts (Module:app)
as the “app build file”.
In the composeOptions
block inside the android
block update the kotlinCompilerExtensionVersion
:
composeOptions {
kotlinCompilerExtensionVersion = "1.5.7"
}
Scroll down until you see the dependencies
block near the bottom of the file and
update "androidx.compose:compose-bom"
to:
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
“BoM” stands for “Bill of Materials.” It lists the versions of libraries compatible with each other. Do not use the “2023.08.00” or later BoM unless you want to build for API Level 34 (Android 14).
Then add the following lines inside the block, below the other implementation
statements:
implementation("androidx.compose.material3:material3:1.1.2")
implementation("androidx.compose.material:material:1.5.4")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("com.android.volley:volley:1.2.1")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.21")
Android Studio’s project template defaults to Material Design 3 theme, but
Material 3 does not yet implement the pullRefresh
feature, hence we’re including both Material 2 and 3 libraries. Navigation compose
helps us navigate between views, Kotlin reflect
helps with code introspection, as we will explain in ChattStore
below, and Volley
is a native android library used to make HTTP requests.
compileSDK, minSDK, targetSDK
In the android
block of your app build file, set compileSDK
to the latest Android API level, e.g., 34. This will give you access to the latest Android libraries and compiler warnings of deprecated features or APIs. The compileSDK
only affects the compilation step; the SDK is not bundled with your app. Set the minSDK
to the Android version running on your device or whose API your code depends on (sometimes APIs change signatures between Android versions). You can have compileSDK = 34
(Android 14), but minSDK = 31
(Android 11), for example. Finally, there’s targetSDK
, which is the Android version your app has been tested on. On a device running an Android version higher than your targetSDK
, OS behavior (e.g., themes) released after your targetSDK
will not be applied to your app. The targetSDK
can be set to between minSDK
and compileSDK
, usually it will be set to compileSDK
and Google PlayStore has a minimum supported version (API Level 33 at the time of writing). Both minSDK
and targetSDK
(if different) will be bundled with your app. Starting with Android 11 (API Level 11 (R)), when new APIs are added to a certain API level, it may also be made available as SDK Extensions
to earlier API levels. (See also references.)
Chatter
The Chatter app consists of two views: one to write and post a chatt
to server, and the other, the main view, to show retrieved chatts
. It is cheaply inspired by Twitter. And it already has a live web API:
https://mada.eecs.umich.edu/getchatts/
https://mada.eecs.umich.edu/postchatt/
You will create your own back end later in the lab.
First let’s define some strings we will be using in the app: open the file /app/res/values/strings.xml
. Inside the resources
block, below the line listing your app_name
, add:
<string name="chatter">Chatter</string>
<string name="post">Post</string>
<string name="send">Send</string>
<!-- 👇👇👇👇👇👇👇 -->
<string name="username">YOUR_UNIQNAME</string>
<string name="message">Some short sample text.</string>
<string name="back">Back</string>
Replace YOUR_UNIQNAME with your uniqname.
Let’s also define some additional colors we will be using: open the file /app/java/YOUR_PACKAGENAME.ui.theme/Color.kt
and add the following colors–some of these we will use in latter labs:
val WhiteSmoke = Color(0xFFEFEFEF)
val HeavenWhite = Color(0xFFFEFEFE)
val Gray88 = Color(0xFFE0E0E0)
val Canary = Color(0xFFFFC107)
val Moss = Color(0xFF526822)
val Firebrick = Color(0xFFB22222)
val DarkGreen = Color(0xFF006400)
Permission, navigation, and dependency
First we need the user’s permission to use the network.
In AndroidManifest.xml
, before the <application
block, add:
<uses-permission android:name="android.permission.INTERNET"/>
Chatt
To post a chatt
with the postchatt
API, the Chatter
back end server expects a JSON object consisting of “username” and “message”. For example:
{
"username": "YOUR_UNIQNAME",
"message": "Hello world!"
}
Chatter
’s getchatts
API will send back all accumulated chatts in the form of a JSON object with the key being “chatts” and the value being an array of string arrays. Each string array consists of three elements: “username”, “message”, and “timestamp”. For example:
{
"chatts": [["username0", "message0", "timestamp0"],
["username1", "message1", "timestamp1"],
...
]
}
Each element of the string array may have a value of JSON null
or the empty string (""
).
Create a new Kotlin File (not Class):
- Right click on
/app/java/PACKAGE_NAME
folder on the left/project pane - Select
New > Kotlin Class/File
-
Enter
Chatt
in theName
text field on top of the dialog box that pops up and double click onFile
(again notClass
(screenshot))When you select
New > Kotlin Class/File
, Android Studio defaults to creating a Kotlin Class, which automatically adds a blank class definition for you, whereas we want an empty file here. So be sure to choose “File” not “Class”.Please remember this distinction between creating a Kotlin File vs. Class. You will need to make this distinction in all subsequent labs.
-
Place the following class definition for
Chatt
in the newly created file:class Chatt(var username: String? = null, var message: String? = null, var timestamp: String? = null)
ChattStore
as Model
We will declare a ChattStore
object to hold our array of chatt
s. Since the chatt
s are retrieved from the Chatter
back-end server and sent to the same back-end server when the user posted a chatt
, we will keep the network functions to communicate with the server as methods of this class.
Create another Kotlin File (not Class), call it ChattStore
, and place the following ChattStore
object in it:
object ChattStore {
private val _chatts = mutableStateListOf<Chatt>()
val chatts: List<Chatt> = _chatts
private val nFields = Chatt::class.declaredMemberProperties.size
private lateinit var queue: RequestQueue
private const val serverUrl = "https://mada.eecs.umich.edu/"
fun initQueue(context: Context) {
queue = newRequestQueue(context)
}
}
Once you have implemented your own back-end server, you will replace
mada.eecs.umich.edu
with your server’s IP address.
With auto import enabled, as you enter code, Android Studio will automatically detect and determine which library you need to import. It will then prompt you to hit the Alt-Enter or Opt-Enter key combination to automatically import said library. Go ahead and import the suggested libraries.
Sometimes, there are multiple library choices and Android Studio would have you choose. Most of the time the choice will be rather obvious (don’t choose the Audio library if you’re not implementing Audio, or do pick the one with the word compose
since we’re using Jetpack Compose, for example). We always provide a full list of imports as an Appendix to each lab spec. Compare your import list against the list in the Appendix when in doubt. Or you could cut and paste all of the imports into your source files before any code. If you choose to do so, be sure that you do NOT check Optimize imports on the fly
in Android Studio’s Preferences/Settings, otherwise Android Studio will automatically remove them all for being unused and therefore unnecessary.
Using the keyword object
to declare the class makes it a singleton object, meaning that there will ever only be one instance of this class when the app runs. Since we want only a single copy of the chatt
s data, we make this a singleton object.
The chatts
array will be used to hold the chatt
s retrieved from the back-end server. While we want chatts
to be readable outside the class, we don’t want it modifiable outside the class, and so have declared a private
_chatts
and made available an immutable version as chatts
. The array _chatts
is declared as of type MutableStateList
, an observable version of List
. When a Jetpack Compose function (a composable) reads the value of an observable variable (the subject), the composable is automatically subcribed to the subject, i.e., it will be notified and the composable will automatically recompose when the value changes. If the subject is updated but the new value is the same as the old value, recomposition will not be triggered (a.k.a. changes are conflated, duplicates are removed).
Recompositions
Performance consideration: when the value of a MutableState
changes, all subscribing composables will recompose. Each composable is associated with a RecomposeScope
. To prevent frequent recompositions of a composable with many UI elements, isolate each UI element into a separate composable (function), thereby limiting effect of changes to a MutableState
only to composables that actually, truly, rely on the value of the MutableState
.
The code Chatt::class.declaredMemberProperties.size
uses introspection to look up the number of properties in the Chatt
type. We store the result in the variable nFields
for later validation use.
ChattListRow
We want to display the chatt
s retrieved from the backend in a timeline view. First we define what each row contains. Create a new Kotlin File, ChattListRow
, and place the following composable in it:
@Composable
fun ChattListRow(index: Int, chatt: Chatt) {
Column(modifier = Modifier.padding(8.dp, 0.dp, 8.dp, 0.dp)
.background(color = if (index % 2 == 0) Gray88 else HeavenWhite)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth(1f)) {
chatt.username?.let { Text(it, fontSize = 17.sp, modifier = Modifier.padding(4.dp, 8.dp, 4.dp, 0.dp)) }
chatt.timestamp?.let { Text(it, fontSize = 14.sp, textAlign = TextAlign.End, modifier = Modifier.padding(4.dp, 8.dp, 4.dp, 0.dp)) }
}
chatt.message?.let { Text(it, fontSize = 17.sp, modifier = Modifier.padding(4.dp, 10.dp, 4.dp, 10.dp)) }
}
}
"dp", "px", "sp"
Aside from different screen sizes, different Android devices also have different screen densities, i.e., number of pixels per inch. To ensure UI elements have more-or-less uniform sizes on screens with different densities, Google recommends that sizes be expressed in terms of device-independent pixels (dp, previously dip) which is then displayed using more or less pixels (px) depending on screen density (see also Cracking Android Screen Sizes and Designing for Multiple Screen Densisites on Android).
Text sizes are measured in sp (scale-independent-pixel) unit, which specifies font sizes relative to user’s font-size preference—to assist visually-impaired users.
The content of a ChattListRow
composable consists of a Column
of two items: a Row
on top and, below it, a text box containing the chatt
message. The chatt
message is padded so that the message is displayed inside the textbox, away from the edges of the textbox.
The upper Row
consists of two items: a text box containing the username
and another text box containing the timestamp
. The contents of these boxes are also padded except for their bottom edges. These two boxes are spaced out evenly with no extra margins at the start and end of the row (which is what Arrangement.SpaceBetween
mean), with the final result being that the two textboxes are flushed left and right respectively. By default, all textboxes take up as much space as needed by their contents.
Margins vs. padding
Difference between margins and padding: when specifying constraints between UI elements, we set the margin between them. Within a UI element, its content, such as text, is displayed at a certain offset (padding) from the sides of the element. The perimeter of a UI element is its border, whose thickness can also be changed. Here are a number of illustrations to help explain:
Source: Margin vs. Padding
Source: stackoverflow article
The thing to remember is that the margins of a View
or a Layout
are outside the View
/Layout
, the margins specify the relationship of a UI element to its parent’s boundaries or to other (“sibling”) Views/Layouts within the parent ViewGroup
. Whereas padding applies inside a UI element, between the border of the UI element and its contents.
If your locale has a language that reads left to right, Start
is the same as Left
, otherwise for languages that read right to left (RTL), Start
is the same as Right
(conversely and similarly End
). Most of the time you would use Start
and End
to refer to two ends of a UI element, reserving left
and right
to the physical world, e.g., when giving direction.
You can hover over a composable (e.g., Column
, Text
, or
Scaffold`) to bring up a menu of possible actions on it.
DSL
Notice how type inference and the use of trailing lamba makes Row
, Column
, Text
, etc. look and act like keywords of a programming language used to describe the UI, separate from Kotlin. Hence Compose is also considered a “domain-specific language (DSL)”, the “domain” here being UI description.
MainView
Create another new Kotlin File, name it MainView
, and add the following initial declaration of MainView
:
@Composable
fun MainView() {
Scaffold(
topBar = {
TopAppBar(title = {
Text(
text = stringResource(R.string.chatter),
fontSize = 20.sp
)
})
},
floatingActionButton = {
FloatingActionButton(
containerColor = Canary,
contentColor = Moss,
shape = CircleShape,
modifier = Modifier.padding(0.dp, 0.dp, 10.dp, 10.dp).scale(1.2f),
onClick = {
// navigate to PostView
}
) {
Icon(Icons.Default.Add, stringResource(R.string.post), modifier = Modifier.scale(1.3f))
}
}
) {
// content of Scaffold
// describe the View
}
}
Scaffold
is a composable that implements the basic Material Design visual layout structure, providing slots for the most common top-level components such as topbar, floating action button, and others. By using Scaffold
, we ensure the proper positioning of these components and that they interoperate smoothly. Scaffold
, TopAppBar
are examples of layout composables that follow the slot-based layout, a.k.a. Slot API pattern of Compose. In our case, we add
- a
TopAppBar
that consists of only a textbox containing the stringChatter
and - a
FloatingActionButton
with a moss-colored ‘+’ icon on a canary-colored background, offset 10dp from the trailing/end and bottom edges of the screen. We leave theonClick
action of the floating action button empty for now.
Both the topBar
and floatingActionButton
parameters of Scaffold
take as argument a composable with zero parameter and Unit
return value. Since a function is not a reference to the function in Kotlin, to use the TopAppBar()
and FloatingActionButton()
composables we thus need to wrap each in a parameterless lambda returning Unit
value, as we have done above.
When adding the TopAppBar
composable, Android Studio will put a red squiggly line under it. Hovering over the line, you’ll see that Android Studio prompts you to Opt in for 'ExperimentalMaterial3Api on MainView'
. Go ahead and click on the recommendation to accept it.
You can experiment with other colors by consulting the Material Design 3 Color System.
Scaffold
’s last parameter, content
, also takes a composable as argument. Since it is the last argument, we have presented it as a trailing lambda in the above. Let’s show the chatt
timeline here. Put the following code below the comment, //describe the View
:
// content of Scaffold
// describe the View
LazyColumn(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier.padding(
it.calculateStartPadding(LayoutDirection.Ltr),
it.calculateTopPadding() + 10.dp,
it.calculateEndPadding(LayoutDirection.Ltr),
it.calculateBottomPadding()
)
.background(color = WhiteSmoke)
) {
items(count = chatts.size) {
ChattListRow(it, chatts[it])
}
}
A LazyColumn
is a Column
that only allocates space and renders the view of currently visible data, ideal for large data set. The function items()
apply the provided composable, ChattListRow
, to a count
number of items.
We also specify that LazyColumn
should space the items evenly with the margin of the top and bottom edges being half the spacing between items (Arrangement.SpaceAround
).
getChatts()
If you run your app now, you will get a blank screen as we have no means to retrieve and populate the chatt
timeline yet.
Let us return to ChattStore
and add the following getChatts()
method to the class, to retrieve chatts
from the back-end server:
fun getChatts() {
val getRequest = JsonObjectRequest("${serverUrl}getchatts/",
{ response ->
val chattsReceived = try { response.getJSONArray("chatts") } catch (e: JSONException) { JSONArray() }
_chatts.clear()
for (i in 0 until chattsReceived.length()) {
val chattEntry = chattsReceived[i] as JSONArray
if (chattEntry.length() == nFields) {
_chatts.add(Chatt(username = chattEntry[0].toString(),
message = chattEntry[1].toString(),
timestamp = chattEntry[2].toString()))
} else {
Log.e("getChatts", "Received unexpected number of fields: " + chattEntry.length().toString() + " instead of " + nFields.toString())
}
}
},
{ e -> Log.e("getChatts", e.localizedMessage ?: "NETWORKING ERROR") }
)
queue.add(getRequest)
}
To retrieve chatt
s, we create a JsonObjectRequest()
with the appropriate GET URL. The server will return the chatt
s as a JSON object. In the completion handler to be invoked when the response returns, we call .getJSONArray()
to decode the serialized JSON value corresponding to the "chatts"
key from the returned response.
Once the GET request is created, we submit it to the request queue managed by the Volley networking library for asynchronous execution. Prior to submitting the request to the request queue, we check that the queue has been created and, if not, create the queue. We need only one request queue in ChattStore
, but ChattStore
, being an object
, persists for the whole lifetime of the app.
Context
The Context
, the computing resources and environment, off which the request queue resides thus must also persist for the whole lifetime of the app. Each Android Activity
has its own Context
. The whole application also has its own Context
. The activity context contains states such as the theme applied to the activity and lasts only as long as the lifetime of its activity. The application context holds lower-level system access and stays around for the whole lifetime of the application. As we will see later in the MainActivity
section, we pass the applicationContext
from MainActivity
to ChattStore()
so that we can use it in creating our Volley request queue above. It is a common Android development pattern to pass Context
around as most system APIs require access to Context
.
Kotlin serialization
Instead of encoding/decoding between JSON and Kotlin data by hand, as we have done here, we could use Kotlin serialization. This would, however, require every field of each entry to be a key-value pair, as opposed to an array of values with no key as we have done. Given the small number of entries in Chatt
, the current approach seems to work well enough.
Pull to refresh
Now that we have a means to retrieve chatt
s from the back-end server, we return to MainView
to retrieve the chatt
s and use them to populate the LazyColumn
composable.
Add this variable declaration to your MainView
composable, before the call to Scaffold()
:
var isRefreshing by remember { mutableStateOf(false) }
// Android Studio will recommend annotating `MainView()` with
// @OptIn for 'ExperimentalMaterialApi'. Accept the recommendation.
val pullRefreshState = rememberPullRefreshState(isRefreshing, {
getChatts()
isRefreshing = false
})
Android Studio will flag rememberPullRefreshState()
as error, hover over it and accept the suggestion to Opt in for 'ExperimentalMaterialApi' on 'MainView'
(screenshot). Then wrap the existing call to LazyColumn()
inside a Box()
composable such that the call to Box()
becomes the full content of Scaffold()
’s trailing lambda:
// content of Scaffold
Box(Modifier.pullRefresh(pullRefreshState)) {
// describe the View
LazyColumn(/* ... no change, keep code ... */) {
// ... keep current code ...
}
PullRefreshIndicator(isRefreshing, pullRefreshState,
Modifier.align(Alignment.TopCenter))
}
The added PullRefreshIndicator()
must come after LazyColumn()
. You can pullRefresh
only if your swipe gesture starts from inside a list. If your list is empty, it is not shown on screen and therefore you cannot initiate pullRefresh
. You also cannot initiate pullRefresh
from the empty space below a list. When you can initiate it, pullRefresh
shows a “loading” icon and run the onRefresh
lambda expression embedded in the pullRefreshState
. The variable isRefreshing
is used to control when pullRefresh
finishes running and stops showing the “loading” icon. pullRefresh
requires isRefreshing
to be a published state variable, which is why we declare it a mutableStateOf()
here, with false
as the initial value. Even though isRefreshing
is a MutableState
, it is declared inside a composable, which means that it is destroyed and recreated every time the composable is recomposed. To retain the value a MutableState
across recompositions, we tag it with remember {}
, as we do also with pullRefreshState
.
As previously discusssed, to publish a subject, we make it a MutableState
using mutableStateOf()
or mutableStateListOf()
. A composable that uses a published variable automatically subscribes to it. You don’t need to use remember()
to subscribe to a published variable. Use remember()
only if you want to retain states declared in composables across recompositions. This is normally used for view-logic states.
Other than recomposition, states in a composable can also be destroyed by events impacting activity lifecycle, such as device-orientation change. To save composable states across changes in orientation, use rememberSaveable()
. States in a singleton object
are not destroyed across recompositions nor activity lifecycles. States declared in an Activity can be maintained across lifecycles by declaring them inside a ViewModel()
, as we will see later in the course.
The lambda we provide to rememberPullRefreshState()
in this case calls getChatts()
. After calling getChatts()
we immediately set isRefreshing
to false
, which will be propagated to the State pullRefreshState
(saved across recompositions by rememberPullRefreshState()
), causing the refresh to end. Note that pullRefresh()
doesn’t actually refresh the view, it calls getChatts()
which refreshes the chatts
array. Compose then recomposes the view given the updated chatts
array. When the user posts a chatt
, we will automatically call getChatts()
to refresh our chatts
array. We implement pullRefresh
for two reasons:
- to refresh our
chatts
array with postings from other users. Since our backend doesn’t implement a push mechanism, such as websockets, the user must actively pull to refresh, and - as a UI/UX mechanism to let the user feels more in control of the app.
PostView
We are not done with MainView
, but let us put it aside for awhile and shift our focus to PostView
, which we will use to compose and post a chatt
. Create a new Kotlin File, call it PostView
and again create a composable with Scaffold()
:
@Composable
fun PostView() {
val context = LocalContext.current
val username = stringResource(R.string.username)
var message by rememberSaveable { mutableStateOf(context.getString(R.string.message)) }
Scaffold(
// put the topBar here
) {
Column(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier.padding(
it.calculateStartPadding(LayoutDirection.Ltr)+8.dp,
it.calculateTopPadding(),
it.calculateEndPadding(LayoutDirection.Ltr)+8.dp,
it.calculateBottomPadding())
) {
Text(username,
Modifier
.padding(0.dp, 30.dp, 0.dp, 0.dp)
.fillMaxWidth(1f), textAlign=TextAlign.Center, fontSize = 20.sp
)
TextField(
value = message,
onValueChange = { message = it },
modifier = Modifier
.padding(8.dp, 20.dp, 8.dp, 0.dp)
.fillMaxWidth(.8f),
textStyle = TextStyle(fontSize = 17.sp),
colors = TextFieldDefaults.colors(unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent)
)
}
}
}
We will need to access Android’s Activity
context to retrieve the default message
string. In Compose, data hoisted and made available to a composable sub-tree is called a CompositionLocal
variable. A CompositionLocal
variable is an observable state that can be subscribed to by composables within the sub-tree where the variable is provided. Since passing Activity context is such a common operation in building Android apps, it is provided to all composables as the CompositionLocal
variable LocalContext
. While CompositionLocal
variables are globally visibile, their states or values are scoped to the sub-tree where the data is provided. The advantage of using a CompositionLocal
variable is that we don’t have to pass/drill it down a sub-tree, yet composables in the sub-tree can subscribe and react to changes in the variable. We read the current Activity context off LocalContext.current
and save it in the context
variable.
We describe PostView
as a Column
of two UI elements (as composables): a text box displaying the immutable variable username
and an editable TextField
. The value
parameter of TextField
is subscribed to message
. We provide a lambda to the onValueChange
parameter to update the value of message
whenever user enters new input. When message
updates, value
will be notified and it will react by recomposing TextField
, thereby updating the text displayed (otherwise, text entered won’t show up in the edit box).
Three ways to remember
There are three ways we can use remember()
:
- Directly assign a remembered mutable state to a variable:
val message = remember { mutableStateOf("A message") } // type: MutableState<String>
however the variable will then be of type
MutableState<T>
. - Use property delegation (
by
):val message by remember { mutableStateOf("A message") } // type: String
we implicitly cast the state object as object of type
T
in Compose, which makes working with the variable more convenient, e.g., we can directly assign a literal value of typeT
to the variable.T
beingString
in this case. - Or destructure the remembered state into its getter and setter:
val (message, setMessage) = remember { mutableStateOf("A message") }
The getter (
message
) is then used to read the state and its setter (setMessage
) to set the state. When used withTextField()
, itsvalue
parameter will be assigned the getter,message
, and itsonValueChange
parameter assigned the setter,setMessage
.
The MutableState
for message
is delegated to rememberSaveable { }
, instead of simply remember { }
, here to retain its value if the user navigates to another view (such as the AudioView
in the audio lab) and back, prior to sending the chatt
. As explained in a stackoverflow answer (second comment), “If you navigate to a different composable, the previous composable is destroyed - along with the state kept by remember
. If you want to retain that state, rememberSaveable
will preserve it when you navigate back. When you are navigating to and from screens, you are not recomposing but composing from a fresh start.” However, rememberSaveable { }
preserves state only if its composable is still on the navigation stack. Once its composable is popped off the stack, none of its states will be further retained.
Navigation between views
We use a NavController
to move from composable to composable. In order that all composables under navigation have access to this NavController
, we declare (“lift”) it as high up in our composable hierarchy as necessary, to encompass all composables under navigation as a subtree of that point (“state hoisting”). For us, since we want to navigate between both of our composables (MainView
and PostView
), we declare the NavController
at the root of the view hierarchy, i.e., in MainActivity
.
In your MainActivity.kt
file, but outside the MainActivity
class definition, create a global CompositionLocal
key:
val LocalNavHostController = staticCompositionLocalOf<NavHostController> { error("LocalNavHostController provides no current")}
We will initialize the variable later. If it is accessed before any value has been provided, we call error()
to throw an IllegalStateException
.
MainActivity
Now put the following code inside the MainActivity
class, after the call to super.onCreate(savedInstanceState)
. Replace the template setContent {}
, and its call to Greeting()
, with the following code (you can also delete the now superfluous Greeting
composable that is part of the provided template):
initQueue(applicationContext)
getChatts()
setContent {
val navController = rememberNavController()
CompositionLocalProvider(LocalNavHostController provides navController) {
NavHost(navController, startDestination = "MainView") {
composable("MainView") {
MainView()
}
composable("PostView") {
PostView()
}
}
}
}
After initializing the Volley queue, we call getChatts()
to retrieve chatts
from
the back end on application launch.
As a general rule, a @Composable
function can only be called by another @Composable
function. The only exception is setContent()
, which binds the given composable to the Activity as the root view of the Activity.
It is the only non-composable allowed to call a composable.
In setContent
, we create a navigation controller navController
using rememberNavController()
so that it survives recompositions of MainActivity
’s root view. Then we wrap the whole UI tree below the root inside a CompositionLocalProvider()
that provides the navController
to the CompositionLocal
variable LocalNavHostController
. All the composables in the whole UI tree below the root can now access this navController
.
Associated with each NavController
is a NavHost
, which ties the NavController
to the collection of composables you want to put under navigation. Collect all the composables you want to put under navigation. Give each a name/identification in the form of a text string. This is known as the path to each composable (though the navigation “graph” is only one level deep) and will be used by Navigation to route to your composable. In creating the NavHost
, we specify that navigation should start at the composable with name or path “MainView”. Then we provide NavHost
with the two composables we want under navigation, MainView
and PostView
. For each composable()
, we first specify its path, as a String
, and provide a lambda that calls the composable associated with the path.
Once we have navigation setup, we can move between composables. Let’s return to MainView
and put in the code to navigate to PostView
when the floating action button is clicked. First add a local variable to the MainView()
composable to save the current LocalNavHostController
off CompositionLocal
:
val navController = LocalNavHostController.current
Then add the following line under the // navigate to PostView
comment inside the lambda assigned to the onClick
parameter of the call to FloatingActionButton
:
// navigate to PostView
navController.navigate("PostView")
@Preview and LiveEdit
When Android Studio created MainActivity.kt
it also put in it a @Preview
block. As the name implies, the @Preview
code allows you to preview your composables. The preview only renders your composable, it is not an emulator, it won’t populate your composable with data. I found the preview of to be of limited use and would just comment out the whole @Preview
block, which automatically disables the preview and closes the Design (or Split) pane. The video, Compose Design Tools, shows you what is possible with @Preview
. Also check out the @Preview section of the References below.
Android Studio Giraffe and higher supports LiveEdit
which “update composables in emulators and physical devices in real time.” While @Preview
allows you to see your UI design in different themes, locales, and UI element settings but does not actually run the rest of your app, LiveEdit
updates your actual running app, though at times of writing (7/28/23), it doesn’t yet update consistently.
Posting chatts
As we have done in MainView
, first add a local variable to the PostView()
composable to save the current LocalNavHostController
off CompositionLocal
:
val navController = LocalNavHostController.current
In the following code, we give PostView()
a title, a button at the upper right corner to post a chatt
, and a “back arrow” navigation button at the upper left corner, in case we want to cancel posting and return to MainView
. Put the following code in PostView
under the comment, // put the topBar here
in the call to Scaffold()
:
// put the topBar here
topBar = {
TopAppBar(title = { Text(text = stringResource(R.string.post),
fontSize=20.sp) },
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp),
navigationIcon = { ArrowBack() },
actions = { SubmitButton() }
) },
In the call to create the TopAppBar
, we give it a title
, with font size 20 sp. The title is stored as a “string” resource named “post” in the /app/res/strings.xml
file.
Then we add a navigationIcon
button, which is placed to the left of the title, following the default Material Design layout. Finally, we add an actions
button, which, by default, is placed flushed right on the top bar.
We put the definition of both navigationIcon
and actions
buttons in separate composables.
Add the following code inside your PostView
composable before the call to Scaffold()
.
@Composable
fun ArrowBack() {
IconButton(onClick = { navController.popBackStack() } ) {
Icon(Icons.Default.ArrowBack, stringResource(back))
}
}
@Composable
fun SubmitButton() {
var canSend by rememberSaveable { mutableStateOf(true) }
IconButton(onClick = {
canSend = false
postChatt(Chatt(username, message)) {
getChatts()
}
navController.popBackStack()
}, enabled = canSend) {
Icon(
Icons.Default.Send,
stringResource(R.string.send)
)
}
}
We assign to the navigation button a “back arrow” icon we collected earlier from Vector Asset
. We give the SubmitButton
a paper-plane icon (Icons.Default.Send
) and name it, “Send”. When clicked, the button calls postChatt()
with a Chatt
object instantiated with the values of username
and message
variables of PostView
. Once the user has clicked the Send
button, we set canSend
to false
to disable and “grey out” the button. This feature is not as visible in this lab since the sending process completes immediately. In latter labs, when the sending process can take some time, it prevents user from repeatedly clicking the Send
button.
Once the chatt
is posted, we call getChatts()
to retrieve an updated list of chatt
s from the back end, including chatt
s other users may have posted since our previous call to getChatts()
. Thanks to reactive UI, MainView
will update its displayed timeline automatically when the chatts
array in ChattStore
is updated, without the user having to pull down to refresh.
To ensure the call to getChatts()
occurs only after postChatt()
has completed (not just that the chatt
is sent, but that a response has returned from the sender), we provide a completion
function to postChatt()
as a trailing lambda. We call getChatts()
in this trailing lambda. In the definition of postChatt()
below, we see that the completion
lambda is run in the callback function of JsonObjectRequest()
, which is executed when JsonObjectRequest()
is done posting the chatt
.
The NavController
maintains a stack of visited composables. When you navigate from one composable to another, the NavController
pushes the destination composable above the current one on the stack. Once we fire off postChatt()
, we pop PostView
off the navigation stack to get back to the MainView
composable. Depending on the amount to be sent and the state of the network, postChatt()
may not have completed when we return to MainView
.
We now provide the postChatt()
method.
postChatt()
Add the postChatt()
method to the ChattStore
singleton object
:
fun postChatt(chatt: Chatt, completion: () -> Unit) {
val jsonObj = mapOf(
"username" to chatt.username,
"message" to chatt.message,
)
val postRequest = JsonObjectRequest(Request.Method.POST,
${serverUrl}postchatt/", JSONObject(jsonObj),
{ completion() },
{ e -> Log.e("postChatt", e.localizedMessage ?: "JsonObjectRequest error") }
)
queue.add(postRequest)
}
In postChatt()
, we first assemble together a Kotlin map comprising the key-value pairs of data we want to post to the server. To post it, we create a JsonObjectRequest()
with the appropriate POST URL. We can’t just post the Kotlin map as is though. The server may not, and actually is not, written in Kotlin, and in any case could have a different memory layout for various data structures. Presented with a chunk of binary data, the server will not know that the data represents a map, nor how to reconstruct the map in its own map layout. To post the Kotlin map, therefore, we call JSONObject()
to encode it into a serialized JSON object that the server will know how to parse.
With the POST request created, we submit it to the request queue managed by the Volley networking library for asynchronous execution. Prior to submitting the request to the request queue, we check that the queue has been created and, if not, create the queue.
If there was no error in posting the chatt
, we call the provided completion()
lambda.
completion
We designed getChatts()
and postChatt()
to handle all networking aspects of the labs, including conversion between JSON to Kotlin data. The function getChatts()
grabs the current ensemble of chatt
s from the back end, convert each from JSON to Kotlin data, and store them in the ChattStore.chatts
array. Thanks to reactive UI, MainView
will update its displayed timeline automatically when the chatts
array in ChattStore
is updated, without the user having to pull down to refresh. While the Chatt
format may be customized to each lab, there is no further lab-specific cleanup functions after the chatt
s are stored, hence we don’t need to pass any customized completion
function to getChatts()
.
On the other hand, postChatt()
grabs the chatt
to be posted, in the form of Kotlin data, converts it to a JSON package, and sends it to the back end. After postChatt()
has completed posting a chatt
, we want to call getChatts()
to retrieve the current batch of chatt
s from the back end. To ensure that getChatts()
will return our recently posted chatt
, we call it only after postChatt()
has completed. We may also need to do some lab-specific cleanup, mainly releasing and resetting various memory resources that should only be done when postChatt()
is no longer dependent on them. We ensure that these steps are performed in succession by passing a lab-specific completion()
function when calling postChatt()
, to be run only after postChatt()
’s network operation has completed.
Congratulations! You’re done with the chatter
lab!
Run and test to verify and debug
You should now be able to run your front end against the provided back end on mada.eecs.umich.edu
.
If you’re not familiar with how to run and test your code, please review the
instructions in the Getting Started with Android Development. There is no special instructions to run the chatter
lab on the Android emulator.
Completing the back end
Once you’re satisfied that your front end is working correctly, follow the back-end spec to build your own back end:
With your back end completed, return here to prepare your front end to connect to your back end via HTTPS.
Installing your self-signed certificate
Download a copy of selfsigned.crt
to YOUR_LABSFOLDER
on your laptop. Enter the following commands:
laptop$ cd YOUR_LABSFOLDER
laptop$ scp -i eecs441.pem ubuntu@YOUR_SERVER_IP:441/selfsigned.crt selfsigned.crt
Then install selfsigned.crt
onto your Android:
- Download
selfsigned.crt
from your laptop onto your emulator or device.- If you don’t see your device mirrored on Android Studio, connect it to Android Studio for USB debugging, then turn on device mirroring by selecting
File/Android Studio > Settings > Tools > Device Mirroring
and checkingEnable mirroring of physical Android device
. To view the mirrored device, selectView > Tool Windows > Running Devices
. - With your emulator or mirrored device visible in your Android Studio, drag
selfsigned.crt
on your laptop and drop it on the home screen of the emulator or mirrored device.Alternatively, you could also email
selfsigned.crt
to yourself, then on the device/emulator, view your email and tap the attachedselfsigned.crt
.
- If you don’t see your device mirrored on Android Studio, connect it to Android Studio for USB debugging, then turn on device mirroring by selecting
- On your Android home screen, swipe up to reveal the
Settings
button. - Go to
Settings > Security & privacy > More security settings > Encryption & credentials > Install a certificate > CA certificate > Install anyway >
tap onselfsigned.crt
.
If Android Files app shows you
Recent files
:No items
, tap the hamburger menu at the top left corner and selectDownloads
in theOpen from
drawer that slides out.
You can verify that your certificate is installed in Settings > Security & privacy > More security settings > Encryption & credentials > Trusted credentials > USER
(tab at the top right corner).
To test the installion, launch a web browser on the emulator or device and access your server at https://YOUR_SERVER_IP/getchatts/
.
Preparing Chatter
Next, we need to tell Chatter
to trust the self-signed certificate.
Right click the xml
folder in /app/res/
, choose New > File
in the drop-down menu. Name the new XML file network_security_config.xml
and put the following content in the file:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<!-- 👇👇👇👇👇👇👇👇👇 -->
<domain includeSubdomains="true">YOUR_SERVER_IP</domain>
<trust-anchors>
<certificates src="user"/>
</trust-anchors>
</domain-config>
</network-security-config>
WARNING: be sure to limit the use of the self-signed certificate to your back-end server IP as shown above. In particular do not use the <base-config>
tag because it will cause your app to try and fail to apply the self-signed certificate with other services, such as Google Maps.
Add the following line that accounts for the new file to your AndroidManifest.xml
, above the existing android:theme
line:
<application
<!-- ... other items -->
android:networkSecurityConfig="@xml/network_security_config"
android:theme=@style/Theme.composeChatter">
Finally, change the serverUrl
property of your ChattStore
class from mada.eecs.umich.edu
to YOUR_SERVER_IP
. Build and run your app and you should now be able to connect your mobile front end to your back end via HTTPS.
Front-end submission guidelines
We will only grade files committed to the master
or main
branch (they are the same). If you use multiple branches, please merge them all to the master/main branch for submission.
Push your front-end code to the same GitHub repo you’ve submitted your back-end code:
- Open GitHub Desktop and click on
Current Repository
on the top left of the interface - Click on the GitHub repo you created at the start of this lab
- Add Summary to your changes and click
Commit to master
(orCommit to main
) at the bottom of the left pane - Since you have pushed your back end code, you’ll have to click
Pull Origin
to synch up the repo on your laptop - Finally click
Push Origin
to push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder chatter
. Confirm that your repo has a folder structure outline similar to the following. If your folder structure is not as outlined, our script will not pick up your submission, you will get ZERO point, and you will further have problems getting started on latter labs. There could be other files or folders in your local folder not listed below, don’t delete them. As long as you have installed the course .gitignore
as per the instructions in Preparing GitHub for EECS 441 Labs, only files needed for grading will be pushed to GitHub.
441
|-- chatter
|-- composeChatter
|-- app
|-- gradle
|-- chatterd
|-- selfsigned.crt
Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your lab doesn’t open, build, or run.
IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md
(click the pencil icon at the upper right corner of the README.md
box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md
if you work by yourself.
Invite eecs441staff@umich.edu
to your GitHub repo. Enter your uniqname (and that of your team mate’s) and the link to your GitHub repo on the Lab Links sheet. The request for teaming information is redundant by design.
References
General
- Android Developers web site
Dev
- Android Studio
- Gradle: build tool and dependency manager for Android
-
Picking your compileSdk
Version, minSdkVersion, and targetSdkVersion - Google Play developer account
- GooglePlay’s Testing Tracks
- Publishing app to Play Store
Jetpack Compose Concepts
- Understanding Jetpack Compose
- Thinking in Compose
- Architecting your Compose UI
- Layouts in Compose
- Slot API
- Lists
- State and Jetpack Compose
- Observer vs Pub-Sub pattern
- Navigating with Compose
- Get started with Jetpack Compose
- Compose tooling
- Material Components and layout
Tutorial Pathways
Documentations
- Use Android Studio with Jetpack Compose
- androidx.compose
- Scaffold
- Column
- LazyColumn
- Button
- MutableState
- mutableStateOf()
- mutableStateListOf()
- remember
- rememberSaveable
-
Navigation Errata:
nullability = true
should benullable = true
. - Navigate with arguments Errata:nullability = true
should benullable = true
. - NavController
- NavHost
- Navigating in Jetpack Compose
3rd-party articles on Jetpack Compose
Depending on date of publication, Compose APIs used in 3rd-party articles may have been deprecated or their signatures may have changed. Always consult the authoritative official documentation and change logs for the most up to date version.
Screen sizes and densities
- How to Find Device Metrics for Any Screen
- Designing for multiple screen densities on Android
- Difference Between dp, dip, sp, px, in, mm, pt in Android
- Screen sizes and densities market distribution
- Screen compatibility overview
Layout and Components
- Scaffold Widgets Template
- TopAppBar
- androidx.compose.material.pullrefresh
- Passing multi typed data between screens in Jetpack Compose navigation component
ConstraintLayout and Compose
- ConstraintLayout in Jetpack Compose
- Explore Constraint Layout on Jetpack Compose <!– ConstraintLayout loves Jetpack Compose
- Jetpack Compose’s Rows and Colums or ConstraintLayout? –>
LiveEdit and @Preview
- Deep dive into Live Edit for Jetpack Compose UI
- Iterative code development
- Composable Preview
- Jetpack Compose and Composable Preview
- Jetpack Compose Preview like a pro
- The power of @Preview
Themes and Styles
- Setting up Themes
- How to create a truly custom theme in Jetpack Compose
- Surfaces
- Sample Code with Surface
- Material Design Color System scroll all the way down until you get to the “2014 Material Design color palettes”
- Access default icon in SDK
- Material Design Icons
- Material Design Icons Guide
- Add multi-density vector graphics
Appendix: imports
Prepared for EECS 441 by Alex Wu, Tiberiu Vilcu, Nowrin Mohamed, and Sugih Jamin | Last updated: January 10th, 2024 |