Movies Widget
Widget UI and Data
Let's make this widget actually do something. We'll fetch the list of movies from the database and display it on the widget. When the user selects a movie, they'll be taken to its display page in the app.
Before moving forward, let's update the widget metadata.
description
adds descriptive text when choosing the widget to place on a home screen. This requires adding the text to strings.xmlminWidth
andminHeight
limits how small the user can size the widgetresizeMode
specifies which directions the user can resize the widgettargetCellWidth
andtargetCellHeight
gives sizing when initially placing the widget. These are multiples of the cell size defined by the home application.widgetCategory
specifies if the widget can appear on the home or lock screen (called "keyguard" here)
Widgets run in a different process than our application. The MovieAppWidget
runs in our
application's process to create a set of Remote Views that are sent to the widget to be
rendered. We need to set it up to fetch data when first creating the widget and when the widget is
updated.
By default, GlanceAppWidget
is set up to read widget state from a preference store. This is a
file that's private to your application, and is normally used to store application settings
between runs. Here it's used for the application to write widget state updates. If you only
have minor data to update, this is fine, but a movie list is typically too much data to store
in a preference store (and the data would have to be read from the database, written to
the file, then read back from the file to perform the widget update).
Instead, we'll override the update data handling.
All classes that extend GlanceAppWidget
contain a GlanceStateDefinition
instance that
provides a DataStore
that the widget can use to get its data. We'll use this data store to
fetch the initial list of movies from the database. Glance gets the data
property from the
data store, a Flow
of our movie list in this case.
Whenever the widget is told to update (we'll see that later), Glance reads data
again and updates
state.
Normally, if we use a DataStore
, we read data
once to get the Flow
of updates, and then
collect from that Flow
to get changes. However, the MovieAppWidget
wants an explicit update
notification, telling it when to update its data. We'll ask for a new Flow
each time from
our repository to get the movie list.
To do this, we need to:
- Create a state definition class. This is just a factory for our data
store. We don't need to define the
getLocation
function; it's not used (and normally returns aFile
indicating where the data is stored) - Create a data store that returns the actual data. Its data property
returns a new
Flow<List<MovieDto>>
each time it's read. - Add a state definition property in our
MovieAppWidget
so it knows how to create its data store (and then get its data). Because the widget doesn't update the data, we don't implement theupdateData
function. - Get the current state so we can display it.
We need to explicitly request widget updates when the UI changes data. This is done via
MovieAppWidget().updateAll(context)
Note
This will update all instances of the movie widget that the user has placed on their home screens. It's possible to update just a specific instance if you'd like, but we won't go into that detail here. (Think as an example, different instances of a picture-frame widget that host different images - you click on one to take you to the app to select an image, and it updates only that instance of the widget)
Ideally, we want to make this update call in as few places as possible, while ensuring we cover all cases. In our application, this is simple - we can watch the movie flow for emissions and call update. For this purpose, we start a coroutine when the view model is initialized and collect the movie flow.
The updateAll()
call requires a context. We can access the application context (which lives
across the lifetime of the application, vs an activity context that only lives while the
activity is running) by changing our view model
to subclass AndroidViewModel
and passing in an Application
. AndroidViewModel
contains a
getApplication()
function that allows access when we call updateAll()
.
This requires that we pass the application when creating our view model in its factory.
The UI for our MovieAppWidget
uses a LazyColumn
to display our list of
movies. When a movie title is clicked, we start our MainActivity
and
pass the movie id as a parameter.
Note that actionStartActivity
creates a lambda for us, so we pass it in parentheses to clickable
rather than put it in a lambda. If we had instead written
.clickable {
actionStartActivity<MainActivity>(
actionParametersOf(movieIdKey to movie.id)
)
}
we'd be telling it to call actionStartActivity
to create the lambda when the button is clicked.
We need to create that lambda and use it as the function called when the button is clicked.
The created lambda creates an explicit Intent
targeting our activity, and uses the
actionParametersOf
function to add key and value pairs to the extras
map of that intent.
The name specified in the movieIdKey
must match the name we use
to retrieve the data from the intent in MainActivity
. We define the
key name as a constant in MainActivity
to ensure the name is
consistent.
The fun part is now dealing with that intent coming into the activity. The activity may or may not already be running. If you were running the application and went to the home screen, the system may keep the activity instance around until it needs to reclaim its memory. However, it could kill it at any time when it's not the current foreground application. This leads to two ways we must deal with the intent:
- if the activity is not running, a new instance will be created and the intent passed to
onCreate
- if the activity is running, the intent will be passed to the
onNewIntent()
function of the existing activity instance
We need to handle the intent in both places, so we create a
helper function that tries to pull the movie id out of an
intent. If it's not present, we reset the stack to be just MovieList
. If it is present, we set
the stack to be MovieList
followed by MovieDisplay
for that movie. This presents the movie to
the user and allows them to press back to get to the movie list.
We call handleMovieId()
from
onCreate
and
onNewIntent
.
Note that onNewIntent
is passed an Intent
as a parameter, while onCreate
uses the property
intent
defined by the activity as the Intent
that launched it.
If we run the application now, we'll get some interesting results if we
- Click on a movie in the widget (and see the movie displayed)
- Go back to home
- Click on a different movie in the widget (and see it displayed)
- Press back repeatedly
The problem is that each time we start the activity from the widget, a new instance is pushed
on the stack for the application process. We now have a stack of activities, managed by the
system, each of which contains a stack of screens. Originally, Android applications used an
Actitity
for each screen, calling startActvitity
to push new instances on the system-managed
stacks. Over time we evolved to using a single Activity
approach.
To fix this, we set the launch mode for our MainActivity
to singleInstance
, which ensures we only ever have one instance of the activity.
Code Changes
CHANGED: /app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <!-- android:launchMode="singleInstance" --> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.MovieUi1" tools:targetApi="31"><activity android:name=".MainActivity" android:exported="true" android:launchMode="singleInstance" android:theme="@style/Theme.MovieUi1"><intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> <intent-filter> <action android:name="android.intent.action.VIEW" /> <data android:scheme="https" /> <data android:scheme="http" /> <category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity><receiver android:name=".glance.MovieAppWidgetReceiver" android:exported="true"> <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> </intent-filter> <meta-data android:name="android.appwidget.provider" android:resource="@xml/movie_app_widget_info" /> </receiver></application> </manifest>
CHANGED: /app/src/main/java/com/androidbyexample/movie/MainActivity.kt
package com.androidbyexample.movie import android.content.Intentimport android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.androidbyexample.movie.screens.MovieDisplayimport com.androidbyexample.movie.screens.MovieListimport com.androidbyexample.movie.screens.Ui import com.androidbyexample.movie.ui.theme.MovieUi1Themeconst val MOVIE_ID_EXTRA = "movieId"class MainActivity : ComponentActivity() { private val viewModel by viewModels<MovieViewModel> { MovieViewModel.Factory } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)intent?.handleMovieId()setContent { MovieUi1Theme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Ui(viewModel) { finish() } } } } }override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.handleMovieId() }private fun Intent.handleMovieId() { val movieId = extras?.getString(MOVIE_ID_EXTRA) if (movieId != null) { viewModel.setScreens(MovieList, MovieDisplay(movieId)) } }}
CHANGED: /app/src/main/java/com/androidbyexample/movie/MovieViewModel.kt
package com.androidbyexample.movie import android.app.Applicationimport androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.glance.appwidget.updateAllimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.androidbyexample.movie.glance.MovieAppWidgetimport com.androidbyexample.movie.repository.ActorDto import com.androidbyexample.movie.repository.MovieDatabaseRepository import com.androidbyexample.movie.repository.MovieDto import com.androidbyexample.movie.repository.MovieRepository import com.androidbyexample.movie.repository.RatingDto import com.androidbyexample.movie.screens.MovieList import com.androidbyexample.movie.screens.Screen import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatestimport kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch @OptIn(FlowPreview::class)class MovieViewModel( application: Application, private val repository: MovieRepository,private val _selectedIdsFlow = MutableStateFlow<Set<String>>(emptySet()) val selectedIdsFlow: Flow<Set<String>> = _selectedIdsFlow.asStateFlow() fun clearSelectedIds() { _selectedIdsFlow.value = emptySet() } fun toggleSelection(id: String) { if (id in _selectedIdsFlow.value) { _selectedIdsFlow.value -= id } else { _selectedIdsFlow.value += id } } private var screenStack = listOf<Screen>(MovieList) set(value) { field = value clearSelectedIds() currentScreen = value.lastOrNull() } // NOTE: We're keep this as a Compose State for comparison. // You can use Compose state to expose anything from the view model, // but our example will be using Flow from now on to demonstrate how // the view model can be used without Compose, perhaps for other // platforms such as iOS, desktop, web or command line var currentScreen by mutableStateOf<Screen?>(MovieList) private set fun pushScreen(screen: Screen) { screenStack = screenStack + screen } fun popScreen() { screenStack = screenStack.dropLast(1) } fun setScreen(screen: Screen) { screenStack = listOf(screen) }//): ViewModel(), MovieRepository by repository {): AndroidViewModel(application), MovieRepository by repository {fun setScreens(vararg screens: Screen) { screenStack = screens.toList() }fun update(movie: MovieDto) { movieUpdateFlow.value = movie } fun update(actor: ActorDto) { actorUpdateFlow.value = actor } fun update(rating: RatingDto) { ratingUpdateFlow.value = rating } // using a debounced flow as a person-update queue private val movieUpdateFlow = MutableStateFlow<MovieDto?>(null) private val actorUpdateFlow = MutableStateFlow<ActorDto?>(null) private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null) init {viewModelScope.launch { repository.moviesFlow.collectLatest { MovieAppWidget().updateAll(getApplication()) } }viewModelScope.launch { movieUpdateFlow.debounce(500).collect { movie -> movie?.let { repository.upsert(it) } } } viewModelScope.launch { actorUpdateFlow.debounce(500).collect { actor -> actor?.let { repository.upsert(it) } } } viewModelScope.launch { ratingUpdateFlow.debounce(500).collect { rating -> rating?.let { repository.upsert(it) } } } } fun deleteSelectedMovies() { viewModelScope.launch { deleteMoviesById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedActors() { viewModelScope.launch { deleteActorsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedRatings() { viewModelScope.launch { deleteRatingsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } companion object { val Factory: ViewModelProvider.Factory = object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras ): T { // Get the Application object from extras val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) return MovieViewModel(application,MovieDatabaseRepository.create(application) ) as T } } } }
CHANGED: /app/src/main/java/com/androidbyexample/movie/glance/MovieAppWidget.kt
package com.androidbyexample.movie.glance import android.content.Context import androidx.compose.ui.unit.dpimport androidx.compose.ui.unit.spimport androidx.datastore.core.DataStoreimport androidx.glance.GlanceId import androidx.glance.GlanceModifierimport androidx.glance.GlanceThemeimport androidx.glance.action.ActionParametersimport androidx.glance.action.actionParametersOfimport androidx.glance.action.actionStartActivityimport androidx.glance.action.clickableimport androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.appWidgetBackgroundimport androidx.glance.appwidget.lazy.LazyColumnimport androidx.glance.appwidget.lazy.itemsimport androidx.glance.appwidget.provideContent import androidx.glance.backgroundimport androidx.glance.currentStateimport androidx.glance.layout.fillMaxSizeimport androidx.glance.layout.fillMaxWidthimport androidx.glance.layout.paddingimport androidx.glance.state.GlanceStateDefinitionimport androidx.glance.text.FontWeightimport androidx.glance.text.Text import androidx.glance.text.TextStyleimport com.androidbyexample.movie.MOVIE_ID_EXTRAimport com.androidbyexample.movie.MainActivityimport com.androidbyexample.movie.repository.MovieDatabaseRepositoryimport com.androidbyexample.movie.repository.MovieDtoimport kotlinx.coroutines.flow.Flowimport java.io.Fileprivate val movieIdKey = ActionParameters.Key<String>(MOVIE_ID_EXTRA)class MovieGlanceStateDefinition: GlanceStateDefinition<List<MovieDto>> { override suspend fun getDataStore( context: Context, fileKey: String ): DataStore<List<MovieDto>> = MovieDataStore(context) override fun getLocation(context: Context, fileKey: String): File { throw RuntimeException("Should not be used") }}class MovieDataStore( context: Context,): DataStore<List<MovieDto>> { val repository = MovieDatabaseRepository.create(context) override val data: Flow<List<MovieDto>> get() = repository.moviesFlow override suspend fun updateData(transform: suspend (t: List<MovieDto>) -> List<MovieDto>): List<MovieDto> { throw RuntimeException("Should not be used") }}class MovieAppWidget : GlanceAppWidget() {override val stateDefinition = MovieGlanceStateDefinition()override suspend fun provideGlance(context: Context, id: GlanceId) { provideContent {// Text(text = "Widget!")val movies = currentState<List<MovieDto>>()GlanceTheme { LazyColumn( modifier = GlanceModifier .fillMaxSize() .padding(4.dp) .appWidgetBackground() .background(GlanceTheme.colors.background), ) { items(items = movies) { movie -> Text( text = movie.title, modifier = GlanceModifier .padding(4.dp) .fillMaxWidth()} } }.clickable( actionStartActivity<MainActivity>( actionParametersOf(movieIdKey to movie.id) ) ), style = TextStyle( fontWeight = FontWeight.Normal, fontSize = 18.sp, ), ) } } }
CHANGED: /app/src/main/res/values/strings.xml
<resources> <string name="app_name">MovieWidget</string> <string name="movies">Movies</string> <string name="movie">Movie</string> <string name="title">Title</string> <string name="description">Description</string> <string name="reset_database">Reset Database</string> <string name="edit">Edit</string> <string name="loading">(loading)</string> <string name="cast">Cast</string> <string name="cast_entry">%1$s: %2$s</string> <string name="clear_selections">Clear Selections</string> <string name="delete_selected_items">Delete selected items</string> <string name="ratings">Ratings</string> <string name="rating">Rating</string> <string name="actors">Actors</string> <string name="actor">Actor</string> <string name="name">Name</string> <string name="movies_rated">Movies rated %1$s</string> <string name="movies_starring">Movies starring %1$s</string> <string name="movie_list">Movie List</string></resources>
CHANGED: /app/src/main/res/xml/movie_app_widget_info.xml
<?xml version="1.0" encoding="utf-8"?><appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"// android:initialLayout="@layout/glance_default_loading_layout" />android:initialLayout="@layout/glance_default_loading_layout" android:description="@string/movie_list" android:minWidth="46dp" android:minHeight="46dp" android:resizeMode="horizontal|vertical" android:targetCellWidth="2" android:targetCellHeight="2" android:widgetCategory="home_screen" />