Xamarin і Xamarin.Forms – кактус в шоколаді. Частина 2

Зовсім недавно ми опублікували статтю про особливості і проблеми популярного мобільного фреймворку 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 і більш пізніх, проблема виникала через конфлікт всередині самих Форм. Тому ми деякі час залишалися на більш старої, але зате не имеющией цього бага версії — 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 виключно для бізнес-логіки, а верстку для кожної з платформ робити нативної.

Якщо у вас залишились запитання або зауваження — із задоволенням відповімо на них в коментарях.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.