In this topic we will:
When developing software we frequently have to catch the user's attention, by displaying a prominent message to the user or prompting the user to enter essential information. We do this by means of dialog boxes. Dialog boxes are pop-up boxes which appear in front of the main UI and either contain informatioin or prompt the user to enter information. In Compose, there are two main composables available to allow us to use dialogs: the AlertDialog and the plain Dialog.
Which of these you use depends on the requirements of your app. A very common type of dialog is one which presents important information to the user or asks the user to confirm an action. This will always contain some text, a "Dismiss" button (to hide the dialog), and a "Confirm" button (which allows the user to confirm an action). This type of dialog is known as an alert dialog, and because it is such a common type of dialog, there is a composable specialised for it - the AlertDialog.
If you wish to develop a more complex dialog, for example to allow the user to enter text or to pick an image or a colour scheme, then you should use the ordinary Dialog class together with a composable containing the content, for example a Surface or a Column.
We will look at each of these now.
The AlertDialog is the simplest to code, as it does not require a custom layout. You specify common properties of the dialog, and Compose will build a dialog for you. Here is a simple example:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DialogsTheme {
var dialogVisible by remember { mutableStateOf(false) }
var dialogSubmitted by remember { mutableStateOf(false) }
Column {
if (dialogSubmitted) {
Text(fontSize = 24.sp, text = "Tickets booked.")
} else {
Button(onClick = {
dialogVisible = true
}) {
Text("Book Tickets")
}
}
}
if (dialogVisible) {
AlertDialog(
icon = {
Icon(painter=painterResource(R.drawable.transit_ticket_24dp), "Book Tickets")
},
title = {
Text("Book Tickets")
},
text = {
Text(
"Do you want to book the tickets?"
)
},
onDismissRequest = {
dialogVisible = false
},
dismissButton = {
Button(onClick = {
dialogVisible = false
}) {
Text("Dismiss")
}
},
confirmButton = {
Button(onClick = {
dialogVisible = false
dialogSubmitted = true
}) {
Text("Confirm")
}
}
)
}
}
}
}
}
Note how we define the dialog with AlertDialog: it is placed within our material theme (so the theme will apply to it) but outside our main Column. Note how the AlertDialog takes various arguments:
icon (optional) - an icon which will appear at the top of the dialog.title - the dialog title, appears at the top of the dialog, below the icon.text - the main content of the dialog.onDismissRequest - a callback function which runs when the user dismisses the dialog by clicking outside it.
dismissButton - the button to use for the dismiss action. Should have an onClick and some content, like a normal Button.confirmButton - the button to use for the confirm action. Should have an onClick and some content, like a normal Button.Note that we use state variables to control whether the dialog is visible or not. In our main column we have a "Book Tickets" button, which when clicked, sets the state variable dialogVisible to true. This triggers a recompose, so that when the composable is next drawn, the AlertDialog will be shown, as it's only displayed if dialogVisible is true.
Also note how if the user presses either the confirm or the dismiss buttons, the dialogVisible state variable is set to false, so the dialog will disapper. Additionally, if the user presses the confirm button, the dialogSubmitted state variable is set to true, which means that a confirmation message is rendered in the column rather than the original book button.
The AlertDialog is very useful if we want to produce a standard alert dialog with Confirm and Dismiss buttons. However if we want to implement a custom UI for the dialog, we should use the Dialog composable instead. With Dialog, you define a standard composable (such as a Surface or Column) containing your content, and wrap a Dialog round it to turn it into a dialog.
Here is an example showing a login dialog:
if (loginDialogVisible) {
Dialog(
onDismissRequest = {
loginDialogVisible = false
}
) {
var username by remember { mutableStateOf("username") }
var password by remember { mutableStateOf("password") }
Column(
modifier = Modifier
.background(color = MaterialTheme.colorScheme.background)
.padding(10.dp)
) {
Text("Username")
TextField(value = username, onValueChange = { username = it })
Text("Password")
TextField(value = password, onValueChange = { password = it },
visualTransformation = PasswordVisualTransformation())
Button(onClick = {
loginDialogVisible = false
loggedIn = username == "fred" && password == "s3cr3t123"
}) {
Text("Login!")
}
}
}
}
Note how we create a Dialog containing a Column as its content. This column implements a login form (note the visualTransformation of PasswordVisualTransformation to hide the password). When the button is clicked, we set the loginDialogVisible state variable to hide the dialog, and we also set a loggedIn state variable to true if the login details were correct. This could be used, for example, to show hidden content.
Frequently we wish to inform the user of an event, such as receiving an email, or getting a new message in social media. Such a message is known as a notification. In Android, a notification is a message which appears as an icon at the top of the device and also appears in a notification list. The Notification class represents a notification.
To use notifications in an application targeting API level 33 (Android 13) upwards, you need to add the POST_NOTIFICATIONS permission to the manifest:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Additionally the minSdk should be at least 26 if you wish to use notification channels (see below).
On Android Oreo (API level 26) and upwards, notifications must be associated with a particular channel. Channels group together related notifications; all notifications on a given channel can be associated with the same sound or light colour (e.g. flashing green for text message, blue for a social media update, and so on. A user can allow or block all notifications on a particular channel for a particular app, by going to the settings for that app, selecting "Notifications", and turning that specific channel on or off.
To use channels, create a NotificationChannel object with a given ID.
The example was originally based on that provided here, but has been modified. More information on channels can be found on this page, including associating a channel with a notification light colour or vibration, organising channels into groups, opening the user's notification settings and deleting channels.
val channelID = "EMAIL_CHANNEL" val channel = NotificationChannel(channelID, "Email notifications", NotificationManager.IMPORTANCE_DEFAULT) val nMgr = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nMgr.createNotificationChannel(channel)
Note that:
nMgr is an object of type NotificationManager. It is a system-wide object used for managing notifications.channelID is a unique string identifier for that channel.NotificationChannel, we supply the channel ID, the visible name of the channel ("Email notifications" here), and the channel importance. The importance controls how prominently the notifications are displayed.Notification object, by specifying options for the notification such as the icon and the associated text. For example:
val notification = Notification.Builder(this, channelID)
.setContentTitle("Time update")
.setContentText("Time is now ${System.currentTimeMillis()}")
.setSmallIcon(R.drawable.caution)
.build()
This will create a notification on the channel channelID (see above) with the title "Time update" and full message text showing the current time in milliseconds since Jan 1 1970.NotificationManager (we did this above when creating a notification channel) and call its notify() method, passing in the Notificiation object:
nMgr.notify(uniqueId, notification) // uniqueId is a unique ID for this notification
In many cases, we will want something to happen when the user clicks on a notification. or example, imagine a mapping app in which you receive a notification when you are nearby a point of interest. You might want a separate activity to launch when the user clicks on the notification, which displays full details of the point of interest (e.g a description, and reviews, for a pub or hotel).
To do this we need to use pending intents. Before looking at pending intents, we also need to look at Android's Intent class. An Intent is a representation of an action to launch, or communicate with, some other app component. For example, if we wish to create a second Activity (with Compose, less common than it used to be), we would create an Intent to launch the second activity. Intents can contain data, known as extras, which are useful if we wish to pass data between activities. These are stored in a collection of key/value pairs known as a Bundle. Intents also have an action: a string describing what the Intent does. Activities can receive many different Intents and we use the action to work out which Intent the activity has received, and act accordingly.
A pending intent is an Intent which will occur at some future time (e.g. when the user clicks on the notification) hence the name PendingIntent.
Here is an example of creating a pending intent which runs a separate activity, EmailActivity. Note we must first create an Intent, and then wrap it with a PendingIntent:
// First, create an Intent, to launch the EmailActivity
// let() is like apply(), but the subject of the let() is passed in as a
// parameter and therefore is referred to with "it" not "this"
val emailIntent = Intent(this, EmailActivity::class.java).let {
it.action = "ACTION_LAUNCH_EMAIL"
it.putExtra("emailMessageId", 2345)
}
// Set the flags (options) for the Intent (discussed below)
emailIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// Now create a PendingIntent referencing that intent
val emailPendingIntent = PendingIntent.getActivity(this, 0, emailIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
Note how we add the email message ID as an extra so that the recipient of the Intent can process it appropriately.
IMPORTANT UPDATE: Intent.FLAG_IMMUTABLE must be specified if targetSdk is 31 or more.
You then use the PendingIntent in a notification:
val notification = Notification.Builder(this)
.setContentTitle("New email message")
.setContentText("You have a new email message")
.setSmallIcon(R.drawable.message)
.setContentIntent(emailPendingIntent)
.build()
One common use-case for pending intents is to navigate back to the activity which generated the notification, by clicking on a notification. The main activity of an email app might notify you when an email has been received. However, you might be using another app at the time, with the email app in a stopped state in the background (i.e. between onStop() and onStart()). What should happen is that the email app should become visible again when you click on the notification.
This is a little more complex than you might think: you have to account for whether the email activity is already running in the background or not; if it is, it should become visible, but if it is not, it should be launched. The code above will achieve this. The key thing to understand here is that when activities are launched, they are placed on top of a stack of activities.
The key things to note here are the flags of the intent (note: setFlags() in Java). To repeat this code:
emailIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOPThese mean the following:
FLAG_ACTIVITY_CLEAR_TOP - if there are other activities above the activity
generating the notification, clear them from the activity stackFLAG_ACTIVITY_SINGLE_TOP - if the activity is already the top activity on the stack
(i.e. the one currently showing), do not relaunch it but use the existing
copy of the activityFLAG_ACTIVITY_SINGLE_TOP, another copy of the activity would be
created and launched when the user presses the notification, so we have two copies
in the stackFLAG_ACTIVITY_SINGLE_TOP), the activity will not be
relaunched, but an Intent will still be delivered to itonNewIntent() method which will handle all Intents delivered
to the activity, whether it's relaunched or notaction and then extracting the extras from itFor example, this responds to the PendingIntent we created earlier if the notification is launched from the same activity. Note how onNewIntent() takes the Intent as a parameter and we are testing its action. If the action is correct we extract the emailMessageId extra from the Intent.
class EmailActivity: ComponentActivity() {
// ... other code ...
override fun onNewIntent(intent: Intent){
super.onNewIntent(intent)
if(intent.action == "ACTION_LAUNCH_EMAIL") {
intent.extras?.let {
Toast.makeText(
this,
"Opening email with ID ${it.getInt("emailMessageId")}",
Toast.LENGTH_LONG
).show()
}
}
}
}
Notifications can have additional actions (which appear as buttons).
You use Notification.Builder's addAction() with an Action object to add these; again providing an icon, text and a PendingIntent. For example, here we create a button "Stop Music Player" which is associated with a PendingIntent containing a broadcast Intent to send a broadcast with the action STOP_MUSIC. This will be received by another application component which handles playing music. (Broadcasts are messages which we can use to communicate between application components such as activities; we will look at them in Topic 11. Note how we use PendingIntent.getBroadcast() rather than PendingIntent.getActivity())
val broadcast =Intent().apply {
action = "STOP_MUSIC"
}
val piStopMusic = PendingIntent.getBroadcast(this, 1, broadcast, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = Notification.Builder(this)
.setContentTitle("Song update")
.setContentText("Now playing: Oh Well by Fleetwood Mac")
.setSmallIcon(R.drawable.stop)
.setContentIntent(pendingIntent)
.addAction(Notification.Action.Builder(R.drawable.icon2, "Stop Music", piStopMusic).build())
.build()
We haven't covered this yet, but this is a convenient place to introduce adding click handlers to Ramani Maps circles and symbols. To do this, use the onClick parameter and pass in a lambda as an event handler, e.g:
Circle(
// ... other settings ...
onClick = {
// handle the user clicking the circle
}
)
To use this you need to ensure that you use at least version 0.9.0 of Ramani Maps.
This exercise builds on your Week 4 work (Ramani Maps with GPS). If you do not have this work, or you only have a version with navigation included, please clone this version:
https://github.com/nwcourses/ramani-gps