[PL] Jak dodać przycisk zamykający stronę modalną w Xamarin.Forms?
Wstęp
Jak trudne może być dodanie przycisku zamykającego widok modalny w Xamarin.Forms? Okazuje się, że może. Problem wydaje się banalny ale rozwiązanie już niekoniecznie – zwłaszcza jeśli chcemy być zgodni z wytycznymi danej platformy. Zapraszam do dalszej lektury lub przejścia do sekcji TL;DR, gdzie znajduje się gotowe rozwiązanie.
Analiza problemu
NavigationPage
w Xamarin.Forms wspiera dwa tryby nawigacji:
-
- Hierarchical Navigation używająca
NavigationStack
- Modal Navigation używająca
ModalStack
- Hierarchical Navigation używająca
W ramach wstępu chciałbym najpierw stworzyć słownik pojęć dla całego artykułu i do klasy Page
będę odnosił się jako “strony”.
Podczas nawigacji hierarchicznej Xamarin.Forms wspierają przycisk powrotu do poprzedniej strony. Na Androidzie jest on reprezentowany jako strzałka, a na iOS domyślnie jako strzałka i napis “Back”. Obie platformy wyświetlają te elementy w lewym górnym rogu, można to zobaczyć na poniższych przykładach:
Przycisk “wstecz” na iOS.
Przycisk “wstecz” na Androidzie.
Niestety podczas nawigacji modalnej Xamarin.Forms nie wspierają w żaden sposób zamknięcia takiej strony. Domyślnie nie wyświetla się żaden przycisk, który pozwoliłby taką stronę zamknąć. Musimy to oprogramować sami.
Najprościej jest dodać zwykły przycisk, który będzie wyświetlany na stronie. Jednak takie rozwiązanie nie zawsze przejdzie w kontekście UX tworzonej przez nas aplikacji. Nasz designer może sobie zażyczyć dodanie przycisku zamykającego stronę modalną do tzw. navigation bar na górze strony. Xamarin.Forms posiadają do tego wsparcie w postaci ToolbarItem
, które możemy użyć w ten sposób:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="XamarinForms.CancelableModal.FirstNavigationStack" Title="First Nav Stack Page> <ContentPage.ToolbarItems> <ToolbarItem Text="Cancel" Clicked="ToolbatItemCancel_OnClicked" /> </ContentPage.ToolbarItems> </ContentPage>
W ten sposób przycisk zostanie dodany z prawej strony navigation bar. Nie mamy możliwości zmiany jego położenia używając mechanizmów zapewnionych przez Xamarin.Forms. Musimy użyć kodu platformowego i właśnie to zrobimy w kolejnej części posta.
iOS – rozwiązanie
W oficjalnej dokumentacji od Apple odnośnie stron modalnych możemy znaleźć taki przykład:
Widok modalny na iOS.
Przycisk do anulowania operacji i tym samym do zamknięcia strony modalnej umieszczony jest z lewej strony tzw. navigation bar. Xamarin.Forms nie posiadają wsparcia dla umieszczenia przycisku w tym miejscu. Formsowy ToolbarItem jest w tym przypadku zbyt ograniczony. Musimy to oprogramować sami.
Na początek stworzymy interfejs IModalPage
z jedną metodą Dismiss
. Ten interfejs będzie używany przez naszą stronę modalną, a wywoływany będzie z poziomu custom renderera. Przykładowe użycie poniżej:
public partial class FirstModalStack : ContentPage, IModalPage { public FirstModalStack() { InitializeComponent(); } public void Dismiss() { Navigation.PopModalAsync(); } }
Następnie stworzymy custom renderera, który doda przycisk “Cancel” w lewym górnym rogu. Powstaje pytanie co właściwie powinniśmy użyć, żeby osiągnąć ten efekt? Po odpowiedź możemy sięgnąć do kodu źródłowego Xamarin Forms, a konkretnie do klasy NavigationRenderer, gdzie znajdziemy taką metodę:
void UpdateToolbarItems() { if (NavigationItem.RightBarButtonItems != null) { for (var i = 0; i < NavigationItem.RightBarButtonItems.Length; i++) NavigationItem.RightBarButtonItems[i].Dispose(); } if (ToolbarItems != null) { for (var i = 0; i < ToolbarItems.Length; i++) ToolbarItems[i].Dispose(); } List<UIBarButtonItem> primaries = null; List<UIBarButtonItem> secondaries = null; foreach (var item in _tracker.ToolbarItems) { if (item.Order == ToolbarItemOrder.Secondary) (secondaries = secondaries ?? new List<UIBarButtonItem>()).Add(item.ToUIBarButtonItem(true)); else (primaries = primaries ?? new List<UIBarButtonItem>()).Add(item.ToUIBarButtonItem()); } if (primaries != null) primaries.Reverse(); NavigationItem.SetRightBarButtonItems(primaries == null ? new UIBarButtonItem[0] : primaries.ToArray(), false); ToolbarItems = secondaries == null ? new UIBarButtonItem[0] : secondaries.ToArray(); NavigationRenderer n; if (_navigation.TryGetTarget(out n)) n.UpdateToolBarVisible(); }
W tej metodzie ustawiane są odpowiednie przyciski po prawej stronie w NavigationItem na podstawie ToolbarItems przypisanych do danej strony. Wiedza ta może przydać się nam do stworzenia własnego customer rendera, który wygląda tak:
[assembly: ExportRenderer(typeof(Page), typeof(CustomPageRenderer))] namespace XamarinForms.CancelableModal.iOS.Renderers { public class CustomPageRenderer : Xamarin.Forms.Platform.iOS.PageRenderer { public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); if (Element is IModalPage modalPage) { NavigationController.TopViewController.NavigationItem.LeftBarButtonItem = new UIBarButtonItem(title: "Cancel", style: UIBarButtonItemStyle.Plain, handler: (sender, args) => { modalPage.Dismiss(); }); } } } }
Nasz custom renderer używa wcześniej stworzonego interfejsu IModalPage
i ustawia nowy przycisk dla NavigationItem.LeftBarButtonItem
. Efekt można zobaczyć na poniższym GIFie:
Android – rozwiązanie
W oficjalnej dokumentacji Google odnośnie widoków modalnych możemy znaleźć taki przykład.
Widok modalny na Androidzie.
W odróżnieniu do iOS, mamy tutaj ikonkę zamiast tekstu. Jest jednak wspólna cecha z iOS – przycisk do zamknięcia widoku jest z lewej strony tzw. app bar. Jak osiągnąć taki efekt? Znowu najprościej będzie spojrzeć na kod źródłowy Xamarin.Forms, a konkretnie do klasy NavigationPageRenderer
i metody UpdateToolbar
. Z tej metody możemy dowiedzieć się, że ikona dla androidowego Toolbara ustawiana jest przez właściwość NavigationIcon
. Możemy tą wiedzę wykorzystać do napisania custom renderera, który używa wcześniej zdefiniowany interfejs IModalPage
:
public class CustomPageRenderer : NavigationPageRenderer { private Toolbar _modalToolbar; public CustomPageRenderer(Context context) : base(context) { } protected override void OnAttachedToWindow() { base.OnAttachedToWindow(); if (Element.CurrentPage is IModalPage modalPage) { var activity = Context as FormsAppCompatActivity; var content = activity.FindViewById(Android.Resource.Id.Content) as ViewGroup; var toolbars = content.GetChildrenOfType<Toolbar>(); _modalToolbar = toolbars.Last(); _modalToolbar.NavigationClick += ModalToolbarOnNavigationClick; } } protected override void OnDetachedFromWindow() { base.OnDetachedFromWindow(); if (_modalToolbar != null) { _modalToolbar.NavigationClick -= ModalToolbarOnNavigationClick; } } protected override void OnLayout(bool changed, int l, int t, int r, int b) { base.OnLayout(changed, l, t, r, b); if (Element.CurrentPage is IModalPage) { _modalToolbar?.SetNavigationIcon(Resource.Drawable.baseline_close_white_24); } } private void ModalToolbarOnNavigationClick(object sender, Toolbar.NavigationClickEventArgs e) { if (Element.CurrentPage is IModalPage modalPage) { modalPage.Dismiss(); } else { Element.SendBackButtonPressed(); } } }
W metodzie OnAttachedToWindow
pobieramy referencje do odpowiedniego Toolbara. W tym celu używamy extension method GetChildrenOfType
, której implementacja wygląda tak:
public static class ViewGroupExtensions { public static IEnumerable<T> GetChildrenOfType<T>(this ViewGroup viewGroup) where T : View { for (int i = 0; i < viewGroup.ChildCount; i++) { View child = viewGroup.GetChildAt(i); if (child is T expectedChild) { yield return expectedChild; } if (child is ViewGroup childViewGroup) { foreach (var expectedInnerChild in GetChildrenOfType<T>(childViewGroup)) { yield return expectedInnerChild; } } } } }
Jest to potrzebne z tego względu, że wyświetlanie stron modalnych w Xamarin.Forms na Androidzie, różni się od wyświetlania stron przez navigation stack. Strony wyświetlane przez navigation stack używają do tego FragmentManager
, który na raz wyświetla tylko jedną stronę. Można do zobaczyć w metodzie SwitchContentAsync
klasy NavigationPageRenderer
. Z kolei sposób wyświetlania stron modalnych można zobaczyć w metodzie PresentModal
klasy Platform
. Ta metoda nie używa fragmentów jak poprzednio – nowa strona zostaje dodana do już istniejącej, a następnie jest animowana przez zmianę translacji osi Y. Wszystko to powoduje, że w momencie wyświetlania strony modalnej mamy dwa Toolbary na ekranie! Z tego powodu w naszym custom rendererze musimy pobrać instancję Toolbara, który znajduje się na górze drzewka. Przykład takiego drzewka można zobaczyć poniżej:
ContentFrameLayout (ViewGroup) RelativeLayout (ViewGroup) PlatformRenderer (ViewGroup) CustomNavigationPageRenderer (ViewGroup) PageContainer (ViewGroup) PageRenderer (ViewGroup) DefaultRenderer (ViewGroup) ButtonRenderer (ViewGroup) AppCompatButton ButtonRenderer (ViewGroup) AppCompatButton Toolbar (ViewGroup) ActionMenuView (ViewGroup) AppCompatTextView ModalContainer (ViewGroup) View CustomNavigationPageRenderer (ViewGroup) Toolbar (ViewGroup) ActionMenuView (ViewGroup) AppCompatTextView
W metodzie OnLayout
w naszym custom renderze ustawiana jest ikonka toolbara wyświetlana z jego lewej strony. Do wyświetlenia ikonki X użyłem grafik ze strony Material.io.
A całość rozwiązania prezentuje się w poniższy sposób:
TL;DR
Kod źródłowy z implementacją przycisku (zgodnego z wytycznymi danej platformy) zamykającego widok modalny znajduje się na moim GitHubie: https://github.com/DamianAntonowicz/XamarinForms.CancelableModal
Podsumowanie
Zachęcam do czytania kodu źródłowego Xamarin.Forms, naprawdę warto zobaczyć jak to wszystko działa od środka. W pewnych przypadkach możemy również znaleźć rozwiązanie naszego problemu zwyczajnie szybciej poprzez czytanie kodu niż np. szukanie informacji w Internecie. Osobiście straciłem dobre parę godzin szukając w sieci informacji o tym jak dobrać się do właściwego Toolbara na Androidzie. Rozwiązania nie znalazłem nigdzie. Dopiero spojrzenie na implementację w kodzie Xamarin.Forms pozwoliło mi na znalezienie odpowiedniego rozwiązania.
One thought on “[PL] Jak dodać przycisk zamykający stronę modalną w Xamarin.Forms?”