Show any composable on top of other apps. Draggable, snaps to the screen edges, drop it at the bottom to close it, aggregates what your screens contribute, and optionally stays alive in a foreground service.
Animated illustration of the sample app (not a screen recording) · full quality: docs/demo.mp4
Jump to: Install · Quick start · Cheat sheet · Windows · Items · Dismissal · Lifecycle · Placement · Service · Testing · Troubleshooting · Migrating from v1
// root build.gradle
allprojects { repositories { maven { url 'https://jitpack.io' } } }
// module build.gradle (latest tag: see the badge)
implementation 'com.github.aabadaa:Fly-view:Tag'Needs the user to allow "Draw over other apps": you ask for it, the library never opens settings behind your back.
A floating counter that shows the same number as your screen. This is the sample app.
1. Your state, nothing special: any flow, state holder or ViewModel.
object CounterState {
val count = MutableStateFlow(0)
val isOpen = MutableStateFlow(false)
}2. Describe the window, once (for example in Application.onCreate). The block is composed while the overlay runs, in the process and not in an activity.
FlyOverlay.contribute("counter") {
val open by CounterState.isOpen.collectAsState()
if (open) {
FlyWindow("counter", onDismissRequest = { CounterState.isOpen.value = false }) {
val count by CounterState.count.collectAsState()
Row {
Text("$count")
Button(onClick = { CounterState.count.update { it + 1 } }) { Text("+") }
Button(onClick = { dismiss() }) { Text("×") }
}
}
}
}3. Start it (permission first, start throws if it is missing)
if (FlyOverlay.hasPermission(context) || FlyOverlay.requestPermission(activity)) { // requestPermission is suspend
CounterState.isOpen.value = true
FlyOverlay.start(context)
}That is all: the window floats over other apps, can be dragged (it snaps to the closest edge), and can be dropped on the bottom to close it. Your screen reads CounterState.count too, so both always show the same number. The overlay stops itself when its last window is gone, the next start composes your blocks again.
| I want to... | Use |
|---|---|
| Declare windows without a screen | FlyOverlay.contribute(id) { FlyWindow(...) { } } |
| Declare a window inside a screen | FlyWindow(key) { } (needs the overlay started) |
| Start / stop | FlyOverlay.start(context) · FlyOverlay.stop() |
| Permission | FlyOverlay.hasPermission(context) · FlyOverlay.requestPermission(activity) (suspend or callback) · FlyOverlay.permissionIntent(context) |
| Several screens, one window | FlyItem(window, id) { } + items in the window |
| Dismiss from inside | dismiss() in the window content |
| Dismiss from outside | FlyOverlay.requestDismiss(window) · requestDismissAll() |
| Take control of the dismissal | FlyDismissEffect { ...; dismiss() } |
| One position per mode | rememberFlyWindowState(key) once per mode, pass the one you want |
| Dock to an edge | placement = FlyPlacement.Docked(FlyEdge.Bottom, FlySize.Fill) |
| Stay above the keyboard | ime = FlyIme.Avoid |
| A dialog | FlyDialog(onDismissRequest) { } |
| A ViewModel for the window session | rememberFlyViewModel { MyViewModel() } |
| Start from a widget / tile (Android 12+) | subclass FlyLaunchActivity |
| Keep the process alive | FlyOverlay.configure(service = MyFlyService::class.java) |
All calls: main thread.
FlyWindow is a small real window that wraps its content, so touches outside of it reach the app below.
| Param | Default | |
|---|---|---|
key |
- | identifies the window. A duplicate key replaces the old content |
state |
rememberFlyWindowState(key) |
position, one per mode if it remembers one for each |
visible |
true |
false plays exit, then removes it and calls onRemoved |
visibleWhen |
Default |
Default: always for a contribute block (floats over other apps), while the screen's activity or fragment is started for a window a screen composes. Also Always, Foreground, Background, Owner(lifecycleOwner) |
focusable |
false |
true for text fields and the back button, it takes them from the app below |
drag |
Whole |
None, Whole, HandleOnly (use Modifier.flyDragHandle()) |
snap |
Edges() |
FlySnap.None, or Edges(padding, durationMillis) |
dismissZone |
DismissZone() |
the area at the bottom where it is dropped, null = off |
placement |
Floating |
see below |
ime |
Ignore |
Avoid keeps it above the keyboard |
hideWhenEmpty |
false |
hides when no FlyItem is contributed |
onDismissRequest |
null |
commit a dismissal: update your state here |
onRemoved |
null |
the window has gone after visible = false |
onClosingSoon |
null |
a hint that it may close soon |
onKeyEvent |
null |
sees every key before the content, true consumes it (needs focusable = true) |
configure |
null |
last resort: change the raw WindowManager.LayoutParams |
Behavior: it stays inside the usable screen (system bars and cutout), survives rotation and size changes, flies to the closest edge with the fling velocity, and shows a drop area while dragging that highlights with a haptic tick (FlyOverlay.dismissZoneContent changes its look).
val icon = rememberFlyWindowState("note/icon", FlyPosition.Edge(FlySide.Right, y = 0.3f))
val editor = rememberFlyWindowState("note/editor", FlyPosition.InheritFrom(icon))
FlyWindow("note", state = if (extended) editor else icon) { ... } // switching animates to the saved positionFlyWindow("tools", hideWhenEmpty = true) {
items.forEach { item -> Row { item.Content(); Button(onClick = item::dismiss) { Text("x") } } } // you draw the dismiss button
}
@Composable fun ScreenA() { FlyItem("tools", "timer", onDismissRequest = { vm.stopTimer() }) { TimerChip() } }
@Composable fun ScreenB() { FlyItem("tools", "note", order = 1) { NoteChip() } }Items with the same (window, id) replace each other. They are composed in the window, so the window's CompositionLocals apply.
Every way of removing a window is a request: the drop on the dismiss zone, the back button, dismiss() in the content, FlyOverlay.requestDismiss.
FlyWindow("note", visible = !closed, onDismissRequest = { vm.close() }) {
FlyDismissEffect { // optional: the window takes control of its own dismissal
vm.showMessage() // anything: a message, a save, a confirmation...
delay(400)
dismiss() // the real dismissal, or cancel() to keep the window
}
}FlyDismissEffect(if any) runs first. It ends withdismiss(),cancel()(the window snaps back), or by returning, which counts asdismiss().onDismissRequestcommits it: update your state. Without it the window hides until it leaves the composition and comes back.
Requests while one is running are ignored.
Window lifecycle (shown and hidden)
A window has a real LifecycleOwner, and its content lives exactly while the window is on the screen. There are no onShown/onHidden callbacks, use the lifecycle:
| Moment | Lifecycle of the window | Content |
|---|---|---|
| The window is added to the screen | CREATED → STARTED → RESUMED |
composed |
Playing exit (visible = false) |
RESUMED |
still composed |
| The window is removed, for any reason | DESTROYED |
disposed |
Inside the content, run something when it shows and undo it when it hides:
FlyWindow("player", visibleWhen = FlyVisibility.Foreground) {
DisposableEffect(Unit) {
player.resume() // the window just showed
onDispose { player.pause() } // hidden for any reason: background, visible = false, overlay stopped...
}
// or with the lifecycle of the window (artifact androidx.lifecycle:lifecycle-runtime-compose)
LifecycleEventEffect(Lifecycle.Event.ON_DESTROY) { repo.saveProgress() }
}From outside the window, read the state of the window. It tells you if it is shown and why it is not:
val state = rememberFlyWindowState("counter")
Text(if (state.isShown) "The counter is floating" else "Hidden: ${state.hiddenReason}")
LaunchedEffect(state) {
snapshotFlow { state.isShown }.collect { shown -> if (!shown) repo.saveProgress() }
}hiddenReason is null while the window is shown, otherwise one of:
FlyHiddenReason |
|
|---|---|
Visibility |
a visibleWhen rule: the app or the owner is in the wrong state |
Invisible |
visible = false, after its exit |
Empty |
hideWhenEmpty and no item is contributed |
Dismissed |
a dismissal was committed and nobody handled onDismissRequest |
OverlayStopped |
the overlay stopped |
LeftComposition |
the FlyWindow call left the composition |
Good to know: the content is disposed when the window hides, so keep state in a ViewModel or a state holder. Nothing runs if the process is killed: onClosingSoon is the hint before, and onRemoved is only for visible = false.
placement |
|
|---|---|
Floating |
free window, drag and snap apply (default) |
Docked(edge, size, margin, insets) |
stuck to Top, Bottom, Start or End. size: Wrap, Fill, Fixed(dp), Fraction(f). Drag and snap are ignored, the floating position is kept |
Fullscreen |
covers the screen |
FlyWindow("note", focusable = true,
placement = FlyPlacement.Docked(FlyEdge.Bottom, FlySize.Fill),
ime = FlyIme.Avoid) // a bar above the keyboardime = Avoid: a docked-bottom or fullscreen window resizes, a floating one moves up while the keyboard shows (Android 11+). It needs focusable = true.
The window needs no activity and no service, it lives as long as the process. Without a service Android may kill the process in the background and the windows disappear. A foreground service keeps it alive:
class MyFlyService : FlyService() // body can be empty
FlyOverlay.configure(service = MyFlyService::class.java)<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<service android:name=".MyFlyService" android:exported="false" android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="shows a floating window" />
</service>- The service is not an owner: windows still hide with their
visibleWhen. UseAlwaysorBackgroundfor the ones that must stay. FlyOverlay.startthrowsOverlayPermissionExceptionif the permission is missing andFlyServiceStartExceptionif the system refuses the service (from the background on Android 12+). Start from a visible activity,FlyLaunchActivityis an invisible one for widgets, tiles and notification actions.- Closing soon: the window content gets
closingSoon(a state) andonClosingSoon:ProcessAtRiskwhen the system is short on memory and no service runs,OwnerStopping,HostStopping. It is a hint, a kill can come without warning.FlyOverlay.lastExitReason(context)tells why the previous process ended (Android 11+). - State holders:
rememberFlyViewModel { }lives for the window session. Do work that must finish after the window is gone insideFlyDismissEffect, beforedismiss(). - Requests from outside (a tile, a receiver) go through a
Channelandsend(not aSharedFlow, which drops values when nobody listens yet).
The math and the state machines have no Android dependency and run as plain JVM tests (fly_view/src/test): snapping and dismiss zone, gravity independent positions, placement and size, visibility rules, duplicate keys, the dismiss effect state machine, closing hints.
| Layer | Package | Tested with |
|---|---|---|
| Core (pure Kotlin) | com.abada.flyView.core |
JUnit + kotlinx-coroutines-test, no emulator |
| Windows (Compose + WindowManager) | com.abada.flyView.overlay |
on a device or emulator (compose-ui-test), the sample app is the manual test |
- The wrapper uses Gradle 9.8.0, and Gradle runs on JDK 21:
gradle/gradle-daemon-jvm.propertiesasks for it (Gradle Daemon toolchain) and the Foojay plugin insettings.gradledownloads it if it is not installed. Update it with./gradlew updateDaemonJvm --jvm-version=<n>. - AGP 9.4.0 with Kotlin 2.4.21: Kotlin is built into AGP 9, so the modules do not apply
kotlin-androidany more (kotlin { compilerOptions { } }replaceskotlinOptions). - The library keeps Java 17 bytecode, so apps that still build with JDK 17 can use it.
- Unit tests of the core:
./gradlew :fly_view:testDebugUnitTest.
| Problem | Fix |
|---|---|
OverlayPermissionException |
ask first: FlyOverlay.requestPermission(activity) |
FlyServiceStartException |
start from a visible activity (FlyLaunchActivity) |
FlyOverlayNotStartedException |
call FlyOverlay.start before a screen composes a FlyWindow |
Nothing shows after start |
the contribute block must show a window (check your state), and its visibleWhen must allow it |
| The window vanishes when the user leaves the app | check visibleWhen: a window a screen composes follows its screen by default, use Always. If the process itself is killed, add a FlyService |
| Back button goes to the overlay, not the app | focusable = false |
| The text field has no keyboard | focusable = true |
| A request is lost | use a Channel with send, not a SharedFlow |
| State resets when the window hides | content state is not kept across hide/show, keep it in a ViewModel or state holder |
v2 replaced the v1 API (FlyViews, FlyViewInfo, DraggableFlyView, FlyViewService, FlyController). Every old API and its replacement: docs/migration-v1-to-v2.md.
How it works inside, the decisions, and what is not verified yet: docs/architecture.md.
Designed and developed by 2023 AbdulAleem Alsayed
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.