compose-popupbar 0.1: a popup player bar for Jetpack Compose

If you have used Swift Radio on iOS, you know the bar at the bottom of the station list. Tap it or swipe up and it grows into the player. Swipe down and it shrinks back. That has been LNPopupController's job since it joined Swift Radio a few versions in, and the v3 post covers how it fits.

When I ported the app to Android, Jetpack Compose had nothing like it. So I shipped what Material offers: a mini player laid over a bottom sheet. It worked, but the two never knew about each other. Tap the mini player and a sheet slides up from somewhere else. Drag the sheet and the mini player just sits there. Two components, two sets of gestures, and no moment where one becomes the other.

I wanted the bar to become the player. That is what compose-popupbar does. Version 0.1.0 is out, MIT licensed, and it now powers the now playing bar in Swift Radio Android 1.2.0. The next FRadio DZ update runs on it too.

Swift Radio Android station list with the now playing bar floating above the bottom edge, showing the track title, artists, and album art

Collapsed: the bar floats over the station list.

Swift Radio Android player expanded from the bar, with the album art, track title, station name, LIVE badge, and transport controls

Expanded: the same surface, grown into the player.

What it does

The split I settled on is the one LNPopupController taught me: the library owns presentation and gestures, the app owns every pixel of content. You hand it a bar, a full-screen composable, and optionally the artwork. It handles the rest.

  • Interruptible morph: the bar and the full-screen content follow the finger. Let go half way and it settles to the nearest state. Predictive back peels it closed.
  • Shared artwork: one composable travels between the bar thumbnail and the expanded slot, so the cover grows instead of cross-fading.
  • Four bar styles: floating, floating compact, prominent, and compact. One parameter on the host.
  • Four interaction styles: drag, snap, nested scroll for long content, or none for taps and buttons only.
  • Progress strip: an optional hairline along the bar's edge, with drag-to-seek when you pass a seek callback.
  • State API: present(), expand(), collapse(), hide(), plus continuous progress and presentation fractions to drive your own animations.
  • Accessibility and RTL: the bar is one merged node, content behind the expanded popup is hidden from TalkBack, close controls are localized, and the layout mirrors.
The sample app dragging a floating popup bar open into a full-screen player and back, then presenting it again after picking another track

The sample app, recorded on a Pixel.

Bar styles

The four styles mirror the shapes LNPopupController offers. All of them dock above the same bottom navigation, and the morph and the gestures do not change between them.

Floating style: a 64 dp rounded card with side and bottom margins above the navigation bar

Floating, the default.

Floating compact style: a shorter 48 dp rounded card above the navigation bar

Floating compact.

Prominent style: a 64 dp edge-to-edge bar flush with the navigation bar

Prominent.

Compact style: a 40 dp edge-to-edge bar with small artwork flush with the navigation bar

Compact.

Using it

Two Gradle blocks and one composable. The library is on JitPack:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        maven("https://jitpack.io")
    }
}

// build.gradle.kts
dependencies {
    implementation("com.github.fethica:compose-popupbar:0.1.0")
}

PopupHost wraps your screen. It takes the bottom bar, the popup bar, the expanded content, and hands your screen a bottom padding so lists can scroll out from under the bar.

@Composable
fun PlayerScreen() {
    val popupState = rememberPopupState(PopupValue.Collapsed)

    PopupHost(
        state = popupState,
        bottomBar = { NavigationBar { /* items */ } },
        popupBar = {
            PopupBar(title = "Title", subtitle = "Subtitle")
        },
        popupContent = {
            Box(Modifier.fillMaxSize()) {
                // Full-screen player content
            }
        },
    ) { padding ->
        LazyColumn(
            contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
        ) {
            // App screen content
        }
    }
}

The bar expands on tap. Call the state methods from your own UI or app state to present, expand, collapse, or hide it. The option matrix, insets, shared artwork, and the composite-build setup are in the documentation.

Two things I got wrong first

The artwork loaded twice. The old mini player and the sheet each loaded the cover, and the two cross-faded on the way up. It looked like two things swapping, not one thing growing. Now the host composes the artwork once and moves that single composable between the two slots. That brought its own surprise: Coil sizes an image request from the first measurement it sees, which is the 48 dp thumbnail, so the expanded cover came out blurry. The fix is to request the image at its original size and let the thumbnail downscale from it. The docs call it out for that reason.

The insets fought each other. The host hands your screen a bottom padding only: the docking bar, plus the popup bar and its margins once it is presented. If your screen sits in its own Scaffold, that scaffold also pads for the navigation bar by default, and you get a band of container color under the floating bar. Turn the scaffold's bottom inset off and apply the host's padding as content padding on the list. The list then scrolls under the bar and under the gesture area, which is the whole point of a floating bar.

In Swift Radio Android

The port removed more code than it added. The MiniPlayer composable and the BottomSheetScaffold around the station list are gone. One PopupHost owns the bar and the player, the station list takes its bottom padding from the host, and the artwork travels from the bar into the player.

If you run a fork, pull the 1.2.0 tag. The bar style is one parameter on PopupHost if you prefer the edge-to-edge look, and everything else about your stations.json, Config.kt, and theme stays as it was.

Not yet

Two things I want and have not built:

  • Paging: swiping the bar to the previous or next item.
  • Translucent backgrounds: the card is an opaque surface for now.

The release has the full changelog. If you are building a player in Compose and the bar does not fit your case, open an issue or write to me. I want to know what you needed.