Public Access
Lay the phone out like the desktop when the surface is not a phone
Three destinations in a bar and everything else behind SETTINGS is the right shape at 360dp, where a fourth entry costs the width of the three that are there. On a tablet, an unfolded foldable or a landscape phone it is the wrong one: there is room for every destination at once, and the hub becomes an extra tap between somebody and a screen they can already see space for. So at 600dp — Android's own boundary between a compact window and a medium one, in the density-independent units Avalonia lays out in — the bar stands down and PhoneRail takes the left edge with all nine on it. It is the desktop's NavRail arrangement rather than its file: the two heads cannot share a view, and this one draws the phone's destination set with the phone's palette and touch targets. The flags are computed in code rather than assembled in the markup because none of them is a single question any more, and Avalonia's bindings have no "and" — and the header's condition is an "or", which not even a wrapper can express. That header is the one worth reading twice: narrow it stands down behind SETTINGS, so the hub's screens can draw their own; wide there is no hub to be behind, so it stays up everywhere. Losing it on the keychain would be losing the only LOCK button on the surface. Removing the hub means removing the routes into it, and there were four kinds. The rail has no SETTINGS entry, because that screen is a menu of the rail. The five back arrows in the screens under it are hidden, since an arrow to a screen the layout removed is the one control on a header that leads nowhere. The system back gesture goes to Hosts instead. And unfolding while sitting on the hub moves to Hosts, rather than leaving somebody on a list of things now visible beside it. One bug fixed on the way: OnBodyResized returned early unless the keyboard was open, so a foldable would have opened to a phone layout until somebody typed something. The chrome is refreshed first and unconditionally; the early return belongs to the older job below it. What this does not do is use the width *inside* a screen — the host list is one column at any size. Two columns needs the row model to change, because that list is headings and hosts in one sequence and a heading has to span, and that model is shared with the desktop. Check 8.1 walks the rail; nothing here is covered by a test, for the reason 8.0 exists.
This commit is contained in:
@@ -44,6 +44,23 @@ internal sealed partial class PhoneShell : UserControl
|
||||
|
||||
private bool offeredDeviceUnlock;
|
||||
|
||||
/// <summary>
|
||||
/// The width above which this surface is laid out like the desktop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 600, which is Android's own boundary between a compact window and a medium one, and is where a
|
||||
/// tablet, an unfolded foldable and a landscape phone land on the far side. It is measured in the units
|
||||
/// Avalonia lays out in, which are density-independent — so this is 600dp and not 600 physical pixels,
|
||||
/// and a 1080-pixel phone at 3× density is correctly on the narrow side of it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One number rather than a set of them. Android names three window classes and this head has two
|
||||
/// layouts, so a second breakpoint would be a third arrangement nothing has been designed for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const double WideAt = 600;
|
||||
|
||||
public PhoneShell()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -72,9 +89,105 @@ internal sealed partial class PhoneShell : UserControl
|
||||
thisLockIsTheLaunch = true;
|
||||
TryOfferDeviceUnlock();
|
||||
}
|
||||
|
||||
RefreshChrome();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
||||
/// <remarks>
|
||||
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
||||
/// the view models are shared with a head where it is always true. The markup reads it through
|
||||
/// <c>$parent[views:PhoneShell]</c>.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> IsWideProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
||||
|
||||
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsRailProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
||||
|
||||
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
||||
|
||||
/// <summary>Whether the header carrying the vault's name, the sync light and LOCK is drawn.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
||||
|
||||
/// <inheritdoc cref="IsWideProperty" />
|
||||
public bool IsWide
|
||||
{
|
||||
get => GetValue(IsWideProperty);
|
||||
private set => SetValue(IsWideProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsRailProperty" />
|
||||
public bool ShowsRail
|
||||
{
|
||||
get => GetValue(ShowsRailProperty);
|
||||
private set => SetValue(ShowsRailProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsBottomBarProperty" />
|
||||
public bool ShowsBottomBar
|
||||
{
|
||||
get => GetValue(ShowsBottomBarProperty);
|
||||
private set => SetValue(ShowsBottomBarProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsVaultHeaderProperty" />
|
||||
public bool ShowsVaultHeader
|
||||
{
|
||||
get => GetValue(ShowsVaultHeaderProperty);
|
||||
private set => SetValue(ShowsVaultHeaderProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out which chrome this surface should be wearing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
|
||||
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
|
||||
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
|
||||
/// have to be an "or", which a wrapper cannot express at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
|
||||
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
|
||||
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
|
||||
/// is how you leave — so the vault's name, the sync light and LOCK stay where they are on every screen.
|
||||
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
|
||||
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
|
||||
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
||||
/// would notice going stale.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshChrome()
|
||||
{
|
||||
var wide = body.Bounds.Width >= WideAt;
|
||||
var pages = shell?.IsShowingPages == true;
|
||||
|
||||
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
|
||||
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
|
||||
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
|
||||
// it can already see. Only from the hub itself — a screen reached through it stays put, because the
|
||||
// user asked for that screen rather than for the menu.
|
||||
if (wide && !IsWide && shell is { Screen: ShellScreen.More })
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
}
|
||||
|
||||
IsWide = wide;
|
||||
ShowsRail = wide && pages;
|
||||
ShowsBottomBar = !wide && pages;
|
||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
||||
}
|
||||
|
||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (shell is null)
|
||||
@@ -82,6 +195,8 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshChrome();
|
||||
|
||||
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
||||
{
|
||||
ApplyScreenshotPolicy(shell.State);
|
||||
@@ -228,14 +343,22 @@ internal sealed partial class PhoneShell : UserControl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrolls whatever has the keyboard back into view once the room left for it is known.
|
||||
/// Answers a resize: which chrome this surface wears, and where the keyboard left the focused box.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one moment this is needed is the one no other handler sees: the box was focused while the whole
|
||||
/// screen was available, and the space it sits in shrank afterwards. Both ways of losing that space end
|
||||
/// here — the margin applied above, and the platform shortening the window on Android 14 and earlier —
|
||||
/// which is why the resize is the trigger rather than either of the two things that cause it.
|
||||
/// ◆ <b>Two jobs, and the chrome's is unconditional while the keyboard's is not.</b> A rotation, an
|
||||
/// unfold and Android's freeform window all arrive here and all of them can cross the width at which
|
||||
/// this surface stops being a phone — so <see cref="RefreshChrome"/> runs first and runs always. What
|
||||
/// follows it is the older job, and the early return below belongs to that one alone: it used to be the
|
||||
/// first thing in this method, which would have meant a foldable opening to a bottom bar until somebody
|
||||
/// typed something.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one moment the scroll is needed is the one no other handler sees: the box was focused while the
|
||||
/// whole screen was available, and the space it sits in shrank afterwards. Both ways of losing that
|
||||
/// space end here — the margin applied above, and the platform shortening the window on Android 14 and
|
||||
/// earlier — which is why the resize is the trigger rather than either of the two things that cause it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Posted rather than called, and at <c>Loaded</c> priority, because the size change is raised during
|
||||
@@ -249,6 +372,8 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// </remarks>
|
||||
private void OnBodyResized(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
RefreshChrome();
|
||||
|
||||
if (keyboard is not { State: InputPaneState.Open })
|
||||
{
|
||||
return;
|
||||
@@ -349,7 +474,12 @@ internal sealed partial class PhoneShell : UserControl
|
||||
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
|
||||
or ShellScreen.Keychain:
|
||||
current.ShowScreenCommand.Execute(ShellScreen.More);
|
||||
|
||||
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
|
||||
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but
|
||||
// a second back — and the arrows in these screens' own headers are hidden there for the
|
||||
// same reason. Home is where back goes when the thing you came from is not on the surface.
|
||||
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user