Skip to content

Commit 8c13713

Browse files
committed
Offer a floating ball in place of the navigation bar
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. 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.
1 parent 69a0c1d commit 8c13713

6 files changed

Lines changed: 691 additions & 32 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,49 @@ class SettingsRepository(context: Context) {
297297
_navPanels.value = encoded
298298
}
299299

300+
/**
301+
* Whether the panels live on a draggable ball over the content instead of in a bar or a rail.
302+
*
303+
* Off by default: the bar is what every other app on the device puts there, and a reader who
304+
* has not asked for anything else should not have to work out where their panels went. It is
305+
* offered at all because the bar costs a strip of every screen for four items that are rarely
306+
* touched, and on a small phone reading a log that strip is the expensive part.
307+
*/
308+
private val _floatingNav = MutableStateFlow(prefs.getBoolean("floating_nav", false))
309+
val floatingNav: StateFlow<Boolean> = _floatingNav.asStateFlow()
310+
311+
fun setFloatingNav(enabled: Boolean) {
312+
prefs.edit().putBoolean("floating_nav", enabled).apply()
313+
_floatingNav.value = enabled
314+
}
315+
316+
/**
317+
* Where the floating ball was left: which side it snapped to, and how far down it sits as a
318+
* fraction of the window height.
319+
*
320+
* No flow, for the same reason the ambience adjustments have none: written straight through
321+
* from a gesture and read once when the ball is composed, so a StateFlow would recompose the
322+
* very thing being dragged on every frame of the drag. Persisted rather than remembered because
323+
* somebody who moved the ball out of the way of what they were reading has made a decision
324+
* about their thumb, and the host process is killed often enough that anything held in memory
325+
* would put the ball back over the content within the hour.
326+
*
327+
* The side is stored, not the x position: the ball always snaps to an edge, so a coordinate
328+
* would be a lie the moment the window is a different width — which, unfoldable and in
329+
* landscape, it routinely is.
330+
*/
331+
fun floatingNavAtEnd(): Boolean = prefs.getBoolean("floating_nav_at_end", true)
332+
333+
fun setFloatingNavAtEnd(atEnd: Boolean) {
334+
prefs.edit().putBoolean("floating_nav_at_end", atEnd).apply()
335+
}
336+
337+
fun floatingNavY(): Float = prefs.getFloat("floating_nav_y", 0.72f)
338+
339+
fun setFloatingNavY(fraction: Float) {
340+
prefs.edit().putFloat("floating_nav_y", fraction).apply()
341+
}
342+
300343
fun setThemeMode(mode: String) {
301344
prefs.edit().putString("theme_mode", mode).apply()
302345
_themeMode.value = mode

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

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

33
import androidx.activity.compose.BackHandler
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxSize
46
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
57
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
68
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
9+
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
710
import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState
811
import androidx.compose.runtime.Composable
912
import androidx.compose.runtime.CompositionLocalProvider
1013
import androidx.compose.runtime.LaunchedEffect
14+
import androidx.compose.runtime.getValue
15+
import androidx.compose.ui.Modifier
16+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1117
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
1218
import androidx.navigation3.runtime.EntryProviderScope
1319
import androidx.navigation3.runtime.NavKey
1420
import androidx.navigation3.runtime.entryProvider
1521
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
1622
import androidx.navigation3.ui.NavDisplay
23+
import org.matrix.vector.manager.di.ServiceLocator
1724
import org.matrix.vector.manager.ui.navigation.FrameworkUpdate
1825
import org.matrix.vector.manager.ui.screens.update.FrameworkUpdateScreen
1926
import org.matrix.vector.manager.ui.navigation.Canary
2027
import org.matrix.vector.manager.ui.screens.canary.CanaryScreen
2128
import org.matrix.vector.manager.ui.navigation.Troubleshoot
2229
import org.matrix.vector.manager.ui.screens.report.TroubleshootScreen
30+
import org.matrix.vector.manager.ui.navigation.FloatingPanelNav
2331
import org.matrix.vector.manager.ui.navigation.LocalNavigator
2432
import org.matrix.vector.manager.ui.navigation.Navigator
2533
import org.matrix.vector.manager.ui.navigation.PanelBar
@@ -48,13 +56,18 @@ import org.matrix.vector.manager.ui.screens.web.WebScreen
4856
* has to work unfolded and in landscape regardless. The scaffold also owns where that container
4957
* sits, so the destinations below it are laid out beside or above it rather than under it.
5058
*
51-
* Which panels that container holds, in which order, is the reader's — see NavPanels.
59+
* Which panels that container holds, in which order, is the reader's — see NavPanels — and there is
60+
* a third arrangement it can take, a ball floating over the content with no container at all. The
61+
* two are not independent: rearranging the panels needs something to rearrange, so edit mode always
62+
* puts the container back for as long as it lasts.
5263
*/
5364
@Composable
5465
fun VectorApp() {
5566
val navigator = rememberNavigator()
5667

5768
CompositionLocalProvider(LocalNavigator provides navigator) {
69+
val settings = ServiceLocator.settings
70+
val floating by settings.floatingNav.collectAsStateWithLifecycle()
5871
val editing = navigator.editingPanels
5972
// The container shows only at the root of a panel. On a detail screen none of the items is
6073
// the current destination, and a navigation bar highlighting nothing is worse than none.
@@ -66,46 +79,69 @@ fun VectorApp() {
6679
val suiteState = rememberNavigationSuiteScaffoldState()
6780
LaunchedEffect(atRoot) { if (atRoot) suiteState.show() else suiteState.hide() }
6881

69-
// Computed rather than left to the scaffold's default because PanelBar has to be told which
70-
// axis it is laying items along, and the scaffold keeps that decision to itself otherwise.
82+
// Computed rather than left to the scaffold's default, for two reasons: the floating style
83+
// forces None, which is what actually removes the container instead of hiding it, and
84+
// PanelBar has to be told which axis it is laying items along. Entering edit mode overrules
85+
// the floating setting for as long as it lasts — there is nothing to rearrange otherwise.
7186
val suiteType =
72-
NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
87+
if (floating && !editing) NavigationSuiteType.None
88+
else NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
7389

7490
NavigationSuiteScaffold(
7591
navigationItems = {
76-
PanelBar(
77-
panels = navigator.panels,
78-
current = navigator.currentTopLevel,
79-
editing = editing,
80-
suiteType = suiteType,
81-
onSelect = { route -> navigator.switchTo(route) },
82-
onEdit = { navigator.editingPanels = true },
83-
onToggleHidden = { key, hidden -> navigator.setPanelHidden(key, hidden) },
84-
onMove = { from, to -> navigator.movePanel(from, to) },
85-
)
92+
// NavigationSuite's `when` over the type has no None branch and no else, so under
93+
// None this slot is silently dropped along with the container. Skipping it here
94+
// says so out loud rather than leaving a composable that never runs.
95+
if (suiteType != NavigationSuiteType.None) {
96+
PanelBar(
97+
panels = navigator.panels,
98+
current = navigator.currentTopLevel,
99+
editing = editing,
100+
suiteType = suiteType,
101+
onSelect = { route -> navigator.switchTo(route) },
102+
onEdit = { navigator.editingPanels = true },
103+
onToggleHidden = { key, hidden -> navigator.setPanelHidden(key, hidden) },
104+
onMove = { from, to -> navigator.movePanel(from, to) },
105+
)
106+
}
86107
},
87108
navigationSuiteType = suiteType,
88109
state = suiteState,
89110
primaryActionContent = {
90111
if (editing) PanelEditDone(onDone = { navigator.editingPanels = false })
91112
},
92113
) {
93-
NavDisplay(
94-
backStack = navigator.backStack,
95-
onBack = { navigator.back() },
96-
// Naming any decorator replaces NavDisplay's default, which is the saveable-state
97-
// one alone, so it is repeated here; the scene-setup decorator NavDisplay applies
98-
// internally is untouched. The ViewModel one is what this list is for: it scopes a
99-
// ViewModelStore per entry, so opening the scope editor for a second module builds
100-
// a second ViewModel instead of reusing the first (they would otherwise share one
101-
// default key under the activity's store).
102-
entryDecorators =
103-
listOf(
104-
rememberSaveableStateHolderNavEntryDecorator(),
105-
rememberViewModelStoreNavEntryDecorator(),
106-
),
107-
entryProvider = entryProvider { registerRoutes(navigator) },
108-
)
114+
Box(Modifier.fillMaxSize()) {
115+
NavDisplay(
116+
backStack = navigator.backStack,
117+
onBack = { navigator.back() },
118+
// Naming any decorator replaces NavDisplay's default, which is the
119+
// saveable-state one alone, so it is repeated here; the scene-setup decorator
120+
// NavDisplay applies internally is untouched. The ViewModel one is what this
121+
// list is for: it scopes a ViewModelStore per entry, so opening the scope
122+
// editor for a second module builds a second ViewModel instead of reusing the
123+
// first (they would otherwise share one default key under the activity's
124+
// store).
125+
entryDecorators =
126+
listOf(
127+
rememberSaveableStateHolderNavEntryDecorator(),
128+
rememberViewModelStoreNavEntryDecorator(),
129+
),
130+
entryProvider = entryProvider { registerRoutes(navigator) },
131+
)
132+
// Last child of the Box so it draws over the destination, and inside the app window
133+
// rather than in one of its own: parasitically this app is com.android.shell, which
134+
// must never ask for SYSTEM_ALERT_WINDOW. It follows the same rule the container
135+
// does — present at the root of a panel, gone on a detail screen that has its own
136+
// back affordance.
137+
if (floating && !editing && atRoot) {
138+
FloatingPanelNav(
139+
panels = navigator.panels,
140+
current = navigator.currentTopLevel,
141+
onSelect = { route -> navigator.switchTo(route) },
142+
)
143+
}
144+
}
109145
}
110146

111147
// After the scaffold on purpose. Back callbacks are dispatched last-registered-first and

0 commit comments

Comments
 (0)