Skip to content

android: adapt the layout to large windows and Android TV - #876

Open
leechristophermurray wants to merge 6 commits into
tailscale:mainfrom
leechristophermurray:feat/list-detail-layout
Open

leechristophermurray wants to merge 6 commits into
tailscale:mainfrom
leechristophermurray:feat/list-detail-layout

Conversation

@leechristophermurray

@leechristophermurray leechristophermurray commented Sep 17, 2026

Copy link
Copy Markdown

What

Adapts the app to windows with room for more than one pane, and gives Android TV a home screen built for a remote.

Fixes tailscale/tailscale#21367

On an unfolded foldable or a tablet the tailnet now shows as a list with a detail pane beside it, split evenly. The list pane carries the controls the single pane layout kept in its header — the tailnet row, the VPN toggle, the exit node row — so each one opens in the detail pane rather than covering the list it acts on. Rows are drawn as the rounded cards the platform uses, grouped by section and separated by space rather than by hairlines, and the row whose detail is open is lifted out of its group as a lighter pill.

Android TV gets its own screen: the account avatar top left, search across the top, the tailnet switch and logo top right, settings bottom left, and the tailnet as a row of focusable cards per user in the middle. The 150dp letterbox is replaced by the 48×27dp overscan margin the platform asks for, so the UI fills the screen.

Why

The app navigated to a full screen detail view at every window size. On a 1006dp wide inner display that meant a screen of node detail beside a column of dead space, and the list was lost on every tap. On a television it meant a column of phone list rows in the middle of the screen, with the tailnet switch buried in a list item.

How it's structured

Six commits, each with its rationale in the message:

Commit Change
feat(android): show the tailnet in a list-detail layout on large windows The split itself, at the 600dp breakpoint, plus the pane target in MainViewModel
feat(android): keep settings navigation inside the detail pane The detail pane gets its own nav graph, so a settings subscreen replaces the pane instead of the window
feat(android): draw list rows as cards like the platform does ListRow, one place that draws the app's rows
feat(android): group settings rows and lift the selected row out of its run ListGroup and RowPosition, for runs of related rows
feat(android): give Android TV a home screen built for a remote TvMainView, and the focus affordances a remote needs
refactor(android): lay the narrow window out as the list pane One list layout across both window sizes

Notable choices, all argued in the commit bodies: the layout is a Row of weighted panes rather than ListDetailPaneScaffold, which would add three dependencies for a scaffold whose 360dp list pane we would then override; window width comes from LocalConfiguration rather than a WindowSizeClass calculation, for the same reason; and the TV screen uses focusable() with an animated scale rather than androidx.tv's Material components, which would mean a second Material theme to keep in step with the app's own.

Phones and folded foldables keep the single pane layout, and Android TV is chosen by the feature check the app already used for its other TV behaviour.

Testing

  • Pixel 11 Pro Fold, unfolded (1006 × 1043dp): the panes split evenly, the selected row reads as selected, the tailnet row opens settings and the exit node row opens the picker in the pane, and a settings subscreen opens inside the pane with a working back arrow.
  • Pixel 11 Pro Fold, folded: the single pane layout, now laid out as the list pane is.
  • Google TV Streamer (1920 × 1080 at 320dpi), driven over adb with D-pad events: the screen fills the display, the remote reaches the avatar, search, switch and settings in turn and each shows its focus, cards scroll sideways, the avatar opens the log out prompt, and settings opens with the exit node as its first entry.
  • make fmt-check, ./gradlew test and scripts/check_license_headers.sh pass.

This is basically three changes in one PR, but it's a UI overhaul I thought to package together.

01-unfolded-list-detail 02-unfolded-settings-in-pane 03-folded-compact-list 04-tv-home 05-tv-settings-exit-node 06-tv-log-out-prompt

@leechristophermurray leechristophermurray changed the title Feat/list detail layout android: adapt the layout to large windows and Android TV Sep 17, 2026
Unfolded foldables and tablets have the width to show a node and the list
it came from at once, but the app navigated to a full screen detail view
at every window size. On a 1006dp wide inner display that meant a screen
of node detail beside a column of dead space, and the list was lost on
every tap.

At 600dp and above - the medium window size class breakpoint - the node
list now splits evenly with a detail pane. Folded foldables, phones and
Android TV keep the single pane layout exactly as it was. The list pane
also takes over the controls the single pane layout keeps in its full
width header, the tailnet row, the VPN toggle and the exit node row, so
that nothing spans both panes and each control opens in the detail pane
rather than covering the list it acts on. That is the shape Android
Settings uses on the same devices.

Considered ListDetailPaneScaffold from material3-adaptive, but it pulls
in three dependencies for a scaffold whose default 360dp list pane we
would then override to get the even split asked for, so the layout is a
Row of weighted panes instead. Window width comes from LocalConfiguration
rather than a WindowSizeClass calculation for the same reason; the
activity already declares the relevant configChanges, so folding
recomposes in place instead of recreating.

MainViewModel now holds a DetailPane target rather than a selected node
id, so the pane can show node details, settings or the exit node picker.
The full screen route for each collapses into the pane when the window is
wide, which is also how deep links, search results and a screen that was
open while the device was unfolded end up beside the list, and expands
back to full screen when the window narrows, so folding mid task keeps
the user's place.

Two fixes fell out of this. backTo() falls back to a single step back,
because a screen reached from the detail pane never pushes its parent
route and popping to that parent would otherwise leave the back arrow
dead. UserView drops its tailnet line when it repeats the login name,
which a personal tailnet always does.

Tested by building libtailscale with gomobile and running the debug build
on a Pixel 11 Pro Fold. Unfolded at 1006dp x 1043dp the panes split
evenly, the selected row is highlighted, the tailnet row opens settings
and the exit node row opens the picker in the pane, and the login and
starting states still render their full width header. gradlew test,
compileDebugKotlin and ktfmtCheck pass. The fold and unfold handoff
between the pane and the full screen views was exercised on hardware.

Signed-off-by: Christopher Lee Murray <lee.christopher.murray@gmail.com>
Reviewed-by: Claude Opus 5 <noreply@anthropic.com>
Settings opens in the detail pane on a wide window, but every screen under
it navigated on the main graph, so tapping DNS settings or tailnet lock
covered the whole window and took the node list with it. The two pane
layout collapsed to one pane for as long as the user stayed in settings,
which is the problem the layout was meant to solve.

The detail pane now hosts a nav graph of its own, mirroring the settings
routes of the main graph. A subscreen replaces the pane's contents and
its back arrow pops within the pane. The system back gesture walks out of
the pane's stack before dismissing the pane, because the nested host
registers its callback after the main view's and the dispatcher runs the
innermost enabled callback first. The main graph keeps these routes for
single pane windows.

The exit node picker gets the same treatment, since its Mullvad and
run-as-exit-node screens covered the window the same way. Choosing an
exit node closes the pane, the pane equivalent of the "back home" the
picker does on a phone.

Considered extending the DetailPane target with an entry per settings
screen and rendering them straight from the main view, which avoids a
second nav host, but that reimplements the back stack navigation already
provides, including the two screens nested under permissions. A nested
NavHost is the documented way to give a detail pane its own history.

Tested on a Pixel 11 Pro Fold, unfolded: the user row opens settings in
the pane, tailnet lock opens inside the pane with a working back arrow,
the node list stays visible throughout, and a narrow window still
navigates full screen. gradlew compileDebugKotlin and ktfmtCheck pass.

Signed-off-by: Christopher Lee Murray <lee.christopher.murray@gmail.com>
Reviewed-by: Claude Opus 5 <noreply@anthropic.com>
Every list in the app was full width rows separated by hairlines, a
generation behind the system settings app that the list-detail layout now
sits beside, where rows are rounded cards over a recessed background and
groups are separated by space rather than by lines. Side by side on the
same screen the difference is what makes the app look unfinished.

Rows now go through ListRow, a thin wrapper over Material's ListItem that
adds the gutter, the gap and the corner radius, so the styling lives in
one place rather than at the forty call sites that draw a row. Lists are
painted in the background colour while rows keep the surface colour,
which is what makes a row read as a card. ItemDivider is now that gap
instead of a hairline, so the lists that structure themselves around it
keep working unchanged. Headers no longer paint a band of their own,
except the sticky header in the node list, which has to hide the rows
passing under it. The search field, the exit node card, the key expiry
banner and the Taildrop banner take the same radius, and the field is
held off the list below it by the space that separates one group of rows
from the next.

Considered editing each call site instead, which allows per screen
judgement but leaves forty places to keep in sync, and grouping a
section's rows into a single container, which needs every list to know
which row is first and last in its group. A card per row needs neither
and matches what the platform draws today.

Explanatory rows - Lists.InfoItem, MultilineDescription, the admin
console line in settings - lost their container, so they read as text on
the background rather than as rows that cannot be tapped.

Tested on a Pixel 11 Pro Fold, unfolded: both panes render as cards on
even gutters, the selected row still reads as selected, and the settings
tree, the node detail and the node list match each other. gradlew
compileDebugKotlin and ktfmtCheck pass. The single pane layout shares
these primitives and draws the same cards on a narrow window.

Signed-off-by: Christopher Lee Murray <lee.christopher.murray@gmail.com>
Reviewed-by: Claude Opus 5 <noreply@anthropic.com>
…ts run

Drawing every row as its own card reads as a list of unrelated things.
The system settings app holds a run of related entries in one card,
rounded at the top and bottom of the run, and lifts the entry whose
detail is on screen out of the run as a fully rounded pill.

ListGroup holds a run in one card for the lists laid out in a Column,
which is every settings screen: rows inside are flush with the card and
separated by the gap ItemDivider leaves. A lazy list cannot be held that
way without composing all of it, so ListRow also takes a RowPosition and
the node list gives each row its place in its user's run, rounding only
the corners at the ends. Either way a selected row draws its own pill,
which is what picks it out of the run.

Selection is a property of the row now rather than a colour the caller
passes in, so no caller can set the colour and forget the shape, and the
selected fill moved a step lighter - gray-600 in the dark theme, blue-50
in the light one - because against the card underneath it gray-700 was
hard to make out.

Considered passing each row a first/middle/last argument everywhere
instead of a container, but the settings screens hide rows behind MDM
policy and build flags, so which row ends a run is not known where the
rows are written. A container has nothing to compute and stays right as
rows come and go.

Tested on a Pixel 11 Pro Fold, unfolded: the settings pane draws its
groups rounded at the ends with hairline gaps inside, the node list draws
each user's nodes as one run, and the row whose detail is open draws as a
lighter pill. gradlew compileDebugKotlin and ktfmtCheck pass. The narrow
window layout shares all of this and draws the same runs.

Signed-off-by: Christopher Lee Murray <lee.christopher.murray@gmail.com>
Reviewed-by: Claude Opus 5 <noreply@anthropic.com>
The TV ran the phone's UI inside a 150dp letterbox: a column of list rows
in the middle of a television, two thirds of the screen unused, the
tailnet switch buried in a list item, and settings reachable only by
scrolling a list with a D-pad. It worked, but it looked like a phone app
someone had cast.

TvMainView replaces MainView on Android TV. It lays the controls around
the edges the way the platform's own apps do: the account avatar top
left, search across the top, the tailnet switch and the logo top right,
settings bottom left, and the tailnet itself as a row of cards per user
in the middle, which is the shelf shape of the Play Store and YouTube.
The cards take focus rather than touch, so they lift and take an accent
border when the remote lands on them, and the first card asks for focus
when the screen opens, because a remote with nothing focused does
nothing. The avatar opens a log out prompt, since on a TV it is the only
thing on screen that stands for the account.

The exit node moved into settings on TV, where the rest of the settings
already are, and the letterbox became the 48dp by 27dp margin the
platform asks for, so the UI fills the screen while staying clear of a
television's overscan. Phones and tablets are untouched: the TV screen is
chosen by the same feature check the app already used for its other TV
behaviour.

Two things a TV needs that the touch UI does not. A row now shows when
the D-pad is on it, because focus is the only pointer a remote has and
without it the settings screens gave no sign of where the remote was.
And the tailnet switch is wrapped in a focusable surface that draws the
accent ring, because a Material switch's own focus indication is a faint
halo that does not read from across a room.

Considered building this with androidx.tv's material components, which
would bring focus handling and cards for free, but it is three
dependencies and a second Material theme to keep in step with the one the
rest of the app uses, for a screen with four controls and a card.
Focusable() with an animated scale covers it.

Tested on a Google TV Streamer, 1920x1080 at 320dpi, driving it over adb
with D-pad events: the screen fills the display, the remote reaches the
avatar, search, switch and settings in turn and each shows its focus, the
cards scroll sideways with focus, the avatar opens the log out prompt with
cancel focused, and settings opens with the exit node as its first entry.
Three things only the hardware showed: cards were invisible until the
window's background stopped being the same colour as them, the cards were
a poster's height with nothing in them, and the search field filled the
bar rather than sitting beside the account. A Pixel 11 Pro Fold confirms
the touch layouts are unchanged, folded and unfolded. gradlew
compileDebugKotlin and ktfmtCheck pass.

Signed-off-by: Christopher Lee Murray <lee.christopher.murray@gmail.com>
Reviewed-by: Claude Opus 5 <noreply@anthropic.com>
The two layouts had drifted. On a wide window the tailnet row, the VPN
toggle and the exit node row are rows in the list; on a narrow one the
tailnet name, toggle and avatar were a full width header, with the exit
node card below it and search under that. Folding the device rearranged
the screen instead of just taking the detail pane away, which reads as two
designs rather than one.

TailnetList is now that list, and it is the whole screen on a narrow
window and the left pane of a wide one. Its rows take callbacks rather
than deciding for themselves what to open, so the same list hands a row
to the detail pane on a tablet and to a screen of its own on a phone. The
full width header is left to the states that are not a running tailnet,
where there is no list to put those controls in.

Considered keeping the phone's header, since a header is a denser way to
spend vertical space on a small screen, and it is: the three rows cost
about 90dp more than the header did. One design across both windows is
worth that.

Tested on a Pixel 11 Pro Fold, folded: the screen is search, the tailnet
row, the toggle, the exit node row and then the nodes, in the same order
and with the same cards as the list pane it shows unfolded. gradlew
compileDebugKotlin and ktfmtCheck pass.

Signed-off-by: Christopher Lee Murray <lee.christopher.murray@gmail.com>
Reviewed-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FR: android: adapt the layout to large windows and Android TV

1 participant