Xamarin Forms – tworzymy pierwszą aplikację z menu bocznym

Nieco ponad tydzień temu poruszyłem temat, jakim jest tworzenie aplikacji mobilnych przy użyciu technologii Xamarin. Jeżeli jeszcze nie wiesz, czym właściwie jest Xamarin, koniecznie tam zajrzyj. W komentarzach znajdziesz również linka do podobnego artykułu autorstwa Damiana Antonowicza. Jeżeli wszystko jest jasne, to możemy zaczynać! Dzisiaj pokażę, jak stworzyć prostą multiplatformową aplikację z bocznym menu (zwanym również hamburger-menu) przy użyciu podejścia Xamarin.Forms PCL. Do dzieła! 🙂

Tworzymy nowy projekt

Pierwszą rzeczą jaką musimy zrobić, jest oczywiście stworzenie nowego projektu. W przypadku Visual Studio 2017 wybieramy następujące opcje:

Jak widać na załączonych obrazkach, należy stworzyć nowy projekt typu Xamarin.Forms Portable Class Library (PCL). Dodatkowo podczas tworzenia projektu mogą zostać wyświetlone okienka konfiguracyjne dotyczące poszczególnych projektów. Przykładowo: jaka ma być najstarsza wersja/kompilacja Windows’a 10 umożliwiająca uruchomienie wynikowej aplikacji UWP. Poza tym zostanie wyświetlony monit, który umożliwia podpięcia Mac’a, na którym ma być kompilowana aplikacja przeznaczona dla iOS, gdyż niestety, ale Apple nie udostępnia swoich kompilatorów na inne systemy (w tym Windows). Dlatego też chcąc pisać aplikacje na ten system, trzeba zainwestować w takowy sprzęt. Ewentualnie można bawić się w maszyny wirtualne, ale to może być nie do końca zgodne z licencją…

Po utworzeniu projektu powinien powitać nas taki widok:

Pusta aplikacja powinna bez problemu się kompilować. Jeżeli jest inaczej, to należy doprowadzić Xamarina/VS do porządku, przy pomocy wujka Google 🙂

Instalacja Mvvm.Light

W aplikacji zastosujemy wzorzec projektowy MVVM. Aby uławić jego implementację warto zainstalować bibliotekę Mvvm.Light. W tym celu klikamy PPM na pozycję „References->Manage NuGet Packages…” w projekcie portable i wyszukujemy pozycję MvvmLightLibs:

Po udanym dodaniu biblioteki do projektu możemy przejść dalej.

Tworzymy wymagane foldery i klasy

Pierwszą rzeczą jaką zrobimy, żeby nie robić bałaganu, jest usunięcie istniejącej strony MainPage.xaml z projektu. Kolejnym krokiem będzie utworzenie folderów dla zachowania jakiegoś sensownego porządku:

  • View
    • Menu
    • Pages
  • ViewModel
    • Menu
    • Pages

Następnie dodajemy do podfolderu View\Menu dwie puste strony (Forms Blank Content Page Xaml) o nazwach MenuPage oraz RootPage. Do podfolderu Pages wrzucamy kolejne dwie puste strony o nazwach MainPage i SecondPage. Przy okazji można też już utworzyć ViewModele dla każdej ze stron (w odpowiadających im folderach), według przykładu:

using GalaSoft.MvvmLight;

namespace FirstXamarinApp.ViewModel.Menu
{
    public class MenuPageViewModel : ViewModelBase
    {
        public MenuPageViewModel()
        {

        }
    }
}

oraz podpiąć je pod nasze View:

using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace FirstXamarinApp.View.Menu
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class MenuPage : ContentPage
    {
        public MenuPage()
        {
            InitializeComponent();
            ViewModel.Menu.MenuPageViewModel vm = new ViewModel.Menu.MenuPageViewModel();
            this.BindingContext = vm;
        }
    }
}

Modyfikujemy i dodajemy kod

Po wykonaniu tych czynności przejdźmy do pliku RootPage i zmieńmy jego kod xaml na taki:

<?xml version="1.0" encoding="utf-8" ?>
<MasterDetailPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="FirstXamarinApp.View.Menu.RootPage"
             IsPresented="{Binding IsPresented, Mode=TwoWay}">
</MasterDetailPage>

Oraz kod code-behind:

using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace FirstXamarinApp.View.Menu
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class RootPage : MasterDetailPage
    {
        public RootPage()
        {
            InitializeComponent();
            ViewModel.Menu.RootPageViewModel vm = new ViewModel.Menu.RootPageViewModel();
            this.BindingContext = vm;
            MasterBehavior = MasterBehavior.Popover; //to daje nam pewność, że przycisk otwierający menu zawsze będzie widoczny 
        }
    }
}

Jak widać zmieniliśmy tu tylko typ strony z ContentPage na MasterDetailPage. Będzie ona swego rodzaju kontenerem dla menu, oraz stron podrzędnych aplikacji. Do tego bindujemy tutaj właściwość IsPresented. Będziemy zmieniać jej wartość na false, kiedy zechcemy, aby menu boczne się schowało (czyli po wybraniu z niego jakiegoś elementu). W związku z tym w klasie RootPageViewModel musimy umieścić taki kod:

using GalaSoft.MvvmLight;

namespace FirstXamarinApp.ViewModel.Menu
{
    public class RootPageViewModel : ViewModelBase
    {
        private bool _IsPresented;
        public bool IsPresented
        {
            get { return _IsPresented; }
            set { Set(() => IsPresented, ref _IsPresented, value); }
        }
    }
}

Skoro tę sprawę mamy załatwioną to możemy przejść dalej. Zajmijmy się teraz wyglądem naszego menu bocznego. W tym celu przejdźmy do pliku MenuPage.xaml i wstawmy kod, który utworzy nam listę, do której będziemy mogli dodawać (z poziomu ViewModelu) nowe elementy:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="FirstXamarinApp.View.Menu.MenuPage" Title="Menu">
    <StackLayout>
        <ListView x:Name="listView" ItemsSource="{Binding Items}">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <ImageCell DetailColor="Aqua" TextColor="Black" Text="{Binding Title}" ImageSource="{Binding IconSource}"/>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </StackLayout>
</ContentPage>

Skoro możemy już dodawać nowe elementy z poziomu ViewModelu do naszego menu. Zróbmy to!

using GalaSoft.MvvmLight;
using System.Collections.ObjectModel;
using Xamarin.Forms;

namespace FirstXamarinApp.ViewModel.Menu
{
    public class MenuPageViewModel : ViewModelBase
    {
        private ObservableCollection<MenuItem> _Items = new ObservableCollection<MenuItem>();
        public ObservableCollection<MenuItem> Items
        {
            get { return _Items; }
            set { Set(() => Items, ref _Items, value); }
        }
        public MenuPageViewModel()
        {
            Items.Add(new MenuItem("Home", ImageSource.FromResource("FirstXamarinApp.Home.png"))); //dodanie nowego elementu typu MenuItem
            Items.Add(new MenuItem("Page2", ImageSource.FromResource("FirstXamarinApp.Home.png")));
        }
    }
    public class MenuItem
    {
        public string Title { get; set; }
        public ImageSource IconSource { get; set; }
        public MenuItem(string Title, ImageSource IconSource)
        {
            this.Title = Title;
            this.IconSource = IconSource;
        }
    }
}

Jak widać powyższy kod dodaje elementy typu MenuItem do naszego menu. Obrazek jest pozyskiwany z zasobów aplikacji (po dodaniu go do projektu należy ustawić we właściwościach Build action na Embedded Resource). W przeciwnym wypadku nie zostanie załadowany.

No okej, teoretycznie mamy już menu i dwie puste strony. To teraz przydałoby się to wszystko wyświetlić. W tym celu przejdźmy do pliku App.xaml.cs:

using FirstXamarinApp.View.Menu;
using FirstXamarinApp.View.Pages;
using Xamarin.Forms;

namespace FirstXamarinApp
{
    public partial class App : Application
    {
        public static NavigationPage NavigationPage { get; private set; }
        public App()
        {
            InitializeComponent();

            NavigationPage = new NavigationPage(new MainPage());
            RootPage rootPage = new RootPage();
            MenuPage menuPage = new MenuPage();

            rootPage.Master = menuPage;
            rootPage.Detail = NavigationPage;
            MainPage = rootPage;
        }

        protected override void OnStart()
        {
            // Handle when your app starts
        }

        protected override void OnSleep()
        {
            // Handle when your app sleeps
        }

        protected override void OnResume()
        {
            // Handle when your app resumes
        }
    }
}

Dodajemy tutaj nowy publiczny statyczny obiekt typu NavigationPage – będzie umożliwiał nam przełączanie się pomiędzy elementami menu z każdego miejsca w kodzie (w praktyce z klasy MenuPageViewModel). Następnie w konstruktorze tworzymy nowe obiekty stron aplikacji i ustawiamy ich hierarchię.

Po kompilacji aplikacji, na jedną z platform powinien powitać nas taki widok:

Jej! Menu działa! 🙂 Ale… jest zupełnie statyczne… Nie przełącza stron, nie zasuwa się kiedy klikniemy na jakiś element… Trzeba coś z tym zrobić!

Ożywiamy menu

Podstawowym problemem ożywienia menu przy zastosowaniu wzorca MVVM jest fakt, że zdarzenie wyboru elementu z listy(ItemSelected) musimy jakoś powiązać z komendą (nie udostępnia tego element ListView). Niestety nie udało mi się znaleźć gotowego pakietu NuGet, który by to umożliwiał (tak jak System.Windows.Interactivity w przypadku WPF’a), ale udało mi się znaleźć gotową klasę, którą można po prostu skopiować do aplikacji i… użyć. Źródło, z którego skopiowałem kod jest podane w pierwszej linijce kodu. Btw. dziękuję jego autorowi, bo odwalił kawał dobrej roboty! 😉

/* Source: https://anthonysimmon.com/eventtocommand-in-xamarin-forms-apps/ */
using System;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Windows.Input;
using Xamarin.Forms;

namespace FirstXamarinApp.Behaviors
{
    public class EventToCommandBehavior : BindableBehavior<Xamarin.Forms.View>
    {
        public static readonly BindableProperty EventNameProperty = BindableProperty.Create<EventToCommandBehavior, string>(p => p.EventName, null);
        public static readonly BindableProperty CommandProperty = BindableProperty.Create<EventToCommandBehavior, ICommand>(p => p.Command, null);
        public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create<EventToCommandBehavior, object>(p => p.CommandParameter, null);
        public static readonly BindableProperty EventArgsConverterProperty = BindableProperty.Create<EventToCommandBehavior, IValueConverter>(p => p.EventArgsConverter, null);
        public static readonly BindableProperty EventArgsConverterParameterProperty = BindableProperty.Create<EventToCommandBehavior, object>(p => p.EventArgsConverterParameter, null);

        private Delegate _handler;
        private EventInfo _eventInfo;

        public string EventName
        {
            get { return (string)GetValue(EventNameProperty); }
            set { SetValue(EventNameProperty, value); }
        }

        public ICommand Command
        {
            get { return (ICommand)GetValue(CommandProperty); }
            set { SetValue(CommandProperty, value); }
        }

        public object CommandParameter
        {
            get { return GetValue(CommandParameterProperty); }
            set { SetValue(CommandParameterProperty, value); }
        }

        public IValueConverter EventArgsConverter
        {
            get { return (IValueConverter)GetValue(EventArgsConverterProperty); }
            set { SetValue(EventArgsConverterProperty, value); }
        }

        public object EventArgsConverterParameter
        {
            get { return GetValue(EventArgsConverterParameterProperty); }
            set { SetValue(EventArgsConverterParameterProperty, value); }
        }

        protected override void OnAttachedTo(Xamarin.Forms.View visualElement)
        {
            base.OnAttachedTo(visualElement);

            var events = AssociatedObject.GetType().GetRuntimeEvents().ToArray();
            if (events.Any())
            {
                _eventInfo = events.FirstOrDefault(e => e.Name == EventName);
                if (_eventInfo == null)
                    throw new ArgumentException(String.Format("EventToCommand: Can't find any event named '{0}' on attached type", EventName));

                AddEventHandler(_eventInfo, AssociatedObject, OnFired);
            }
        }

        protected override void OnDetachingFrom(Xamarin.Forms.View view)
        {
            if (_handler != null)
                _eventInfo.RemoveEventHandler(AssociatedObject, _handler);

            base.OnDetachingFrom(view);
        }

        private void AddEventHandler(EventInfo eventInfo, object item, Action<object, EventArgs> action)
        {
            var eventParameters = eventInfo.EventHandlerType
                .GetRuntimeMethods().First(m => m.Name == "Invoke")
                .GetParameters()
                .Select(p => Expression.Parameter(p.ParameterType))
                .ToArray();

            var actionInvoke = action.GetType()
                .GetRuntimeMethods().First(m => m.Name == "Invoke");

            _handler = Expression.Lambda(
                eventInfo.EventHandlerType,
                Expression.Call(Expression.Constant(action), actionInvoke, eventParameters[0], eventParameters[1]),
                eventParameters
            )
            .Compile();

            eventInfo.AddEventHandler(item, _handler);
        }

        private void OnFired(object sender, EventArgs eventArgs)
        {
            if (Command == null)
                return;

            var parameter = CommandParameter;

            if (eventArgs != null && eventArgs != EventArgs.Empty)
            {
                parameter = eventArgs;

                if (EventArgsConverter != null)
                {
                    parameter = EventArgsConverter.Convert(eventArgs, typeof(object), EventArgsConverterParameter, CultureInfo.CurrentUICulture);
                }
            }

            if (Command.CanExecute(parameter))
            {
                Command.Execute(parameter);
            }
        }
    }

    public class BindableBehavior<T> : Behavior<T> where T : BindableObject
    {
        public T AssociatedObject { get; private set; }

        protected override void OnAttachedTo(T visualElement)
        {
            base.OnAttachedTo(visualElement);

            AssociatedObject = visualElement;

            if (visualElement.BindingContext != null)
                BindingContext = visualElement.BindingContext;

            visualElement.BindingContextChanged += OnBindingContextChanged;
        }

        private void OnBindingContextChanged(object sender, EventArgs e)
        {
            OnBindingContextChanged();
        }

        protected override void OnDetachingFrom(T view)
        {
            view.BindingContextChanged -= OnBindingContextChanged;
        }

        protected override void OnBindingContextChanged()
        {
            base.OnBindingContextChanged();
            BindingContext = AssociatedObject.BindingContext;
        }
    }
}

Użycie powyższej klasy jest banalnie proste, w naszym projekcie będzie wyglądało dokładnie tak:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:b="clr-namespace:FirstXamarinApp.Behaviors;assembly=FirstXamarinApp"
             x:Class="FirstXamarinApp.View.Menu.MenuPage" Title="Menu">
    <StackLayout>
        <ListView x:Name="listView" ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem, Mode=TwoWay}">
            <ListView.Behaviors>
                <b:EventToCommandBehavior EventName="ItemSelected" Command="{Binding ItemSelectedCommand}"/>
            </ListView.Behaviors>
            <ListView.ItemTemplate>
                <DataTemplate>
                    <ImageCell DetailColor="Aqua" TextColor="Black" Text="{Binding Title}" ImageSource="{Binding IconSource}"/>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </StackLayout>
</ContentPage>

Dodałem tutaj również bindowanie właściwości SelectedItem, aby pozyskiwać informację o tym, który element został wybrany. W końcu co nam po samym zdarzeniu wybrania, któregoś z elementów 🙂 Następnie możemy w łatwy sposób powiązać utworzoną w ten sposób komendę z metodą zawartą w ViewModelu:

using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.Command;
using System.Collections.ObjectModel;
using System.Windows.Input;
using Xamarin.Forms;
using FirstXamarinApp.View.Pages;

namespace FirstXamarinApp.ViewModel.Menu
{
    public class MenuPageViewModel : ViewModelBase
    {
        private ObservableCollection<MenuItem> _Items = new ObservableCollection<MenuItem>();
        public ObservableCollection<MenuItem> Items
        {
            get { return _Items; }
            set { Set(() => Items, ref _Items, value); }
        }
        private MenuItem _SelectedItem;
        public MenuItem SelectedItem
        {
            get { return _SelectedItem; }
            set { Set(() => SelectedItem, ref _SelectedItem, value); }
        }
        public ICommand ItemSelectedCommand { get; set; }
        public MenuPageViewModel()
        {
            ItemSelectedCommand = new RelayCommand(ItemSelectedMethod);
            Items.Add(new MenuItem("Home", ImageSource.FromResource("FirstXamarinApp.Home.png")));
            Items.Add(new MenuItem("Page2", ImageSource.FromResource("FirstXamarinApp.Home.png")));
        }

        private async void ItemSelectedMethod()
        {
            if (SelectedItem == Items[0]) //jeżeli zostanie wybrany pierwszy element z listy
            {
                var root = App.NavigationPage.Navigation.NavigationStack[0];
                App.NavigationPage.Navigation.InsertPageBefore(new MainPage(), root); //ustawienie obecnej strony jako domowej
                await App.NavigationPage.PopToRootAsync(false); //przełączenie strony
            }
            if (SelectedItem == Items[1])
            {
                var root = App.NavigationPage.Navigation.NavigationStack[0];
                App.NavigationPage.Navigation.InsertPageBefore(new SecondPage(), root);
                await App.NavigationPage.PopToRootAsync(false);
            }
        }
    }
    public class MenuItem
    {
        public string Title { get; set; }
        public ImageSource IconSource { get; set; }
        public MenuItem(string Title, ImageSource IconSource)
        {
            this.Title = Title;
            this.IconSource = IconSource;
        }
    }
}

Żeby zobaczyć efekty działania tego kodu, trzeba coś napisać na naszych podstronach. Na przykład:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="FirstXamarinApp.View.Pages.MainPage">
    <Label Text="To jest strona domowa!"/>
</ContentPage>

oraz

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="FirstXamarinApp.View.Pages.SecondPage">
    <Label Text="To jest strona druga!"/>
</ContentPage>

Kiedy już wkonamy te operacje, po kliknięciu w dany element menu, ten powinien przenieść nas do odpowiadającej mu strony. Jednak… menu dalej się nie zasuwa! Temu też na szczęście da się zaradzić. Przypomnijmy: W celu zasunięcia menu po kliknięciu na jakiś element, musimy zmienić wartość właściwości IsPresented w RootPageViewModel na false. Aby uzyskać dostęp do tego elementu z poziomu MenuPageViewModel, możemy przekazać instancję klasy RootPageViewModel do naszego MenuPageViewModel. Żeby to zrobić musimy dokonać małych zmian w kodzie naszych klas. Odpowienio:

RootPage.xaml.cs

using FirstXamarinApp.ViewModel.Menu;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace FirstXamarinApp.View.Menu
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class RootPage : MasterDetailPage
    {
        public RootPageViewModel vm;
        public RootPage()
        {
            InitializeComponent();
            vm = new ViewModel.Menu.RootPageViewModel();
            this.BindingContext = vm;
            MasterBehavior = MasterBehavior.Popover;
        }
    }
}

App.xaml.cs

public App()
{
    InitializeComponent();

     NavigationPage = new NavigationPage(new MainPage());
     RootPage rootPage = new RootPage();
     MenuPage menuPage = new MenuPage(rootPage.vm);

     rootPage.Master = menuPage;
     rootPage.Detail = NavigationPage;
     MainPage = rootPage;
}

MenuPage.xaml.cs

using FirstXamarinApp.ViewModel.Menu;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace FirstXamarinApp.View.Menu
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class MenuPage : ContentPage
    {
        public MenuPage(RootPageViewModel rootPageViewModel)
        {
            InitializeComponent();
            ViewModel.Menu.MenuPageViewModel vm = new ViewModel.Menu.MenuPageViewModel(rootPageViewModel);
            this.BindingContext = vm;
        }
    }
}

MenuPageViewModel.cs

private RootPageViewModel rootPageViewModel;
public MenuPageViewModel(RootPageViewModel rootPageViewModel)
{
    this.rootPageViewModel = rootPageViewModel;
    ItemSelectedCommand = new RelayCommand(ItemSelectedMethod);
    Items.Add(new MenuItem("Home", ImageSource.FromResource("FirstXamarinApp.Home.png")));
    Items.Add(new MenuItem("Page2", ImageSource.FromResource("FirstXamarinApp.Home.png")));
 }

Po wprowadzeniu tych zmian spokojnie możemy zmienić wartość tej właściwości po kliknięciu na jakiś element (czyli w metodzie ItemSelectedMethod):

private async void ItemSelectedMethod()
{
    if (SelectedItem == Items[0])
    {
        var root = App.NavigationPage.Navigation.NavigationStack[0];
        App.NavigationPage.Navigation.InsertPageBefore(new MainPage(), root);
        await App.NavigationPage.PopToRootAsync(false);
    }
    if (SelectedItem == Items[1])
    {
        var root = App.NavigationPage.Navigation.NavigationStack[0];
        App.NavigationPage.Navigation.InsertPageBefore(new SecondPage(), root);
        await App.NavigationPage.PopToRootAsync(false);
    }
    rootPageViewModel.IsPresented = false;
}

Od tej pory menu będzie się już chować, po wybraniu jakiegoś elementu 😉 Końcowy efekt działania aplikacji prezentuje się następująco na Windowsie i Androidzie:

Ze względów technicznych, nie mogłem skompilować i przetestować aplikacji przeznaczonej dla iOS’a. Aplikacja może nie wygląda zbyt ładnie, ale nie taki był zamiar tego wpisu/poradnika. Chciałem przedstawić tutaj, jak stworzyć prostą apkę z menu bocznym przy użyciu Xamarin.Forms, bez pisania ani jednej linijki natywnego kodu. Oczywiście nic nie stoi na przeszkodzie, aby ją ładnie dopracować i wykorzystać do własnych niecnych celów 🙂

Na koniec zostawiam link do w pełni działającego projektu: klik

To byłoby na tyle. Jak zwykle zachęcam do zgłaszania swoich uwag i zadawania pytań w komentarzach. Planuję kolejne wpisy poświęcone Xamarinowi i jestem ciekaw, czy taki styl pisania nazwijmy to „poradników” wam pasuje. Do zobaczenia! 🙂

2,217 total views, 1 views today