Skip to content

Arrange the navigation panels, and optionally do without them - #813

Merged
JingMatrix merged 3 commits into
masterfrom
nav-panels
Aug 1, 2026
Merged

Arrange the navigation panels, and optionally do without them#813
JingMatrix merged 3 commits into
masterfrom
nav-panels

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Two commits:

  • Let the reader arrange the navigation panels — long-press the navigation container to reorder or hide panels.
  • Offer a floating ball in place of the navigation bar — an appearance-sheet option, off by default.

The second is separable: the first builds without FloatingPanelNav.kt, and reverting the second lands on the first with no conflicts.

Arranging

Long-press any item to enter edit mode. Each panel gains a badge that hides or restores it; dragging reorders. All four are shown while editing, hidden ones dimmed — the only way to restore one. The last visible panel gets no hide badge, so one always remains. Works on the bar and the rail.

The appearance sheet gains a way in, since nothing on Android teaches that a navigation bar can be long-pressed.

Floating ball

The suite type becomes NavigationSuiteType.None, so the container is never laid out and the strip it costs every screen returns as content. Long-press fans the visible panels into an arc, dragging highlights one, releasing navigates. A tap opens the same arc latched so each panel is an ordinary tappable target — required, as drag-to-select is invisible to TalkBack.

Drawn inside the app window, never a system overlay: parasitically this app is com.android.shell, which must not request SYSTEM_ALERT_WINDOW.

Notes

Stored as one string of route keys with ! marking hidden — logs,home,!store,modules. Not a StringSet, which does not preserve order; not ordinals or class names, since R8 rewrites class names and an ordinal shifts once a fifth panel is added. decodeNavPanels is total: unknown and duplicate keys are dropped, unnamed destinations appended visible.

Hiding is not deletion. NavKeySerializer resolves keys with a bare Class.forName and entryProvider throws for one it was never given, so a back stack saved before a panel was hidden would crash. Every route stays registered.

The start destination, the highlight fallback and a restored root are now the first visible panel. currentTopLevel keeps its own visibility test alongside reconcilePanels, as hiding the current panel recomposes the container a frame before the effect runs.

Three non-obvious points:

  • combinedClickable cannot carry the long press. ShortNavigationBarItem applies selectable() internally and handles the release first, so a long press would also switch tab. A pointer-input state machine on the slot watches the Initial pass instead.
  • The reorder measures from onGloballyPositioned rather than item size, as WideNavigationRail folds inter-item spacing into each item's size.
  • The dragged item uses absoluteOffset; offset mirrors under RTL, which values-ar makes reachable.

Testing

Pixel 7a emulator, API 36, via the debug demo host. Edit mode, hide, restore, reorder on both axes, the minimum-one-panel rule, hiding the current panel, back leaving edit mode, and the arrangement surviving a force-stop. For the ball: the latched arc, hold-and-drag selection, and arc bounds in landscape.

The rail required forcing navigationSuiteType in a throwaway build, as currentWindowAdaptiveInfo() ignores wm size/wm density overrides — the scaffold still selected a bar at w1200dp-h1800dp. Rail layout, edit mode and vertical drag are confirmed; the automatic switch on real large-screen hardware is untested.

@JingMatrix

Copy link
Copy Markdown
Owner Author

Left undone:

The arc labels have no backdrop and are hard to read over busy content. A scrim behind the arc, or a pill behind each label, would fix it.

decodeNavPanels has no unit tests. It is pure, and it is the part most likely to break quietly once a fifth panel is added.

On #532: hiding the Store panel is not the option requested there. It removes the panel from the navigation, but the store still exists, update checking still runs, and the activity feed and framework update check still reach the network. Supporting a second repository is not planned at the moment, though not ruled out later. This does not close it.

The four panels have been in the same order, all of them always shown, since
the bar was written. That order is a guess about how someone uses the app, and
it is wrong for anyone who lives in the log viewer or has never opened the
store. Long-pressing any item now enters an edit mode: every panel gains a
badge in its top corner that hides or restores it, and dragging an item
reorders it, the way icons are arranged on a launcher.

All four are shown while editing, hidden ones dimmed, because that is the only
way back for a panel that has been hidden -- an edit mode that showed only what
is already visible would be a one-way door. The badge is withheld entirely from
the last visible panel rather than disabled, so there is never a control that
does nothing; NavPanels.canHide answers that question once for the badge, the
screen-reader action and anything added later.

Hiding is not deletion, and the distinction is load-bearing. NavKeySerializer
resolves a persisted key with a bare Class.forName and NavDisplay's
entryProvider throws for a key it was never given, so a back stack saved before
a panel was hidden would take the app down rather than show a stale tab. Every
route stays declared and stays registered; only the container changes.

The arrangement is stored as one delimited string of route keys with '!'
marking hidden -- "logs,home,!store,modules". A string rather than a set
because putStringSet does not preserve order and the order is the point, and
keys rather than ordinals or class names because R8 rewrites class names in a
release build and an ordinal would silently name a different panel the day a
fifth one is declared. decodeNavPanels is total: unknown and duplicate keys are
dropped, and any destination the string does not name is appended visible, so a
panel added next year still appears for the readers who arranged theirs today.

Three assumptions about Home had to go with it. The start destination, the
highlighted item's fallback and a restored root are now the first *visible*
panel, and reconcilePanels corrects a root naming a panel that is no longer
drawn. currentTopLevel keeps its own visibility test even so: hiding the panel
you are standing on recomposes the container a frame before the effect runs,
and a bar highlighting nothing is worse than one highlighting where you are
about to be.

The appearance sheet gains a way in, because nothing on Android teaches that a
navigation bar can be long-pressed at all. The arrangement itself stays on the
container, where a drag can be watched.

Three things the implementation could not do the obvious way:

- combinedClickable cannot carry the long press. ShortNavigationBarItem applies
  selectable() internally, so the item is the nearer pointer node and handles
  the release first: a long press on Logs would open edit mode *and* switch to
  Logs. A pointer-input state machine on the slot watches the Initial pass
  instead, consuming nothing while the press might still be a tap and taking
  the gesture the moment the hold lands.

- The reorder measures from onGloballyPositioned rather than item size.
  WideNavigationRail folds the inter-item spacing into each item's size, so on
  the rail axis every shift would lag the finger by that gap.

- The dragged item uses absoluteOffset. Recorded positions and drag deltas both
  count pixels rightwards, and offset mirrors under RTL -- which values-ar
  makes reachable.
An option in the appearance sheet, off by default. With it on there is no bar
and no rail: the suite type becomes NavigationSuiteType.None, which is the part
that makes this worth having at all -- the container is not merely hidden but
never laid out, so the strip it costs every screen comes back as content. On a
small phone reading a log, that strip is the expensive part.

Long-pressing the ball fans the visible panels into an arc around it; keep
dragging to highlight one and release to go there. A plain tap opens the same
arc latched, so each panel is then an ordinary tappable target -- that is the
accessible path, and it is not decoration: a drag-to-select gesture is
invisible to TalkBack, so without it the whole style would be unreachable for
anyone using a screen reader.

It is an ordinary composable drawn over the destination, inside the app window.
Never a system overlay: parasitically this app *is* com.android.shell, and
asking for SYSTEM_ALERT_WINDOW from there is not something we should ever do.
It follows the rule the container already follows -- present at the root of a
panel, gone on a detail screen, which has its own back affordance.

The arc opens inward from whichever edge the ball is parked on and stays inside
the window, including at the corners. Only the side and a fractional height are
persisted, not a coordinate: the ball always snaps to an edge, so an x position
would be a lie the moment the window is a different width, which unfolded and
in landscape it routinely is.

Every coordinate here is counted from the left of the window, because that is
what the insets, the constraints and a pointer's own position are counted from,
so the placement has to be absolute as well: Alignment.TopStart puts a child
against the right edge under RTL and Modifier.offset negates its x there, and
values-ar makes both reachable. Mirrored, the ball is drawn against the edge
opposite the one it is parked at, the arc is drawn a panel's width off the ball
it belongs to, and the finger picks by pointing away from what it is over.
Which side the ball parks on is decided once, by atEnd against the layout
direction, and the layout is not allowed a second opinion.

Edit mode overrules the setting for as long as it lasts, since there is nothing
to rearrange otherwise, and the sheet's rearrange row stops being merely a
discoverability aid and becomes the only way in -- there is no bar left to
long-press.
Twelve units across eighteen languages: the four spoken labels a rearrange
needs -- the hide and show badges, the two moves a screen reader makes in
place of a drag -- the ball's own open and close, the edit-mode Done, and the
five that describe all of it in the appearance sheet.

The terms follow what each translator already chose in these files rather than
being coined again: the word for the bar is theirs, and the panels take the
word that file uses for a section of the app, which is not always the English
one. Russian and Ukrainian keep the two apart deliberately -- панель is what
they call the bar, so the panels are разделы and розділи, or the summary would
be replacing a thing with itself. The hide and show badges quote the panel
name in those two, since a bare label in the nominative reads wrongly after
their verbs, and Turkish takes the suffix-free 'öğesini' form for the same
reason.
@JingMatrix
JingMatrix merged commit cd6860d into master Aug 1, 2026
1 check passed
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.

Enhancement: Option to use an alternative module repository or completely disable online repository access

1 participant