Часть 5. От привязки данных до MVVM

Шаблон архитектуры Model-ViewModel (MVVM) был создан с учетом XAML. Шаблон обеспечивает разделение между тремя слоями программного обеспечения — пользовательский интерфейс XAML, называемый представлением; базовые данные, называемые моделью; и посредник между представлением и моделью, который называется ViewModel. Представление и ViewModel часто подключаются через привязки данных, определенные в XAML-файле. BindingContext для представления обычно является экземпляром ViewModel.

Простой видModel

Как введение в ViewModels, давайте сначала рассмотрим программу без одного. Ранее вы узнали, как определить новое объявление пространства имен XML, чтобы разрешить XAML-файлу ссылаться на классы в других сборках. Ниже приведена программа, которая определяет объявление пространства имен XML для System пространства имен:

xmlns:sys="clr-namespace:System;assembly=netstandard"

Программа может использовать x:Static для получения текущей даты и времени из статического DateTime.Now свойства и задать это DateTime значение в BindingContext StackLayout:

<StackLayout BindingContext="{x:Static sys:DateTime.Now}" …>

BindingContext — специальное свойство: при установке BindingContext элемента он наследуется всеми дочерними элементами этого элемента. Это означает, что все дочерние элементы StackLayout имеют то же самое BindingContext, и они могут содержать простые привязки к свойствам этого объекта.

В программе One-Shot DateTime два дочерних элемента содержат привязки к свойствам этого DateTime значения, но два других дочерних элемента содержат привязки, которые, как представляется, отсутствуют пути привязки. Это означает, что DateTime само значение используется для StringFormat:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:sys="clr-namespace:System;assembly=netstandard"
             x:Class="XamlSamples.OneShotDateTimePage"
             Title="One-Shot DateTime Page">

    <StackLayout BindingContext="{x:Static sys:DateTime.Now}"
                 HorizontalOptions="Center"
                 VerticalOptions="Center">

        <Label Text="{Binding Year, StringFormat='The year is {0}'}" />
        <Label Text="{Binding StringFormat='The month is {0:MMMM}'}" />
        <Label Text="{Binding Day, StringFormat='The day is {0}'}" />
        <Label Text="{Binding StringFormat='The time is {0:T}'}" />

    </StackLayout>
</ContentPage>

Проблема заключается в том, что дата и время задаются один раз при первой сборке страницы и никогда не изменяются:

Просмотр даты и времени

XAML-файл может отображать часы, которые всегда показывают текущее время, но требуется некоторый код, чтобы помочь. При думать с точки зрения MVVM модель и ViewModel являются классами, написанными полностью в коде. Представление часто представляет собой XAML-файл, который ссылается на свойства, определенные в ViewModel с помощью привязок данных.

Правильная модель не учитывается в представлении ViewModel, и соответствующий ViewModel не учитывает представление. Однако зачастую программист настраивает типы данных, предоставляемые ViewModel, с типами данных, связанными с определенными пользовательскими интерфейсами. Например, если модель обращается к базе данных, содержащей 8-разрядные строки ASCII, viewModel потребуется преобразовать между этими строками в строки Юникода, чтобы обеспечить монопольное использование Юникода в пользовательском интерфейсе.

В простых примерах MVVM (например, показанных здесь), часто нет модели вообще, и шаблон включает только представление и ViewModel, связанные с привязками данных.

Вот ViewModel для часов с одним именем свойства DateTime, которое обновляет это DateTime свойство каждые секунды:

using System;
using System.ComponentModel;
using Xamarin.Forms;

namespace XamlSamples
{
    class ClockViewModel : INotifyPropertyChanged
    {
        DateTime dateTime;

        public event PropertyChangedEventHandler PropertyChanged;

        public ClockViewModel()
        {
            this.DateTime = DateTime.Now;

            Device.StartTimer(TimeSpan.FromSeconds(1), () =>
                {
                    this.DateTime = DateTime.Now;
                    return true;
                });
        }

        public DateTime DateTime
        {
            set
            {
                if (dateTime != value)
                {
                    dateTime = value;

                    if (PropertyChanged != null)
                    {
                        PropertyChanged(this, new PropertyChangedEventArgs("DateTime"));
                    }
                }
            }
            get
            {
                return dateTime;
            }
        }
    }
}

ViewModels обычно реализует INotifyPropertyChanged интерфейс, что означает, что класс запускает PropertyChanged событие всякий раз, когда одно из его свойств изменяется. Механизм привязки данных при Xamarin.Forms присоединении обработчика к этому PropertyChanged событию позволяет получать уведомления при изменении свойства и обновлять целевой объект новым значением.

Часы, основанные на этом ViewModel, могут быть так же просты, как это:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples;assembly=XamlSamples"
             x:Class="XamlSamples.ClockPage"
             Title="Clock Page">

    <Label Text="{Binding DateTime, StringFormat='{0:T}'}"
           FontSize="Large"
           HorizontalOptions="Center"
           VerticalOptions="Center">
        <Label.BindingContext>
            <local:ClockViewModel />
        </Label.BindingContext>
    </Label>
</ContentPage>

Обратите внимание, как ClockViewModel задано значение BindingContext тегов Label элементов свойства. Кроме того, можно создать экземпляр ClockViewModel коллекции Resources и задать его с BindingContext помощью StaticResource расширения разметки. Или файл программной части может создать экземпляр ViewModel.

Binding Расширение разметки Label для Text свойства форматирования DateTime свойства. Ниже показан экран:

Просмотр даты и времени с помощью ViewModel

Кроме того, можно получить доступ к отдельным свойствам DateTime свойства viewModel, разделив свойства с точками:

<Label Text="{Binding DateTime.Second, StringFormat='{0}'}" … >

Интерактивный MVVM

MVVM часто используется с двусторонними привязками данных для интерактивного представления на основе базовой модели данных.

Ниже приведен класс с именем HslViewModel , который преобразует Color значение в Hue, Saturationа также Luminosity значения и наоборот:

using System;
using System.ComponentModel;
using Xamarin.Forms;

namespace XamlSamples
{
    public class HslViewModel : INotifyPropertyChanged
    {
        double hue, saturation, luminosity;
        Color color;

        public event PropertyChangedEventHandler PropertyChanged;

        public double Hue
        {
            set
            {
                if (hue != value)
                {
                    hue = value;
                    OnPropertyChanged("Hue");
                    SetNewColor();
                }
            }
            get
            {
                return hue;
            }
        }

        public double Saturation
        {
            set
            {
                if (saturation != value)
                {
                    saturation = value;
                    OnPropertyChanged("Saturation");
                    SetNewColor();
                }
            }
            get
            {
                return saturation;
            }
        }

        public double Luminosity
        {
            set
            {
                if (luminosity != value)
                {
                    luminosity = value;
                    OnPropertyChanged("Luminosity");
                    SetNewColor();
                }
            }
            get
            {
                return luminosity;
            }
        }

        public Color Color
        {
            set
            {
                if (color != value)
                {
                    color = value;
                    OnPropertyChanged("Color");

                    Hue = value.Hue;
                    Saturation = value.Saturation;
                    Luminosity = value.Luminosity;
                }
            }
            get
            {
                return color;
            }
        }

        void SetNewColor()
        {
            Color = Color.FromHsla(Hue, Saturation, Luminosity);
        }

        protected virtual void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

HueSaturationИзменения свойств и Luminosity свойств приводят Color к изменению свойства и изменениям, что приводит к Color изменению других трех свойств. Это может показаться бесконечным циклом, за исключением того, что класс не вызывает PropertyChanged событие, если свойство не изменилось. Это ставит конец неконтролируемому циклу обратной связи.

Следующий XAML-файл содержит свойство, свойство которого привязано BoxView к Color свойству ViewModel, а три и три Slider Label представления привязаны к свойствам и SaturationLuminosity свойствамHue:Color

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples;assembly=XamlSamples"
             x:Class="XamlSamples.HslColorScrollPage"
             Title="HSL Color Scroll Page">
    <ContentPage.BindingContext>
        <local:HslViewModel Color="Aqua" />
    </ContentPage.BindingContext>

    <StackLayout Padding="10, 0">
        <BoxView Color="{Binding Color}"
                 VerticalOptions="FillAndExpand" />

        <Label Text="{Binding Hue, StringFormat='Hue = {0:F2}'}"
               HorizontalOptions="Center" />

        <Slider Value="{Binding Hue, Mode=TwoWay}" />

        <Label Text="{Binding Saturation, StringFormat='Saturation = {0:F2}'}"
               HorizontalOptions="Center" />

        <Slider Value="{Binding Saturation, Mode=TwoWay}" />

        <Label Text="{Binding Luminosity, StringFormat='Luminosity = {0:F2}'}"
               HorizontalOptions="Center" />

        <Slider Value="{Binding Luminosity, Mode=TwoWay}" />
    </StackLayout>
</ContentPage>

Привязка для каждой из них Label — это значение по умолчанию OneWay. Оно должно отображаться только для отображения значения. Но привязка для каждой Slider из них TwoWay. Это позволяет Slider инициализироваться из ViewModel. Обратите внимание, что Color свойство задано Aqua при создании экземпляра ViewModel. Но изменение также Slider должно задать новое значение для свойства в ViewModel, которое затем вычисляет новый цвет.

MVVM с помощью двухсторонними привязки данных

Командирование с помощью ViewModels

Во многих случаях шаблон MVVM ограничен манипуляцией с элементами данных: объекты пользовательского интерфейса в объектах параллельных данных View в ViewModel.

Однако иногда представление должно содержать кнопки, которые активируют различные действия в ViewModel. Но ViewModel не должен содержать Clicked обработчики для кнопок, так как это привязывает ViewModel к определенной парадигме пользовательского интерфейса.

Чтобы разрешить ViewModels быть более независимыми от конкретных объектов пользовательского интерфейса, но по-прежнему разрешать вызов методов в ViewModel, существует командный интерфейс. Этот командный интерфейс поддерживается следующими элементами:Xamarin.Forms

  • Button
  • MenuItem
  • ToolbarItem
  • SearchBar
  • TextCell (и, следовательно, также ImageCell)
  • ListView
  • TapGestureRecognizer

За исключением SearchBar элемента и ListView элемента, эти элементы определяют два свойства:

  • Command типа System.Windows.Input.ICommand
  • CommandParameter типа Object

Определяет SearchBar и SearchCommandParameter свойства, а определяет ListView RefreshCommand свойство типаICommand.SearchCommand

Интерфейс ICommand определяет два метода и одно событие:

  • void Execute(object arg)
  • bool CanExecute(object arg)
  • event EventHandler CanExecuteChanged

ViewModel может определять свойства типа ICommand. Затем эти свойства можно привязать к Command свойству каждого Button или другого элемента или, возможно, пользовательское представление, реализующее этот интерфейс. При необходимости можно задать CommandParameter свойство для идентификации отдельных Button объектов (или других элементов), привязанных к этому свойству ViewModel. Внутри этого метода вызывается всякий раз, Button когда пользователь нажимает методButton, передав его методу Execute CommandParameter.Execute

Метод CanExecute и CanExecuteChanged событие используются для случаев, когда Button касание в настоящее время может быть недопустимым, в этом случае Button следует отключить сам. Вызовы CanExecute Button при Command первом наборе свойства и при CanExecuteChanged каждом запуске события. Если CanExecute возвращается false, Button он отключает и не создает Execute вызовы.

Для справки по добавлению команд в ViewModels определяет два класса, Xamarin.Forms реализующих ICommand: Command и Command<T> где T тип аргументов Execute и CanExecute. Эти два класса определяют несколько конструкторов, а также ChangeCanExecute метод, который ViewModel может вызвать для принудительного Command CanExecuteChanged вызова объекта для запуска события.

Вот viewModel для простой клавиатуры, предназначенной для ввода номеров телефонов. Обратите внимание, что Execute метод CanExecute определяется как лямбда-функции прямо в конструкторе:

using System;
using System.ComponentModel;
using System.Windows.Input;
using Xamarin.Forms;

namespace XamlSamples
{
    class KeypadViewModel : INotifyPropertyChanged
    {
        string inputString = "";
        string displayText = "";
        char[] specialChars = { '*', '#' };

        public event PropertyChangedEventHandler PropertyChanged;

        // Constructor
        public KeypadViewModel()
        {
            AddCharCommand = new Command<string>((key) =>
                {
                    // Add the key to the input string.
                    InputString += key;
                });

            DeleteCharCommand = new Command(() =>
                {
                    // Strip a character from the input string.
                    InputString = InputString.Substring(0, InputString.Length - 1);
                },
                () =>
                {
                    // Return true if there's something to delete.
                    return InputString.Length > 0;
                });
        }

        // Public properties
        public string InputString
        {
            protected set
            {
                if (inputString != value)
                {
                    inputString = value;
                    OnPropertyChanged("InputString");
                    DisplayText = FormatText(inputString);

                    // Perhaps the delete button must be enabled/disabled.
                    ((Command)DeleteCharCommand).ChangeCanExecute();
                }
            }

            get { return inputString; }
        }

        public string DisplayText
        {
            protected set
            {
                if (displayText != value)
                {
                    displayText = value;
                    OnPropertyChanged("DisplayText");
                }
            }
            get { return displayText; }
        }

        // ICommand implementations
        public ICommand AddCharCommand { protected set; get; }

        public ICommand DeleteCharCommand { protected set; get; }

        string FormatText(string str)
        {
            bool hasNonNumbers = str.IndexOfAny(specialChars) != -1;
            string formatted = str;

            if (hasNonNumbers || str.Length < 4 || str.Length > 10)
            {
            }
            else if (str.Length < 8)
            {
                formatted = String.Format("{0}-{1}",
                                          str.Substring(0, 3),
                                          str.Substring(3));
            }
            else
            {
                formatted = String.Format("({0}) {1}-{2}",
                                          str.Substring(0, 3),
                                          str.Substring(3, 3),
                                          str.Substring(6));
            }
            return formatted;
        }

        protected void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

В этом представлении предполагается, что AddCharCommand свойство привязано к Command свойству нескольких кнопок (или что-либо другое с интерфейсом команды), каждое из которых определяется параметром CommandParameter. Эти кнопки добавляют символы в InputString свойство, которое затем форматируется как номер телефона для DisplayText свойства.

Существует также второе свойство типа ICommand с именем DeleteCharCommand. Это привязано к кнопке с интервалом назад, но кнопка должна быть отключена, если нет символов для удаления.

Следующая клавиатура не так визуально сложна, как это может быть. Вместо этого разметка была сокращена до минимума, чтобы продемонстрировать более четкое использование интерфейса команды:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples;assembly=XamlSamples"
             x:Class="XamlSamples.KeypadPage"
             Title="Keypad Page">

    <Grid HorizontalOptions="Center"
          VerticalOptions="Center">
        <Grid.BindingContext>
            <local:KeypadViewModel />
        </Grid.BindingContext>

        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
        </Grid.ColumnDefinitions>

        <!-- Internal Grid for top row of items -->
        <Grid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <Frame Grid.Column="0"
                   OutlineColor="Accent">
                <Label Text="{Binding DisplayText}" />
            </Frame>

            <Button Text="&#x21E6;"
                    Command="{Binding DeleteCharCommand}"
                    Grid.Column="1"
                    BorderWidth="0" />
        </Grid>

        <Button Text="1"
                Command="{Binding AddCharCommand}"
                CommandParameter="1"
                Grid.Row="1" Grid.Column="0" />

        <Button Text="2"
                Command="{Binding AddCharCommand}"
                CommandParameter="2"
                Grid.Row="1" Grid.Column="1" />

        <Button Text="3"
                Command="{Binding AddCharCommand}"
                CommandParameter="3"
                Grid.Row="1" Grid.Column="2" />

        <Button Text="4"
                Command="{Binding AddCharCommand}"
                CommandParameter="4"
                Grid.Row="2" Grid.Column="0" />

        <Button Text="5"
                Command="{Binding AddCharCommand}"
                CommandParameter="5"
                Grid.Row="2" Grid.Column="1" />

        <Button Text="6"
                Command="{Binding AddCharCommand}"
                CommandParameter="6"
                Grid.Row="2" Grid.Column="2" />

        <Button Text="7"
                Command="{Binding AddCharCommand}"
                CommandParameter="7"
                Grid.Row="3" Grid.Column="0" />

        <Button Text="8"
                Command="{Binding AddCharCommand}"
                CommandParameter="8"
                Grid.Row="3" Grid.Column="1" />

        <Button Text="9"
                Command="{Binding AddCharCommand}"
                CommandParameter="9"
                Grid.Row="3" Grid.Column="2" />

        <Button Text="*"
                Command="{Binding AddCharCommand}"
                CommandParameter="*"
                Grid.Row="4" Grid.Column="0" />

        <Button Text="0"
                Command="{Binding AddCharCommand}"
                CommandParameter="0"
                Grid.Row="4" Grid.Column="1" />

        <Button Text="#"
                Command="{Binding AddCharCommand}"
                CommandParameter="#"
                Grid.Row="4" Grid.Column="2" />
    </Grid>
</ContentPage>

Command Свойство первогоButton, отображаемого в этой разметке, привязано кDeleteCharCommand; остальные привязаны к AddCharCommand объекту, который совпадает с CommandParameter символом, отображаемым на Button лице. Вот программа в действии:

Калькулятор с помощью MVVM и команд

Вызов асинхронных методов

Команды также могут вызывать асинхронные методы. Это достигается с помощью async ключевое слово await при указании Execute метода:

DownloadCommand = new Command (async () => await DownloadAsync ());

Это означает, что DownloadAsync метод является и Task должен ожидаться:

async Task DownloadAsync ()
{
    await Task.Run (() => Download ());
}

void Download ()
{
    ...
}

Реализация меню навигации

Пример программы, содержащей весь исходный код в этой серии статей, использует ViewModel для домашней страницы. Этот ViewModel — это определение короткого класса с тремя свойствами с именем Type, Titleи Description которые содержат тип каждой из примеров страниц, заголовка и краткого описания. Кроме того, ViewModel определяет статическое свойство, которое All является коллекцией всех страниц в программе:

public class PageDataViewModel
{
    public PageDataViewModel(Type type, string title, string description)
    {
        Type = type;
        Title = title;
        Description = description;
    }

    public Type Type { private set; get; }

    public string Title { private set; get; }

    public string Description { private set; get; }

    static PageDataViewModel()
    {
        All = new List<PageDataViewModel>
        {
            // Part 1. Getting Started with XAML
            new PageDataViewModel(typeof(HelloXamlPage), "Hello, XAML",
                                  "Display a Label with many properties set"),

            new PageDataViewModel(typeof(XamlPlusCodePage), "XAML + Code",
                                  "Interact with a Slider and Button"),

            // Part 2. Essential XAML Syntax
            new PageDataViewModel(typeof(GridDemoPage), "Grid Demo",
                                  "Explore XAML syntax with the Grid"),

            new PageDataViewModel(typeof(AbsoluteDemoPage), "Absolute Demo",
                                  "Explore XAML syntax with AbsoluteLayout"),

            // Part 3. XAML Markup Extensions
            new PageDataViewModel(typeof(SharedResourcesPage), "Shared Resources",
                                  "Using resource dictionaries to share resources"),

            new PageDataViewModel(typeof(StaticConstantsPage), "Static Constants",
                                  "Using the x:Static markup extensions"),

            new PageDataViewModel(typeof(RelativeLayoutPage), "Relative Layout",
                                  "Explore XAML markup extensions"),

            // Part 4. Data Binding Basics
            new PageDataViewModel(typeof(SliderBindingsPage), "Slider Bindings",
                                  "Bind properties of two views on the page"),

            new PageDataViewModel(typeof(SliderTransformsPage), "Slider Transforms",
                                  "Use Sliders with reverse bindings"),

            new PageDataViewModel(typeof(ListViewDemoPage), "ListView Demo",
                                  "Use a ListView with data bindings"),

            // Part 5. From Data Bindings to MVVM
            new PageDataViewModel(typeof(OneShotDateTimePage), "One-Shot DateTime",
                                  "Obtain the current DateTime and display it"),

            new PageDataViewModel(typeof(ClockPage), "Clock",
                                  "Dynamically display the current time"),

            new PageDataViewModel(typeof(HslColorScrollPage), "HSL Color Scroll",
                                  "Use a view model to select HSL colors"),

            new PageDataViewModel(typeof(KeypadPage), "Keypad",
                                  "Use a view model for numeric keypad logic")
        };
    }

    public static IList<PageDataViewModel> All { private set; get; }
}

XAML-файл для MainPage определения ListBox свойства, для которого ItemsSource задано это All свойство, и который содержит объект TextCell для отображения Title и Description свойств каждой страницы:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.MainPage"
             Padding="5, 0"
             Title="XAML Samples">

    <ListView ItemsSource="{x:Static local:PageDataViewModel.All}"
              ItemSelected="OnListViewItemSelected">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding Title}"
                          Detail="{Binding Description}" />
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

Страницы отображаются в прокручиваемом списке:

Прокручиваемый список страниц

Обработчик в файле программной части активируется, когда пользователь выбирает элемент. Обработчик задает SelectedItem свойство задней null части, а затем создает экземпляр выбранной ListBox страницы и переходит к нему:

private async void OnListViewItemSelected(object sender, SelectedItemChangedEventArgs args)
{
    (sender as ListView).SelectedItem = null;

    if (args.SelectedItem != null)
    {
        PageDataViewModel pageData = args.SelectedItem as PageDataViewModel;
        Page page = (Page)Activator.CreateInstance(pageData.Type);
        await Navigation.PushAsync(page);
    }
}

Видео

Xamarin Evolve 2016: MVVM Сделано простым с Xamarin.Forms и Prism

Итоги

XAML — это мощный инструмент для определения пользовательских интерфейсов в Xamarin.Forms приложениях, особенно при использовании привязки данных и MVVM. Результатом является чистое, элегантное и потенциально инструментируемое представление пользовательского интерфейса со всеми фоновыми поддержками в коде.

Другие видео о Xamarin см. на Channel 9 и YouTube.