Skip to content

Commit cd6860d

Browse files
authored
Rearrange, hide, or float the navigation panels (#813)
Long-press the navigation container to enter edit mode: drag panels to reorder, tap a badge to hide or restore one. Hidden panels stay registered as destinations, since a saved back stack may still name them; only the container stops drawing them. One panel always remains visible. Works on both the bottom bar and the rail. Add an appearance-sheet option, off by default, that replaces the bar with a draggable ball. The navigation suite type becomes None, so the container is never laid out and the strip it costs every screen returns as content. Long-press the ball to fan the panels into an arc and release on one; a plain tap latches the arc open so each panel is an ordinary target, which is the path a screen reader can use. Drawn inside the app window, never a system overlay -- the parasitic manager runs as com.android.shell and must not request SYSTEM_ALERT_WINDOW. The arrangement persists as one ordered string of route keys, hidden ones flagged, tolerant of unknown or duplicate entries. The ball's edge and height persist likewise; ball and arc are positioned in absolute window coordinates so both render correctly under RTL. Sixteen strings across eighteen locales.
1 parent b5bce6a commit cd6860d

27 files changed

Lines changed: 1808 additions & 45 deletions

File tree

manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,69 @@ class SettingsRepository(context: Context) {
357357
_logTracesInline.value = inline
358358
}
359359

360+
// --- Navigation panels ---
361+
362+
/**
363+
* Which panels the navigation container shows, in which order, and which are hidden.
364+
*
365+
* One delimited string rather than a set: `putStringSet` does not preserve order — the same
366+
* fact `muted_updates` above relies on being harmless — and here the order is the whole point.
367+
* Route keys rather than ordinals or class names, because R8 rewrites class names in a release
368+
* build and an ordinal would silently mean a different panel the day a fifth one is added.
369+
* Empty means "the catalogue as declared", which is what a fresh install has and what anyone
370+
* who has never opened edit mode keeps. See NavPanels for the format.
371+
*/
372+
private val _navPanels = MutableStateFlow(prefs.getString("nav_panels", "") ?: "")
373+
val navPanels: StateFlow<String> = _navPanels.asStateFlow()
374+
375+
fun setNavPanels(encoded: String) {
376+
prefs.edit().putString("nav_panels", encoded).apply()
377+
_navPanels.value = encoded
378+
}
379+
380+
/**
381+
* Whether the panels live on a draggable ball over the content instead of in a bar or a rail.
382+
*
383+
* Off by default: the bar is what every other app on the device puts there, and a reader who
384+
* has not asked for anything else should not have to work out where their panels went. It is
385+
* offered at all because the bar costs a strip of every screen for four items that are rarely
386+
* touched, and on a small phone reading a log that strip is the expensive part.
387+
*/
388+
private val _floatingNav = MutableStateFlow(prefs.getBoolean("floating_nav", false))
389+
val floatingNav: StateFlow<Boolean> = _floatingNav.asStateFlow()
390+
391+
fun setFloatingNav(enabled: Boolean) {
392+
prefs.edit().putBoolean("floating_nav", enabled).apply()
393+
_floatingNav.value = enabled
394+
}
395+
396+
/**
397+
* Where the floating ball was left: which side it snapped to, and how far down it sits as a
398+
* fraction of the window height.
399+
*
400+
* No flow, for the same reason the ambience adjustments have none: written straight through
401+
* from a gesture and read once when the ball is composed, so a StateFlow would recompose the
402+
* very thing being dragged on every frame of the drag. Persisted rather than remembered because
403+
* somebody who moved the ball out of the way of what they were reading has made a decision
404+
* about their thumb, and the host process is killed often enough that anything held in memory
405+
* would put the ball back over the content within the hour.
406+
*
407+
* The side is stored, not the x position: the ball always snaps to an edge, so a coordinate
408+
* would be a lie the moment the window is a different width — which, unfoldable and in
409+
* landscape, it routinely is.
410+
*/
411+
fun floatingNavAtEnd(): Boolean = prefs.getBoolean("floating_nav_at_end", true)
412+
413+
fun setFloatingNavAtEnd(atEnd: Boolean) {
414+
prefs.edit().putBoolean("floating_nav_at_end", atEnd).apply()
415+
}
416+
417+
fun floatingNavY(): Float = prefs.getFloat("floating_nav_y", 0.72f)
418+
419+
fun setFloatingNavY(fraction: Float) {
420+
prefs.edit().putFloat("floating_nav_y", fraction).apply()
421+
}
422+
360423
fun setThemeMode(mode: String) {
361424
prefs.edit().putString("theme_mode", mode).apply()
362425
_themeMode.value = mode

manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt

Lines changed: 91 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,44 @@
11
package org.matrix.vector.manager.ui
22

3-
import androidx.compose.material3.Icon
4-
import androidx.compose.material3.Text
3+
import androidx.activity.compose.BackHandler
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
57
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
8+
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
9+
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
610
import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState
711
import androidx.compose.runtime.Composable
812
import androidx.compose.runtime.CompositionLocalProvider
913
import androidx.compose.runtime.LaunchedEffect
1014
import androidx.compose.runtime.getValue
11-
import androidx.compose.ui.res.stringResource
15+
import androidx.compose.ui.Modifier
1216
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1317
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
1418
import androidx.navigation3.runtime.EntryProviderScope
1519
import androidx.navigation3.runtime.NavKey
1620
import androidx.navigation3.runtime.entryProvider
1721
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
1822
import androidx.navigation3.ui.NavDisplay
23+
import org.matrix.vector.manager.di.ServiceLocator
1924
import org.matrix.vector.manager.ui.navigation.FrameworkUpdate
2025
import org.matrix.vector.manager.ui.screens.update.FrameworkUpdateScreen
2126
import org.matrix.vector.manager.ui.navigation.Canary
2227
import org.matrix.vector.manager.ui.screens.canary.CanaryScreen
2328
import org.matrix.vector.manager.ui.navigation.Troubleshoot
2429
import org.matrix.vector.manager.ui.screens.report.TroubleshootScreen
2530
import org.matrix.vector.manager.ui.navigation.DeepLink
31+
import org.matrix.vector.manager.ui.navigation.FloatingPanelNav
2632
import org.matrix.vector.manager.ui.navigation.LocalNavigator
2733
import org.matrix.vector.manager.ui.navigation.Navigator
34+
import org.matrix.vector.manager.ui.navigation.PanelBar
35+
import org.matrix.vector.manager.ui.navigation.PanelEditDone
2836
import org.matrix.vector.manager.ui.navigation.Scope
2937
import org.matrix.vector.manager.ui.navigation.StoreDetail
3038
import org.matrix.vector.manager.ui.navigation.CrashTrace
3139
import org.matrix.vector.manager.ui.navigation.LogTrace
3240
import org.matrix.vector.manager.ui.navigation.SystemStatus
3341
import org.matrix.vector.manager.ui.navigation.Web
34-
import org.matrix.vector.manager.ui.navigation.TOP_LEVEL_DESTINATIONS
3542
import org.matrix.vector.manager.ui.navigation.TopLevelRoute
3643
import org.matrix.vector.manager.ui.navigation.rememberNavigator
3744
import org.matrix.vector.manager.ui.screens.home.HomeScreen
@@ -53,6 +60,11 @@ import org.matrix.vector.manager.ui.screens.web.WebScreen
5360
* no longer lock itself to portrait or declare itself non-resizable on large screens, so the shell
5461
* has to work unfolded and in landscape regardless. The scaffold also owns where that container
5562
* sits, so the destinations below it are laid out beside or above it rather than under it.
63+
*
64+
* Which panels that container holds, in which order, is the reader's — see NavPanels — and there is
65+
* a third arrangement it can take, a ball floating over the content with no container at all. The
66+
* two are not independent: rearranging the panels needs something to rearrange, so edit mode always
67+
* puts the container back for as long as it lasts.
5668
*/
5769
@Composable
5870
fun VectorApp() {
@@ -82,7 +94,10 @@ fun VectorApp() {
8294
}
8395

8496
CompositionLocalProvider(LocalNavigator provides navigator) {
85-
// The bar shows only at the root of a tab. On a detail screen none of the four items is
97+
val settings = ServiceLocator.settings
98+
val floating by settings.floatingNav.collectAsStateWithLifecycle()
99+
val editing = navigator.editingPanels
100+
// The container shows only at the root of a panel. On a detail screen none of the items is
86101
// the current destination, and a navigation bar highlighting nothing is worse than none.
87102
val atRoot = !navigator.canGoBack
88103

@@ -92,42 +107,85 @@ fun VectorApp() {
92107
val suiteState = rememberNavigationSuiteScaffoldState()
93108
LaunchedEffect(atRoot) { if (atRoot) suiteState.show() else suiteState.hide() }
94109

110+
// Computed rather than left to the scaffold's default, for two reasons: the floating style
111+
// forces None, which is what actually removes the container instead of hiding it, and
112+
// PanelBar has to be told which axis it is laying items along. Entering edit mode overrules
113+
// the floating setting for as long as it lasts — there is nothing to rearrange otherwise.
114+
val suiteType =
115+
if (floating && !editing) NavigationSuiteType.None
116+
else NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
117+
95118
NavigationSuiteScaffold(
119+
navigationItems = {
120+
// NavigationSuite's `when` over the type has no None branch and no else, so under
121+
// None this slot is silently dropped along with the container. Skipping it here
122+
// says so out loud rather than leaving a composable that never runs.
123+
if (suiteType != NavigationSuiteType.None) {
124+
PanelBar(
125+
panels = navigator.panels,
126+
current = navigator.currentTopLevel,
127+
editing = editing,
128+
suiteType = suiteType,
129+
onSelect = { route -> navigator.switchTo(route) },
130+
onEdit = { navigator.editingPanels = true },
131+
onToggleHidden = { key, hidden -> navigator.setPanelHidden(key, hidden) },
132+
onMove = { from, to -> navigator.movePanel(from, to) },
133+
)
134+
}
135+
},
136+
navigationSuiteType = suiteType,
96137
state = suiteState,
97-
navigationSuiteItems = {
98-
TOP_LEVEL_DESTINATIONS.forEach { destination ->
99-
item(
100-
selected = navigator.currentTopLevel == destination.route,
101-
onClick = { navigator.switchTo(destination.route) },
102-
icon = { Icon(destination.icon, contentDescription = null) },
103-
// The label doubles as the item's accessibility name, so the icon above
104-
// carries no contentDescription of its own — otherwise TalkBack announces
105-
// every selected tab twice.
106-
label = { Text(stringResource(destination.labelRes)) },
138+
primaryActionContent = {
139+
if (editing) PanelEditDone(onDone = { navigator.editingPanels = false })
140+
},
141+
) {
142+
Box(Modifier.fillMaxSize()) {
143+
NavDisplay(
144+
backStack = navigator.backStack,
145+
onBack = { navigator.back() },
146+
// Naming any decorator replaces NavDisplay's default, which is the
147+
// saveable-state one alone, so it is repeated here; the scene-setup decorator
148+
// NavDisplay applies internally is untouched. The ViewModel one is what this
149+
// list is for: it scopes a ViewModelStore per entry, so opening the scope
150+
// editor for a second module builds a second ViewModel instead of reusing the
151+
// first (they would otherwise share one default key under the activity's
152+
// store).
153+
entryDecorators =
154+
listOf(
155+
rememberSaveableStateHolderNavEntryDecorator(),
156+
rememberViewModelStoreNavEntryDecorator(),
157+
),
158+
entryProvider = entryProvider { registerRoutes(navigator) },
107159
)
160+
// Last child of the Box so it draws over the destination, and inside the app window
161+
// rather than in one of its own: parasitically this app is com.android.shell, which
162+
// must never ask for SYSTEM_ALERT_WINDOW. It follows the same rule the container
163+
// does — present at the root of a panel, gone on a detail screen that has its own
164+
// back affordance.
165+
if (floating && !editing && atRoot) {
166+
FloatingPanelNav(
167+
panels = navigator.panels,
168+
current = navigator.currentTopLevel,
169+
onSelect = { route -> navigator.switchTo(route) },
170+
)
171+
}
108172
}
109-
},
110-
) {
111-
NavDisplay(
112-
backStack = navigator.backStack,
113-
onBack = { navigator.back() },
114-
// Naming any decorator replaces NavDisplay's default, which is the saveable-state
115-
// one alone, so it is repeated here; the scene-setup decorator NavDisplay applies
116-
// internally is untouched. The ViewModel one is what this list is for: it scopes a
117-
// ViewModelStore per entry, so opening the scope editor for a second module builds
118-
// a second ViewModel instead of reusing the first (they would otherwise share one
119-
// default key under the activity's store).
120-
entryDecorators =
121-
listOf(
122-
rememberSaveableStateHolderNavEntryDecorator(),
123-
rememberViewModelStoreNavEntryDecorator(),
124-
),
125-
entryProvider = entryProvider { registerRoutes(navigator) },
126-
)
127173
}
174+
175+
// After the scaffold on purpose. Back callbacks are dispatched last-registered-first and
176+
// BackHandler registers from an effect, which run in composition order, so this one
177+
// outranks the handler NavDisplay installs and edit mode ends before the stack is touched.
178+
BackHandler(enabled = editing) { navigator.editingPanels = false }
128179
}
129180
}
130181

182+
/**
183+
* Every destination, registered.
184+
*
185+
* All four panels keep their entry whether or not the reader has hidden them. A saved stack names
186+
* its keys by class, and entryProvider throws for one it was never given, so dropping the
187+
* registration of a hidden panel would turn a stale saved stack into a crash.
188+
*/
131189
private fun EntryProviderScope<NavKey>.registerRoutes(navigator: Navigator) {
132190
entry<TopLevelRoute.Home> {
133191
HomeScreen(

0 commit comments

Comments
 (0)