android: adapt the layout to large windows and Android TV - #876
Open
leechristophermurray wants to merge 6 commits into
Open
leechristophermurray wants to merge 6 commits into
leechristophermurray wants to merge 6 commits into
Conversation
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>
leechristophermurray
force-pushed
the
feat/list-detail-layout
branch
from
September 18, 2026 00:03
272ec19 to
342a47d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
feat(android): show the tailnet in a list-detail layout on large windowsMainViewModelfeat(android): keep settings navigation inside the detail panefeat(android): draw list rows as cards like the platform doesListRow, one place that draws the app's rowsfeat(android): group settings rows and lift the selected row out of its runListGroupandRowPosition, for runs of related rowsfeat(android): give Android TV a home screen built for a remoteTvMainView, and the focus affordances a remote needsrefactor(android): lay the narrow window out as the list paneNotable choices, all argued in the commit bodies: the layout is a
Rowof weighted panes rather thanListDetailPaneScaffold, which would add three dependencies for a scaffold whose 360dp list pane we would then override; window width comes fromLocalConfigurationrather than aWindowSizeClasscalculation, for the same reason; and the TV screen usesfocusable()with an animated scale rather thanandroidx.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
make fmt-check,./gradlew testandscripts/check_license_headers.shpass.This is basically three changes in one PR, but it's a UI overhaul I thought to package together.