#8 Floating Windows on Android: The Final App
Have you ever wondered how to make those floating windows used by Facebook Heads and other apps? Have you ever wanted to use the same technology in your app? It’s easy, and I will guide you through the whole process.
I'm the author of Floating Apps; the first app of its kind on Google Play and the most popular one with over 8 million downloads. After 6 years of the development of the app, I know a bit about it. It’s sometimes tricky, and I spent months reading documentation and Android source code and experimenting. I received feedback from tens of thousands of users and see various issues on different phones with different Android versions.
Here's what I learned along the way.
Before reading this article, it's recommended to go through Floating Windows on Android 7: Boot Receiver.
In this article, I will teach you how to wrap everything together to get the note-taking app with floating technology.
The Final App
To finalize our app, we need to finish adding notes from the floating window. It's not hard but not simple because the view model we created in the first article is not available in the service.
As a workaround, we encapsulate the whole data management for notes into a class and prepare it to be usable both from the view model and our service.
As you can notice below, the newly created class also comes with support for broadcasting the NOTES_RECEIVER_ACTION
event when notes are added or removed. More on the broadcasting event is in the next chapter.
There is also enableMultiInstanceInvalidation()
for Room enabled. It solves the problem with the concurrent changes to the same database.
There is the complete source code for our new NotesDb
class:
class NotesDb(val context: Context) {
val db = Room.databaseBuilder(
if (context.applicationContext == null) context else context.applicationContext,
AppDatabase::class.java,
"db-notes"
).enableMultiInstanceInvalidation().build()
fun insert(note: String, sendBroadcast: Boolean = false) {
if (note.isEmpty()) return
val noteObj = Note((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), note)
insert(noteObj, sendBroadcast)
}
fun insert(note: Note, sendBroadcast: Boolean = false) {
GlobalScope.launch {
db.notes().insert(note)
if (sendBroadcast) {
update()
}
}
}
fun remove(note: Note, sendBroadcast: Boolean = false) {
GlobalScope.launch {
db.notes().delete(note)
if (sendBroadcast) {
update()
}
}
}
fun list(setter: (List<Note>) -> Unit) {
GlobalScope.launch {
setter(db.notes().getAll())
}
}
fun update() {
context.sendBroadcast(Intent(NOTES_RECEIVER_ACTION))
}
}
We need to update our NotesViewModel
(not shown here) to use NotesDb
and finish the Window
. When the user clicks on the add icon, the note is added, and the text field is cleared. And it's really that simple as adding a few lines:
class Window(context: Context) {
private val db = NotesDb(context)
private fun initWindow() {
// Add note and clear the edit field.
rootView.findViewById<View>(R.id.content_button).setOnClickListener {
with(rootView.findViewById<EditText>(R.id.content_text)) {
// Don't forget to pass true for sendBroadcast parameter.
db.insert(text.toString(), true)
setText("")
}
}
// ... the rest of initWindow() ...
}
// ... unrelated code is omitted for brevity ...
}
Notify The Main App
Jetpack Compose automatically refreshes notes if we change them in the view model. However, as it's impossible to get the view model in the service, we must invoke the refresh manually.
First, let's introduce a simple broadcast receiver that invokes lambda when the event is received. The event is sent by the NotesDb
introduced above.
const val NOTES_RECEIVER_ACTION = "com.localazy.quicknote.actions.UPDATE_NOTES"
class NotesReceiver(val update: () -> Unit) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
update()
}
}
We can now use the broadcast receiver in the view model - we create a broadcast aware view model.
When the event is received, we simply load notes from the Room database again. It leads to a change in the view model and refreshes the list of notes in our main app. The data-related logic is kept inside the view model and separated from UI, and it should be this way.
class NotesViewModel(application: Application) : AndroidViewModel(application) {
private val context = application.applicationContext
private val updateReceiver = NotesReceiver { loadItemsFromDb() }
init {
context.registerReceiver(updateReceiver, IntentFilter(NOTES_RECEIVER_ACTION))
loadItemsFromDb()
}
override fun onCleared() {
super.onCleared()
context.unregisterReceiver(updateReceiver)
}
// ... unrelated code is omitted for brevity ...
}
At this point, we have finished our note-taking app!
Localization & Volunteers
I skyrocketed Floating Apps by translating it to 30 languages, and one of the things I would like to teach you is how you can do the same.
We now have an excellent note-taking app that brings value to our users. Let's convert some of them to volunteers/contributors that help us to localize the app to more languages.
First, we need to give them the right tool for it, and ask them to help us. So let's create a language selector that is great for normal users as they can switch language, but it can also help us to communicate with our potential contributors. In the first article, we have integrated Localazy and its awesome localization library for Android, which is actually integrated automatically. By default, it resolves the language the same way as Android does. But we can force different locale if we want so.
Let's create a view model for our language selector. As the Localazy library is available, it's quite simple. LocalazyWrapperListener
simplifies the standard LocalazyListener
, and you can find it on Github.
class LocaleViewModel(application: Application) : AndroidViewModel(application) {
private val localazyListener = LocalazyWrapperListener {
viewModelScope.launch {
update()
}
}
var locales by mutableStateOf(listOf<LocalazyLocale>())
private set
init {
Localazy.setListener(localazyListener)
update()
}
private fun update() {
locales = Localazy.getLocales() ?: emptyList()
}
}
And a simple @Composable
to show a list of available languages with a 'help us translate' message. Notice that the message is kept in English and is not translated. That's for purpose because we want to attract people who are likely to understand English good enough to supply an accurate translation.
@Composable
fun ShowLocales(
items: List<LocalazyLocale>,
onChange: (LocalazyLocale) -> Unit,
onHelp: () -> Unit
) {
Column {
LazyColumnFor(items = items, modifier = Modifier.padding(0.dp, 8.dp)) {
TextButton(
onClick = { onChange(it) },
modifier = Modifier.padding(16.dp, 4.dp, 4.dp, 4.dp).fillMaxWidth()
) {
val name = "${it.localizedName}${if (!it.isFullyTranslated) " (incomplete)" else ""}"
Text(name)
}
}
TextButton(
onClick = { onHelp() },
modifier = Modifier.padding(16.dp, 12.dp, 4.dp, 4.dp).fillMaxWidth()
) {
Text("Help us translate the app!")
}
}
}
And the last thing to do is to wrap everything into LocaleActivity
:
class LocaleActivity : AppCompatActivity() {
private val localesViewModel by viewModels<LocaleViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ShowLocales(
localesViewModel.locales,
onChange = {
// Change the locale and persist the new choice.
Localazy.forceLocale(it.locale, true)
// Stop the service and reopen MainActivity with clearing top.
// MainActivity restarts the service, so the locale change
// is applied across both activity and the service.
startFloatingService(INTENT_COMMAND_EXIT)
startActivity(Intent(this@LocaleActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
})
},
onHelp = {
// Open the project on Localazy to allow contributors to help us with translating.
startActivity(
Intent(Intent.ACTION_VIEW, Localazy.getProjectUri()).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
)
}
)
}
}
}
We have to add the newly created LocaleActivity
to AndroidManifest.xml
and also add a way to open it from MainActivity
. See Results below for the video of the fully functional app and the full code on Github.
Btw, notice that there is no values-XX
folder in the project, just the base language's values
folder. All other languages are supplied by Localazy automatically. You don't need to care about it at all ;-).
It's simple, but it works! Just asking my users, I get hundreds of people helping me translate the app, suggest new ideas, and hunt bugs. I owe all my knowledge shared with you in these articles to users who helped me along the way! Thanks!
If you want to read more about this topic, be sure to check how I converted Floating Apps to Localazy.
Results
And here comes our final app! Fully working notes-taking app with floating technology and externally manage languages with seamless locale switching.
We are not yet done! I prepared two more articles to teach you tips, tricks, and shortcomings of floating technology.
Source Code
The whole source code for this article is available on Github.
The Series
This article is part of the Floating Windows on Android series.
- Floating Windows on Android 1: Jetpack Compose & Room
- Floating Windows on Android 2: Foreground Service
- Floating Windows on Android 3: Permissions
- Floating Windows on Android 4: Floating Window
- Floating Windows on Android 5: Moving Window
- Floating Windows on Android 6: Keyboard Input
- Floating Windows on Android 7: Boot Receiver
- Floating Windows on Android 8: The Final App
- Floating Windows on Android 9: Shortcomings
- Floating Windows on Android 10: Tips & Tricks
Subscribe to my newsletter
Read articles from Václav Hodek directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by