From 52b2994d3bb5a17592e06b6cf46320990c0df14e Mon Sep 17 00:00:00 2001 From: MarcoE Date: Thu, 17 Sep 2026 12:45:23 +0200 Subject: [PATCH] Intercetta il tasto indietro dove passa davvero Il salvataggio come bozza non e' mai scattato dal tasto indietro: la UI Blazor non riceveva l'evento, e uscire da un form con modifiche in corso buttava via il lavoro esattamente come prima che la bozza esistesse. Il back non arrivava a OnBackPressed per due motivi diversi: - da AndroidX Activity 1.6 il gesto passa dall'OnBackPressedDispatcher e il metodo dell'Activity non viene piu' invocato; - con la navigazione a tre tasti il back arriva come evento di tastiera e la WebView lo intercetta per conto suo, tornando indietro nella propria cronologia, prima che Activity o dispatcher lo vedano. Ora la chiave si ferma in DispatchKeyEvent, che passa prima della gerarchia di view, e resta un callback sul dispatcher per la navigazione a gesti, dove evento di tastiera non ce n'e'. Entrambi restano accesi solo finche' la UI ha davvero qualcosa da dire: altrimenti il back si comporta come sempre. I gestori diventano una pila, cosi' sopra il form puo' aprirsi un bottom sheet e il back chiude prima quello senza portarsi via la registrazione sottostante. ActionSheet si registra da solo mentre e' aperto. Verificato sull'EDA52: back sul form con modifiche apre "Lavoro non salvato" e salva la bozza; back sullo sheet chiude solo lo sheet. Co-Authored-By: Claude Opus 5 (1M context) --- SteUp.Maui/Platforms/Android/MainActivity.cs | 80 +++++++++++++++++-- .../Components/Layout/Sheet/ActionSheet.razor | 32 +++++++- .../Core/Messages/System/BackButtonService.cs | 32 ++++++-- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/SteUp.Maui/Platforms/Android/MainActivity.cs b/SteUp.Maui/Platforms/Android/MainActivity.cs index 6b64ac5..f406f0d 100644 --- a/SteUp.Maui/Platforms/Android/MainActivity.cs +++ b/SteUp.Maui/Platforms/Android/MainActivity.cs @@ -16,11 +16,15 @@ namespace SteUp.Maui ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] public class MainActivity : MauiAppCompatActivity { + private BackButtonService? _backButton; + private BlazorBackCallback? _backCallback; + protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); ApplySystemBarsAppearance(); + HookBackButton(); // Da Android 15 (API 35) il edge-to-edge e' forzato e adjustResize non ridimensiona // piu' la finestra: la tastiera si limita a coprire la WebView. Riduciamo noi il @@ -44,14 +48,78 @@ namespace SteUp.Maui controller.AppearanceLightNavigationBars = true; } - public override void OnBackPressed() + /// + /// Il tasto indietro va intercettato sul dispatcher di AndroidX, non + /// sovrascrivendo OnBackPressed: da AndroidX Activity 1.6 il gesto passa + /// tutto di li' e il metodo dell'Activity non viene piu' invocato — la + /// UI Blazor non riceveva mai il back e il lavoro non salvato del form + /// se ne andava con la navigazione. + /// + /// Il callback resta acceso solo finche' c'e' davvero qualcuno in + /// ascolto: quando la UI non ha nulla da dire, il back torna a + /// comportarsi come sempre invece di essere inghiottito. + /// + private void HookBackButton() { - // Se la UI Blazor ha un form aperto se ne occupa lei (propone il salvataggio - // come bozza); altrimenti comportamento standard. - var backButton = IPlatformApplication.Current?.Services.GetService(); - if (backButton?.TryHandleBackPressed() == true) return; + _backButton = IPlatformApplication.Current?.Services.GetService(); + if (_backButton is null) return; - base.OnBackPressed(); + _backCallback = new BlazorBackCallback(_backButton); + OnBackPressedDispatcher.AddCallback(this, _backCallback); + + _backCallback.Enabled = _backButton.HasHandlers; + _backButton.HandlersChanged += OnBackHandlersChanged; + } + + private void OnBackHandlersChanged(bool hasHandlers) + { + if (_backCallback is null) return; + + _backCallback.Enabled = hasHandlers; + if (!hasHandlers) return; + + // Il dispatcher consulta i callback dall'ultimo registrato al primo, + // e BlazorWebView registra il suo (che fa tornare indietro la + // cronologia della WebView) quando l'handler viene creato, cioe' dopo + // OnCreate. Ri-registrandoci nel momento in cui la UI ha davvero + // qualcosa da dire torniamo in cima e il back arriva prima a noi. + _backCallback.Remove(); + OnBackPressedDispatcher.AddCallback(this, _backCallback); + } + + /// + /// Con la navigazione a tre tasti il back arriva come evento di tastiera + /// e la WebView lo intercetta per conto suo, tornando indietro nella + /// propria cronologia: ne' l'Activity ne' il dispatcher lo vedono mai. + /// Qui la chiave passa prima della gerarchia di view, quindi e' l'unico + /// punto in cui possiamo fermarla mentre un form ha lavoro non salvato. + /// Il callback sul dispatcher resta per la navigazione a gesti, dove + /// evento di tastiera non ce n'e'. + /// + public override bool DispatchKeyEvent(KeyEvent? e) + { + if (e?.KeyCode == Keycode.Back && _backButton?.HasHandlers == true) + { + // Si consuma anche il DOWN: lasciandolo passare la WebView + // reagirebbe lo stesso. + if (e.Action == KeyEventActions.Up) _backButton.TryHandleBackPressed(); + return true; + } + + return base.DispatchKeyEvent(e); + } + + protected override void OnDestroy() + { + if (_backButton is not null) _backButton.HandlersChanged -= OnBackHandlersChanged; + + base.OnDestroy(); + } + + private sealed class BlazorBackCallback(BackButtonService service) + : AndroidX.Activity.OnBackPressedCallback(false) + { + public override void HandleOnBackPressed() => service.TryHandleBackPressed(); } private sealed class ImeInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener diff --git a/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor b/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor index 8f4e0b3..a95e1e4 100644 --- a/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor +++ b/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor @@ -1,4 +1,8 @@ -@* +@using SteUp.Shared.Core.Messages.System +@inject BackButtonService BackButton +@implements IDisposable + +@* Bottom sheet (STILE_UI §6.14): sale dal basso, maniglia di trascinamento, testata con titolo e chiusura, un'unica linea di struttura, corpo scorrevole. Sostituisce i menu flottanti: stessa funzione, bersagli molto piu' grandi. @@ -43,9 +47,35 @@ [Parameter] public RenderFragment? ChildContent { get; set; } + // Il tasto indietro del dispositivo chiude lo sheet invece di uscire dalla + // schermata sotto: e' il gesto che l'utente si aspetta, ed e' anche la + // ragione per cui i gestori sono impilati (vedi BackButtonService). + private IDisposable? _backRegistration; + + protected override void OnParametersSet() + { + if (Open && _backRegistration is null) + { + _backRegistration = BackButton.Register(() => InvokeAsync(Close)); + } + else if (!Open) + { + Unregister(); + } + } + private async Task Close() { + Unregister(); Open = false; await OpenChanged.InvokeAsync(false); } + + private void Unregister() + { + _backRegistration?.Dispose(); + _backRegistration = null; + } + + public void Dispose() => Unregister(); } diff --git a/SteUp.Shared/Core/Messages/System/BackButtonService.cs b/SteUp.Shared/Core/Messages/System/BackButtonService.cs index 711a02c..ab95b55 100644 --- a/SteUp.Shared/Core/Messages/System/BackButtonService.cs +++ b/SteUp.Shared/Core/Messages/System/BackButtonService.cs @@ -5,15 +5,32 @@ namespace SteUp.Shared.Core.Messages.System; /// Il form scheda si registra mentre è aperto così il back non manda l'app in /// background buttando via il lavoro non salvato: passa invece dallo stesso /// percorso del pulsante "Annulla", che propone il salvataggio come bozza. +/// +/// I gestori sono impilati: sopra il form può aprirsi un bottom sheet, e il +/// tasto indietro deve chiudere prima quello. Chiudendolo si torna al gestore +/// sottostante, che resta valido — senza la pila il sheet, uscendo di scena, +/// porterebbe via con sé anche la registrazione del form. /// public class BackButtonService { - private Func? _handler; + private readonly List> _handlers = []; - /// Registra il gestore corrente. Ritorna l'azione di deregistrazione. + /// + /// Segnala all'host se in questo momento c'è qualcuno da avvisare. L'host + /// se ne serve per accendere e spegnere la propria intercettazione, così + /// quando la UI non ha nulla da dire il tasto indietro torna a comportarsi + /// esattamente come sempre. + /// + public event Action? HandlersChanged; + + public bool HasHandlers => _handlers.Count > 0; + + /// Registra il gestore corrente in cima alla pila. Ritorna la deregistrazione. public IDisposable Register(Func handler) { - _handler = handler; + _handlers.Add(handler); + HandlersChanged?.Invoke(true); + return new Registration(this, handler); } @@ -23,11 +40,10 @@ public class BackButtonService /// public bool TryHandleBackPressed() { - var handler = _handler; - if (handler is null) return false; + if (_handlers.Count == 0) return false; // Fire-and-forget: il gestore rientra nel dispatcher Blazor da solo. - _ = handler(); + _ = _handlers[^1](); return true; } @@ -35,8 +51,8 @@ public class BackButtonService { public void Dispose() { - // Non azzerare se nel frattempo si è registrato qualcun altro. - if (ReferenceEquals(owner._handler, handler)) owner._handler = null; + if (!owner._handlers.Remove(handler)) return; + owner.HandlersChanged?.Invoke(owner.HasHandlers); } } }