An external display is a second window, not a bigger one

Android 16 makes external displays a mainstream concern. The layout work is mostly window size classes you already have; the new parts are density, input capability and deciding what belongs on which screen.

6 min read
androidcomposekotlinadaptive

Day 34 — An external display is a second window, not a bigger one

Day 34 of 100, closing the adaptive pillar. Desktop windowing and connected displays moved from a niche concern to a platform feature, and the good news is that most of the work is already done if you followed Day 29.

The symptom

An app that behaves perfectly on a tablet and awkwardly on a monitor:

Button(
    onClick = { … },
    modifier = Modifier.size(56.dp),        // a comfortable thumb target
) { Icon(Icons.Default.Add, null) }

On a 27-inch display driven by a mouse, a 56dp button is enormous and the cursor is precise to a pixel. Meanwhile the text you sized for arm's-length phone reading is small at desk distance, and the two-pane layout that filled a tablet has a third of the screen of empty space in the middle.

Nothing is broken. It just looks like a phone app that was stretched.

Why the obvious fix fails

The obvious fix is another size-class branch:

val size = if (widthClass == EXPANDED) 36.dp else 56.dp

Width is a poor proxy for input method. A tablet in landscape is also EXPANDED and is still touched with fingers, so this shrinks the buttons on a device where they need to stay big. And a folded phone connected to a monitor puts your app on a large display while the phone's window is still compact.

Size tells you about room. It tells you nothing about how the user is pointing at things.

A connected display is a separate window with its own size, density and input capability

The actual mechanism

Three properties vary independently, and treating them as one is the mistake:

Window sizecurrentWindowAdaptiveInfo(), unchanged from Day 29. This drives layout structure, and an external display is simply a large window.

Density — a monitor typically has lower pixel density and greater viewing distance. LocalDensity reports it; the practical effect is that dp is doing its job and you mostly shouldn't fight it.

Input capability — the actually-new question:

val config = LocalConfiguration.current
val hasPointer = config.touchscreen == Configuration.TOUCHSCREEN_NOTOUCH
val hasKeyboard = config.keyboard == Configuration.KEYBOARD_QWERTY

That's the property worth branching on for target sizing, hover states and keyboard shortcuts — not width.

Compose already handles the interaction side once you use the standard modifiers. Modifier.clickable provides hover and focus states to any Indication that supports them, so a mouse gets hover feedback for free. Modifier.hoverable and onPointerEvent(PointerEventType.Enter) give finer control when a component needs it.

Multi-window is the model

Android 16's desktop windowing means your activity can be resized freely, and connected displays mean it can be moved between screens. Both are configuration changes, both recompose, and both are already handled if size classes drive your layout.

The genuinely new decision is whether your app should present different content on a second display rather than the same content larger. Presentation APIs support that — a slideshow on the projector with notes on the phone, a game on the TV with controls in hand — and it's a product decision rather than a layout one.

For most apps the answer is no: one window, sized by its class, on whichever display it happens to be. That's the default and it's usually right.

What actually needs doing

A short and finite list, which is the reassuring part:

Keyboard navigation. Tab order and Enter/Space activation. Modifier.focusable() and a sensible focusGroup structure — and this is accessibility work you should be doing anyway, which is Day 67.

Hover states. Free with clickable, worth checking on components that use a custom Indication.

Right-click where it means something: Modifier.onPointerEvent filtering for PointerButton.Secondary.

Text selection. SelectionContainer around content a desktop user will expect to be selectable. On a phone almost nobody tries; on a monitor everybody does.

Window resize without jank. Your layout recomposes on every resize frame while a window is dragged, so an expensive composition is visible as lag in a way it never was on a phone. Day 7 and Day 10 pay off here.

The thing that breaks

The one genuine trap: cached configuration read once.

// Wrong — captured on first composition, never updated
val isLarge = remember { widthClass == EXPANDED }

On a phone this is nearly harmless, since the value changes only on rotation. Drag a window across a display boundary and it's wrong for the rest of the session. Read adaptive info during composition and let recomposition do its job — remember it only with the value as a key, as in Day 29.

How to prove it

Desktop windowing is testable without hardware: enable it in Developer Options on a Android 16 device or emulator, then drag your app's window across the full range of widths. The layout should change at 600 and 840 and never flicker between.

For input capability, an emulator with a mouse gives you hover and right-click. For keyboard, tab through an entire screen and confirm every interactive element is reachable and activates on Enter — if that fails, it fails for screen-reader users too.

The resize-jank check is the one that finds real problems:

adb shell wm size 1280x800    # then drag the window and watch for dropped frames

If dragging is smooth, composition is cheap. If it stutters, something expensive is running per frame, and it was probably stuttering on scroll too.

The activity flags that still matter

Two manifest details decide whether the platform even lets your app behave well, and both predate Compose:

<activity
    android:resizeableActivity="true"
    android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />

resizeableActivity="false" opts you out of multi-window entirely — the system letterboxes your app rather than resizing it, and no amount of adaptive Compose code runs. It's a legacy escape hatch and it reads to users as an app that doesn't work on their device.

configChanges is the more nuanced one. Declaring it avoids activity recreation on resize, which makes window dragging smoother. It is not a substitute for state that survives — process death still happens — so treat it as a performance option on top of correct state handling, never instead of it.

What this generalizes to

The pillar's conclusion: stop asking what device this is. Ask how much room there is, what the user is pointing with, and where the awkward physical regions are. Those three questions cover phones, tablets, foldables, desktops and monitors, and they will cover whatever comes next, because they describe the situation rather than the hardware.

That's the through-line of six days — isTablet() was never really asking about tablets, it was asking about room, badly.

It is also why the adaptive work compounds rather than accumulating. Size classes bought the pane scaffolds; the pane scaffolds bought hinge placement; the state discipline that survives a fold is the same discipline that survives process death. Six days of adaptive support turns out to be four days of ordinary good practice and two days of genuinely new API. Tomorrow, Day 35 begins the components pillar with Scaffold and the app-shell structure everything else sits inside.


Day 34 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Connected displays.