Совсем недавно мы опубликовали статью про особенности и проблемы популярного мобильного фреймворка Xamarin. Сегодня же мы продолжим рассказ и сосредоточимся на нюансах библиотеки Xamarin.Forms. Под катом вас ждёт история о том, какие грабли поджидают решившего сделать кроссплатформенный UI.
Базовые проблемы
Для начала, вёрстку можно готовить как в коде, так и в формате XAML. К сожалению, превью интерфейса в реальном времени вы посмотреть не сможете, хотя для нативных средств разработки такая возможность доступна. Поэтому мы выбрали разработку интерфейса из кода. Выглядеть это будет немного громоздко, но в целом — удобно:
public class LoginViewController: ContentPage { public LoginViewController() { Content = new StackLayout { Orientation = StackOrientation.Vertical, Children = { new Entry { Placeholder = "Эл. почта", Keyboard = Keyboard.Email, }, new Entry { Placeholder = "Пароль", IsPassword = true, }, new Button { Text = "Войти" }, new ActivityIndicator { IsRunning = true, IsVisible = false, } } }; } }
Далее, набор компонентов в Xamarin.Forms не очень большой. Не хватает таких, казалось бы, банальных вещей, как например «карусели» для кастомного содержимого. Есть полноэкранный контроллер-карусель, но нам нужен был подобный компонент, занимающий только часть экрана. Пришлось немного погнуть один из сторонних велосипедов.
У тех компонентов, что имеются в наличии, часто не хватает свойств или событий, имеющихся на iOS или Android. Может отсутствовать возможность поменять шрифт placeholder’а или цвет курсора, установить максимальную длину у текстового поля и так далее, подобные вещи приходится дописывать самостоятельно. В вышедшей в середине ноября 2015 года версии Xamarin.Forms 2.0 часть таких свойств добавлена, но до 100% покрытия всех возможностей нативных платформ ещё далеко.
Не радует и невозможность выставлять у всех компонентов отступы (padding и margin) — они есть только у контейнеров. Хотите у кнопки или поля ввода сделать отступ? Оберните её в контейнер:
new ContentView { Padding = new Thickness { Top = Sizes.StandartTopPadding Left = Sizes.StandartLeftPadding }, Content = new Label { Text ="Текст с отступами" } }
Но слишком глубокая иерархия замедляет процесс рендеринга, который у Forms в принципе несколько более медленней, чем у нативных компонентов. Особенно замедление заметно на списках, но и просто достаточно сложные формы для некоторых приложений могут стать серьёзной проблемой.
Не меньше радует, что часть возможностей реализована некорректно и это является “фичей”. Например, при использовании стандартной навигации в Android у контроллеров при переходе на новый экран не будет вызываться часть событий жизненного цикла, т.к. навигация происходит не по реальным экранам или фрагментам, а банальной сменой вьюшки в рамках одного физического экрана.
Баги
Так же у компонентов часто встречаются баги. Например, у ScrollView была проблема — при появлении клавиатуры можно было прокрутить скролл дальше, чем нужно, в область без контента.
Источник проблемы — содержимое ScrollView по высоте меньше, чем контейнер. Размеры области для прокрутки содержимого определяет вот такой код:
protected override void LayoutChildren(double x, double y, double width, double height) { //[...] ContentSize=new Size(width, Math.Max(height, Content.Bounds.Bottom + Padding.Bottom)); }
В результате появилась идея как быстро (и грязно) можно порешать проблему — создать наследника ScrollView с перекрытием нужного метода:
protected override void LayoutChildren(double x, double y, double width, double height) { //[...] //выкинули Max, размер контента всегда определяется размером контента ContentSize = new Size(width, Content.Bounds.Bottom + Padding.Bottom); }
Просто? Как бы не так — свойство ContentSize имеет приватный сеттер и в наследнике его значение просто так не изменить. Но раз уж мы пошли по кривой дорожке — всегда можно позвать на помощь рефлексию и таки изменить значение свойства.
public class ScrollViewCopycat : ScrollView { private readonly Action<Size> setContentSize; public ScrollViewCopycat() { var methodInfo = typeof(ScrollViewCopycat) .GetProperty("ContentSize", BindingFlags.Instance | BindingFlags.Public) .GetSetMethod(true); setContentSize = value => methodInfo.Invoke(this, new object[] { value }); } protected override void LayoutChildren(double x, double y, double width, double height) { //[...] setContentSize(new Size(width, Content.Bounds.Bottom + Padding.Bottom)); } }
В какой-то момент нас окончательно добил следующий баг: при изменении значения свойства видимости для пачки элементов управления (выставляли для нескольких полей на экране свойство IsVisible, одним в False, другим в True) элемент мог просто не появиться на экране! При этом он занимал своё месту в иерархии (в форме на экране появлялась дыра), но реально он оказался скрыт. Проблема возникала не только у нас, можно найти несколько обсуждений на форуме Xamarin — вот примеры раз или два.
Баг оказался плавающим, причем появился он в Xamarin.Forms 1.3.3.6323 и более поздних, проблема возникала из-за состояния гонки внутри самих Forms. Поэтому мы некотороые время оставались на более старой, но зато не имеющией этого бага версии — 1.3.1.6296. К сожалению в этой версии тоже имелись свои баги, исправленные в более поздних.
Так что в конце концов мы пришли к таком решению:
- у всех UI-контроллах, свойства которых мы хотим изменить, вызывается метод BatchBegin();
- меняем необходимые свойства;
- опять таки на всех контроллах вызываем BatchCommit().
public class Batch { private readonly ILayoutController visualElement; public Batch(ILayoutController visualElement) { this.visualElement = visualElement; } public IDisposable Begin() { var animatables = GatherAnimatables(visualElement).ToArray(); foreach (var animatable in animatables) animatable.BatchBegin(); return new ActionDisposable(() => { foreach (var animatable in animatables) animatable.BatchCommit(); }); } private static IEnumerable<IAnimatable> GatherAnimatables(ILayoutController root) { return root.Children.OfType<IAnimatable>() .Concat(root.Children.OfType<ILayoutController>().SelectMany(GatherAnimatables)); } }
Данный код не только решает упомянутую проблему, но и является рекомендуемым при изменении нескольких свойств компонента сразу. Скажем, если код написан так:
if (alert) { errorlabel.IsVibislbe = true; errorlabel.TextColor = Colors.Red; errorlabel.Text = AlertText; }
То компонент будет перерисован трижды, после каждого изменения свойства. А вот если обернуть его в BatchBegin/BatchCommit — перерисовка (и пересчёт размера) произойдёт только один раз, что позитивно скажется на скорости.
Бывают и другие баги, например, TextView может повлиять на размер своего контейнера, хотя у того выставлен параметр «растягиваться на всю ширину»:
Возникает это, если вертикальный контейнер лежит в другом контейнере с горизонтальной ориентацией.
Content=new StackLayout { Orientation = Orientation.Horizontal, BackgroundColor = Color.Green, Children = { new StackLayout { Orientation = StackOrientation.Vertical, VerticalOptions = LayoutOptions.FillAndExpand, HorizontalOptions = LayoutOptions.FillAndExpand, Children = { new Label { BackgroundColor = Color.Red, HorizontalOptions = LayoutOptions.FillAndExpand, } } } } }
Связь моделей и UI-компонентов (биндинг)
Встроенная поддержка двухстороннего биндинга между моделью и вьюшкой нас тоже не порадовала. Вот первый вариант указания связи:
public class Model1 { public string Text { get; private set; } public Model1 (string text) { Text = text; } } var label1 = new Label { BindingContext = new Model1("Hello, problems!") } label1.SetBinding(Label.TextProperty, "Text");
Если ошибиться, и вместо “Text” написать другое имя — то ни на этапе компиляции, ни в рантайме ничего не взорвётся. Просто Label отобразится без текста.
Есть конечно чуть лучший вариант установки связи:
label1.SetBinding<Model1>(Label.TextProperty, source => source.Text);
Но и он не спасает нас от ситуации, когда в Label будет помещён другой объект:
var label1 = new Label { BindingContext = new Model2(), };
В этом случае опять таки ничего при выполнении не упадёт.
Но и это ещё не всё. Если вам нужны взаимосвязанные поля в модели (когда при изменении одного изменяется и другое) — для работы UI придётся дописать немного довольно скучного кода — реализовать интерфейс INotifyPropertyChanged и самостоятельно сообщать список изменившихся полей:
public class Model : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged([CallerMemberName]string propertyName = null) { if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } private int value1; public int Value1 { get { return value1; } set { value1 = value; OnPropertyChanged(); OnPropertyChanged("Value2"); } } public int Value2 { get { return Value1*2; } } }
По этим причинам биндинг между моделью и контроллами мы написали свой — проверяющий соответствие типов полей, автоматически обновляющий связанные поля и т.п.
Списки
Ну и отдельная головная боль — списки. Начнём с мелочей: у списка есть заголовок и подвал (footer и header), этакие уникальные ячейки, которые прокручиваются вместе с обычными строчками. Это хорошо. Но при замене контента заголовка тот не пересчитывает свою высоту, если новый заголовок больше или меньше предшественника, а высота строк таблицы зафиксирована. Приходится делать это вручную
public interface IHeader { Layout GetView(); double GetHeight(); } public void SetHeaderForm(IHeader value) { value.GetView().Layout(new Rectangle(value.GetView().X, value.GetView().Y, Width, value.GetHeight())); list.Header = value; }
Если писать на нативных iOS компонентах — такой проблемы не возникнет, размер пересчитается сам.
Другой неприятный момент – “контекстные действия”. Это меню как правило вызывается на Android долгим тапом, а на iOS – свайпом по ячейке. Неприятность ситуации в том, что для этих контекстных действий в Xamarin.Forms используется объект MenuItem, имеющий среди всего прочего свойство Icon. Но в данных менюшках никакие иконки не отображаются. И это фича.
Так что для показа иконок мы задействовали Object-C библиотеку MGSwipeTableCell, вокруг которой написали свою обёртку. Правда в результате мы потеряли возможность автоматического изменения размера ячеек в списке – все они теперь должны быть строго одной высоты, т.к написание корректного сложного кастомного рендера ячейки не так просто, как кажется.
Ну и напоследок, хотя список в качестве источника данных принимае IEnumerable, “подгрузки по мере прокрутки” по-умолчанию нет — в момент определения источника компонент вычитывает данные до конца. Не то что бы мы сильно ждали подобного поведения, т.к.«из коробки» бесконечных списков нет ни в iOS ни в Android, но лёгкая надежда всё-таки была. Увы, компоненты Xamarin.Forms реализуют исключительно прожиточный минимум возможностей — всё остальное придётся дописывать самим.
Выводы
Стоит или нет использовать Xamarin.Forms – нам покажет следующий этап, перенос уже написанного под Android Java-проекта на Forms. Но уже сейчас мы можем сказать, что Xamarin.Forms стоит использовать только для максимально простого UI. Если в планах есть использование всех до единой фишек конкретной платформы или хитрые дизайнерские решения – Xamarin.Forms будет больше мешать, чем помогать. В этом варианте лучше использовать Xamarin исключительно для бизнес-логики, а вёрстку для каждой из платформ делать нативной.
Если у вас остались вопросы или есть замечания — с удовольствием ответим на них в комментариях.
ссылка на оригинал статьи http://habrahabr.ru/post/273161/
Добавить комментарий