Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2)

в 7:50, , рубрики: .net, .net core, C#, mobile developement, xamarin, xamarin.forms, Блог компании Developer Soft, Программирование, разработка мобильных приложений
Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2) - 1

Это вторая статья из цикла, где мы проводим сравнение Kivy, Xamarin.Forms и React Native. В ней я постараюсь написать такой же планировщик задач, но с использованием Xamarin.Forms. Посмотрю, как у меня это получится, и с чем мне придется столкнуться.

Повторять ТЗ я не буду, его можно посмотреть в первой статье: Kivy. Xamarin. React Native. Три фреймворка — один эксперемент

Для начала скажу пару слов о платформе Xamarin.Forms и о том, как я буду подходить к решению поставленной задачи. Xamarin.Forms является надстройкой над Xamarin.iOs и Xamarin.Android. После сборки общая часть “разворачивается” в стандартные нативные контролы, так что по сути вы получаете полностью нативные приложения под все поддерживаемые платформы.

Синтаксис Xamarin.Forms крайне близок к синтаксису WPF, а сама общая часть написана на .NET Standard. В результате вы получаете возможность использования MVVM подхода при разработке приложения, а также доступ к огромному количеству сторонних библиотек, написанных для .NET Standard и уже лежащих в NuGet, которые вы спокойно можете использовать у себя в Xamarin.Forms приложениях.

Исходные коды приведённого здесь приложения доступны на GitHub.

Итак, давайте создадим пустое Xamarin.Forms приложение и начнём. Модель данных у нас будет простая, всего два класса Note и Project:

public class Note {
    public string UserIconPath { get; set; }
    public string UserName { get; set; }
    public DateTime EditTime { get; set; }
    public string Text { get; set; }
}

public class Project {
    public string Name { get; set; }
    public ObservableCollection<Note> Notes { get; set; }

    public Project() {
        Notes = new ObservableCollection<Note>();
    }
}

Я буду стараться придерживаться MVVM подхода, но какую-то специальную библиотеку использовать не буду, чтобы не усложнять код. Все классы моделей и вью моделей будут реализовывать интерфейс INotifyPropertyChanged. Его реализацию в приводимых примерах кода я уберу для лаконичности.

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

public class MainViewModel {
    public ObservableCollection<Project> Projects { get; set; }

    public MainViewModel() {
        Projects = Project.GetTestProjects();
    }

    public void AddNewProject(string name) {
        Project project = new Project() { Name = name };
        Projects.Add(project);
    }

    public void DeleteProject(Project project) {
        Projects.Remove(project);
    }
}

Код самого экрана:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:local="clr-namespace:TodoList.View" x:Class="TodoList.View.ProjectsPage">
    <ContentPage.ToolbarItems>
        <ToolbarItem Clicked="AddNew_Clicked" Icon="plus.png"/>
    </ContentPage.ToolbarItems>

    <ListView ItemsSource="{Binding Projects}" ItemTapped="List_ItemTapped">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding Name}" TextColor="Black">
                    <TextCell.ContextActions>
                        <MenuItem Clicked="DeleteItem_Clicked" IsDestructive="true" CommandParameter="{Binding .}" Text="Delete"/>
                    </TextCell.ContextActions>
                </TextCell>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

Разметка получилась достаточно простая, единственное, на чем хочется остановиться — это реализация свайп-кнопок для удаления проектов. В ListView есть понятие ContextActions, если его задать, то в iOS они будут реализованы через свайп, в Android — через длинный тап. Данный подход реализован в Xamarin.Forms, ибо он является нативным для каждой из платформ. Однако если мы захотим свайп в андроиде, нам надо будет руками реализовывать его в нативной части андроида. У меня нет задачи тратить много времени на это, поэтому я удовлетворился стандартным подходом :) В результате свайп в iOS и контекстное меню в Android реализуются достаточно просто:

<TextCell.ContextActions>
    <MenuItem Clicked="DeleteItem_Clicked" IsDestructive="true" CommandParameter="{Binding .}" Text="Delete"/>
</TextCell.ContextActions>

Подставив тестовые данные, получаем вот такой список:
Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2) - 2

Теперь перейдём к обработчику событий. Начнём с простого — удаления проекта:

MainViewModel ViewModel { get { return BindingContext as MainViewModel; } }

async Task DeleteItem_Clicked(object sender, EventArgs e) {
    MenuItem menuItem = sender as MenuItem;
    if (menuItem == null)
        return;

    Project project = menuItem.CommandParameter as Project;
    if (project == null)
        return;

    bool answer = await DisplayAlert("Are you sure?", string.Format("Would you like to remove the {0} project", project.Name), "Yes", "No");
    if(answer)
        ViewModel.DeleteProject(project);
}

Нехорошо удалять что-то без вопроса пользователю, и в Xamarin.Forms это элементарно сделать, используя штатный метод DisplayAlert. После его вызова покажется следующее окошко:
Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2) - 3

Данное окошко из iOs. На Android будет свой вариант подобного окна.

Следующим реализуем добавление нового проекта. Казалось бы, это делается по аналогии, но в Xamarin.Forms нет реализации диалога, подобного тому, которым я подтверждал удаление, но позволяющего вводить текст. Варианта решения есть два:

  • написать свой сервис, который будет поднимать нативные диалоги;
  • реализовать какой-то воркэраунд на стороне Xamarin.Forms.

Мне не хотелось тратить время на поднятие диалога через натив, и я решил воспользоваться вторым подходом, реализацию которого взял из треда: How to do a simple InputBox dialog?, а именно метод Task InputBox(INavigation navigation).

async Task AddNew_Clicked(object sender, EventArgs e) {
    string result = await InputBox(this.Navigation);
    if (result == null)
        return;

    ViewModel.AddNewProject(result);
}

Теперь обработаем тап по строкам, для открытия проекта:

void List_ItemTapped(object sender, Xamarin.Forms.ItemTappedEventArgs e) {
    Project project = e.Item as Project;
    if (project == null)
        return;

    this.Navigation.PushAsync(new NotesPage() { BindingContext = new ProjectViewModel(project) });
}

Как видно из кода выше, чтобы перейти на окно проекта, нам нужны его view model и объект page окна.

Хотелось бы сказать пару слов про Navigation. Свойство Navigation определяется в VisualElement class, и позволяет работать с навигационной панелью в любой view вашего приложения без прокидывания её туда руками. Однако, чтобы этот подход работал, создать данную панель всё-таки надо самому. Поэтому в App.xaml.cs напишем:

NavigationPage navigation = new NavigationPage();
navigation.PushAsync(new View.ProjectsPage() { BindingContext = new MainViewModel() });
MainPage = navigation;

Где ProjectsPage — это как раз то окно, которое я сейчас описываю.

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

Разметка этого окна получилась посложнее, ибо отображать каждая строка должна больше информации:

Notes View

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="TodoList.View.NotesPage"
             xmlns:local="clr-namespace:TodoList.View"
             xmlns:utils="clr-namespace:TodoList.Utils"
             Title="{Binding Project.Name}">
    <ContentPage.Resources>
        <ResourceDictionary>
            <utils:PathToImageConverter x:Key="PathToImageConverter"/>
        </ResourceDictionary>
    </ContentPage.Resources>
    <ContentPage.ToolbarItems>
        <ToolbarItem Clicked="AddNew_Clicked" Icon="plus.png"/>
    </ContentPage.ToolbarItems>

    <ListView ItemsSource="{Binding Project.Notes}" x:Name="list" ItemTapped="List_ItemTapped" HasUnevenRows="True">
        <ListView.ItemTemplate>
            <DataTemplate>
                <ViewCell>
                    <local:MyCellGrid Margin="5">
                        <local:MyCellGrid.RowDefinitions>
                            <RowDefinition Height="40"/>
                            <RowDefinition Height="*"/>
                        </local:MyCellGrid.RowDefinitions>
                        <local:MyCellGrid.ColumnDefinitions>
                            <ColumnDefinition Width="40"/>
                            <ColumnDefinition Width="*"/>
                            <ColumnDefinition Width="40"/>
                        </local:MyCellGrid.ColumnDefinitions>
                        <Image Grid.Row="0" Grid.Column="0" Source="{Binding UserIconPath, Converter={StaticResource PathToImageConverter}}" />
                        <StackLayout Grid.Row="0" Grid.Column="1">
                            <Label Text="{Binding UserName}" FontAttributes="Bold"/>
                            <Label Text="{Binding EditTime}"/>
                        </StackLayout>
                        <Button Grid.Row="0" Grid.Column="2" BackgroundColor="Transparent" Image="menu.png" Margin="5" HorizontalOptions="FillAndExpand" Clicked="RowMenu_Clicked"/>
                        <local:MyLabel Grid.Row="1" Grid.Column="1" Margin="0,10,0,0" Grid.ColumnSpan="2" Text="{Binding Text}"/>
                    </local:MyCellGrid>

                    <ViewCell.ContextActions>
                        <MenuItem Clicked="DeleteItem_Clicked" IsDestructive="true" CommandParameter="{Binding .}" Text="Delete"/>
                    </ViewCell.ContextActions>
                </ViewCell>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

В контенте окна у нас опять лежит ListView, прибинженная к коллекции заметок. Однако мы хотим высоту ячеек по контенту, но не более 150, для этого выставим HasUnevenRows=«True», чтобы ListView позволил ячейкам занимать столько места, сколько они попросят. Но в такой ситуации строки могут запросить высоту более 150 и ListView им позволит так отобразиться. Чтобы этого избежать в ячейке я использовал своего наследника Grid панели: MyCellGrid. Данная панель на операции measure запрашивает высоту внутренних элементов и возвращает ее либо 150, если она больше:

public class MyCellGrid : Grid {
    protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint) {
        SizeRequest sizeRequest = base.OnMeasure(widthConstraint, heightConstraint);
        if (sizeRequest.Request.Height <= 150)
            return sizeRequest;
            
        return new SizeRequest(new Size() { Width = sizeRequest.Request.Width, Height = 150 });
    }
}

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

<Button Grid.Row="0" Grid.Column="2" BackgroundColor="Transparent" Image="menu.png" Margin="5" HorizontalOptions="FillAndExpand" Clicked="RowMenu_Clicked"/>

С тестовыми данными наша форма выглядит вот так:
Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2) - 4

Обработка пользовательский действий в этой форме полностью аналогична той, которая писалась для окна списка проектов. Остановиться хочется только на контекстном меню по нашей кнопке в углу строки. Сначала я думал, что без проблем это сделаю на уровне Xamarin.Forms. Действительно, нам надо всего лишь создать view примерно такого вида:

<StackLayout>
    <Button Text=”Edit”/>
    <Button Text=”Delete”/>
</StackLayout>

И показывать её рядом с кнопкой. Однако проблема в том, что мы не можем точно узнать, где это “рядом с кнопкой”. Данное контекстное меню должно быть расположено поверх ListView и, при открытии, позиционироваться в координатах окна. Для этого надо знать координаты нажатой кнопки относительно окна. Мы же можем получить координаты кнопки только относительно внутреннего ScrollView, расположенного в ListView. Так что когда строки не сдвинуты, то все нормально, но когда строки проскроллированы, мы должны учитывать то, на сколько произошел скролл при расчете координат. ListView нам не отдает величину скролла. Так что его надо вытягивать из натива, что делать очень не хотелось. Поэтому я решил пойти по пути более стандартному и простому: показать стандартное системное контекстное меню. В результате обработчик нажатия на кнопку получится следующий:

async Task RowMenu_Clicked(object sender, System.EventArgs e) {
    string action = await DisplayActionSheet("Note action:", "Cancel", null, "Edit", "Delete");
    if (action == null)
        return;
            
    BindableObject bindableSender = sender as BindableObject;
    if(bindableSender != null) {
        Note note = bindableSender.BindingContext as Note;
        if (action == "Edit") {
            EditNote(note);
        } else if(action == "Delete") {
            await DeleteNote(note);
        }
    }
}

Вызов метода DisplayActionSheet как раз и показывает штатное контекстное меню:
Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2) - 5

Если вы заметили, текст заметки у меня выводится в моем контроле MyLabel, а не в штатном Label. Это сделано вот для чего. Когда пользователь изменяет текст заметки, срабатывает биндинг, и в Label автоматически прилетает новый текст. Однако Xamarin.Forms не пересчитывает размер ячейки при этом. Разработчики Xamarin заявляют, что это достаточно дорогостоящая операция. Да и у самого ListView нет какого-то метода, который заставил бы его пересчитать свой размер, InvalidateLayout тоже не помогает. Единственное, что у них для этого есть, это метод ForceUpdateSize у объекта Cell. Поэтому, чтобы до него добраться и в нужный момент дёрнуть, я написал свой наследник Label и дёргаю этот метод на каждое изменение текста:

public class MyLabel : Label {
    protected override void OnPropertyChanged([CallerMemberName] string propertyName = null) {
        base.OnPropertyChanged(propertyName);

        if (propertyName == "Text") {
            ((this.Parent as MyCellGrid).Parent as Cell).ForceUpdateSize();
        }
    }
}

Теперь после редактирования заметки ListView автоматически поправит размер ячейки под новый текст.

При редактировании или создании новой заметки открывается окно с Editor в контенте и кнопкой Save на тулбаре:
Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 2) - 6

Данное окно немного отличается от того, что у нас в ТЗ: отсутствием круглой кнопки снизу. Если её расположить просто поверх редактора, то она будет перекрыта выезжающей клавиатурой. Красивого решения как её подвинуть и не уходить при этом в натив быстрым поиском я не нашёл. Поэтому убрал её и оставил только кнопку Save в верхней панели. Само по себе данное окно очень простое, так что его описание я опущу.

Что хочется сказать в итоге.

Xamarin.Forms хорошо подойдёт тем, кто хорошо знаком с инфраструктурой .NET и давно с ней работает. Им не придётся переходить на новые IDE и фреймворки. Как видно, код приложения мало чем отличается от кода любого другого XAML based приложения. К тому же Xamarin позволяет разрабатывать и билдить iOS приложения в Visual Studio под Windows.

Для того чтобы начать писать приложения на Xamarin.Forms, вам не надо никакого красноглазия с консолью. Просто ставите Visual Studio и пишете приложения. Обо всём остальном за вас уже позаботились. При этом, как бы Microsoft не ассоциировался с платными продуктами, Xamarin бесплатен и есть бесплатные версии Visual Studio.

То, что Xamarin.Forms под капотом использует .NET Standard, даёт доступ к куче библиотек, уже написанных под него, которые будут облегчать жизнь при разработке своих приложений.

Xamarin.Forms позволяет без особых трудностей дописывать что-то в нативных частях вашего приложения, если требуется реализовать что-то платформоспецифичное. Там вы получаете тот же C#, но уже API родное для каждой из платформ.

Однако, конечно же, не обошлось и без недостатков.

API, доступное в общей части, достаточно скудное, ибо содержит в себе только то, что является общим для всех платформ. Например, как видно в моём примере, все платформы содержат alert-сообщения и контекстные меню, и эта вещь доступна в Xamarin.Forms. Однако стандартное меню, позволяющее ввести текст, доступно лишь в iOS, поэтому в Xamarin.Forms его нет.

Так же подобные ограничения встречаются и в использовании компонентов. Что-то сделать можно, что-то нельзя. Тот же свайп для удаления проекта или заметки работает лишь в iOS. В Android данный context action будет представлен в виде меню, показывающемся на длинном тапе. А если хочется свайп в андроиде, то welcome в андроид часть и писать это руками.

Ну и конечно же производительность. Скорость работы приложения на Xamarin.Forms в любом случае будет ниже скорости работы нативного приложения. Так что сам Microsoft заявляет, что если вам надо приложение без особых изысков в плане дизайна и требований к производительности, то Xamarin.Forms для вас. Если нужны красивости или скорость, то тут надо уже опускаться в натив. Благо Xamarin имеет версии и под натив, которые уже оперируют сразу родным платформенным API и работают быстрее, чем формсы.

Автор: Mirimon

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js