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); } } }