Мій досвід з MAUI. Створюємо прапорець з текстом за допомогою Handlers

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

Вітаю! Мене звати Ігор, я маю деякий досвід у розробці мобільних застосунків за допомогою MAUI. Сьогодні ми продовжимо розглядати MAUI на прикладах.

У минулій статті ми додавали текст для стандартного прапорця і використовували для цього ContentView, у якому розміщували Grid або HorizontalStackLayout з CheckBox та Label всередині.

Рисунок 1 — Схема прапорця з текстом, що оснований на ContentView

Головним плюсом такого підходу є повна кросплатформність. Тобто ми створюємо цей компонент, використовуючи готові абстрактні елементи керування. Такий прапорець із текстом буде виглядати майже однаково для всіх платформ, і нам не доведеться працювати з нативною частиною.

Мінусом є те, що використано багато компонентів. Потенційно це може погано вплинути на швидкодію. Наприклад, на сторінці ми додали CollectionView, який у шаблоні має ось такий складений із декількох частин прапорець. Під час гортання переліку застосунок буде повільніше малювати ось такий складений прапорець, аніж нативний прапорець із текстом.

Рисунок 2. Схема відображення прапорця з текстом в CollectionView

Тому наша ціль сьогодні використати повністю нативну реалізацію прапорця з текстом. Це дозволить значно спростити ієрархію відображення елементів. Мінусом буде те, що доведеться для кожної платформи реалізовувати відображення тексту власноруч.

Унаслідок цього схема буде приблизно така:

Рисунок 3. Спрощена схема CollectionView та нативної реалізації прапорця з текстом

Насправді справжня схема буде трохи іншою, але про це згодом.

Отже, як ми знаємо з першої статті, нам потрібно створити загальний клас для прапорця, загальний Handler з мапінгом властивостей і Handlers для кожної платформи.

Створення загального класу

Додамо код:

using Font = Microsoft.Maui.Font;

namespace AndriyCo.Tcu.MauiClient.Controls;

public interface IMaterialCheckBox : ICheckBox, IText

{

}

public class MaterialCheckBox : CheckBox, IMaterialCheckBox

{

    public static readonly BindableProperty TextProperty =

        BindableProperty.Create(nameof(Text), typeof(string), typeof(IText), default);

    public static readonly BindableProperty TextColorProperty =

        BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(IText), default);

    public static readonly BindableProperty FontProperty =

        BindableProperty.Create(nameof(Font), typeof(Font), typeof(IText), default);

    public static readonly BindableProperty CharacterSpacingProperty =

        BindableProperty.Create(nameof(CharacterSpacing), typeof(double), typeof(IText), default);

    public string Text

    {

        get => (string)GetValue(TextProperty);

        set => SetValue(TextProperty, value);

    }

    public Color TextColor

    {

        get => (Color)GetValue(TextColorProperty);

        set => SetValue(TextColorProperty, value);

    }

    public Font Font

    {

        get => (Font)GetValue(FontProperty);

        set => SetValue(FontProperty, value);

    }

    public double CharacterSpacing

    {

        get => (double)GetValue(CharacterSpacingProperty);

        set => SetValue(CharacterSpacingProperty, value);

    }

}

Ми створили клас MaterialCheckBox і інтерфейс IMaterialCheckBox, які наслідуються від стандартних CheckBox та ICheckBox відповідно.

Додали властивості для відображення тексту й тепер можемо переходити до створення Handlers.

Створення Handlers

Створимо загальний Handler:

using Microsoft.Maui.Handlers;

#if IOS || MACCATALYST

using PlatformView = Microsoft.Maui.Platform.MauiCheckBox;

#elif ANDROID

using PlatformView = Google.Android.Material.CheckBox.MaterialCheckBox;

#elif WINDOWS

using PlatformView = Microsoft.UI.Xaml.Controls.CheckBox;

#elif NET || (NET6_0 && !IOS && !ANDROID && !TIZEN)

using PlatformView = System.Object;

#endif

namespace AndriyCo.Tcu.MauiClient.Handlers;

public interface IMaterialCheckBoxHandler : ICheckBoxHandler

{

    new IMaterialCheckBox VirtualView { get; }

    new PlatformView PlatformView { get; }

}

public partial class MaterialCheckBoxHandler : IMaterialCheckBoxHandler

{

    public new IMaterialCheckBox VirtualView => (IMaterialCheckBox)base.VirtualView;

    PlatformView IMaterialCheckBoxHandler.PlatformView => PlatformView;

    public static IPropertyMapper<IMaterialCheckBox, IMaterialCheckBoxHandler> TextMapper = new PropertyMapper<IMaterialCheckBox, IMaterialCheckBoxHandler>(Mapper)

    {

        [nameof(IMaterialCheckBox.Text)] = MapText,

        [nameof(IMaterialCheckBox.TextColor)] = MapTextColor,

    };

    public MaterialCheckBoxHandler() : base(TextMapper)

    {

    }

    public MaterialCheckBoxHandler(IPropertyMapper mapper, CommandMapper commandMapper = null) : base(mapper, commandMapper)

    {

    }

}

У блоці using ми зазначили, чим буде PlatformView для кожної платформи. Створили інтерфейс та partial клас MaterialCheckBoxHandler для Handler і додали мапінг властивостей до методів, що змінюють ці властивості на нативному рівні.

Handler для Android

Реалізація тексту для прапорця доволі проста:

using Microsoft.Maui.Handlers;

using Microsoft.Maui.Platform;

namespace AndriyCo.Tcu.MauiClient.Handlers;

public partial class MaterialCheckBoxHandler : CheckBoxHandler

{

    private static void MapText(IMaterialCheckBoxHandler handler, IMaterialCheckBox virtualView) =>

        handler.PlatformView.Text = virtualView.Text;

    private static void MapTextColor(IMaterialCheckBoxHandler handler, IMaterialCheckBox virtualView) =>

        handler.PlatformView.UpdateTextColor(virtualView);

}

Створюємо клас MaterialCheckBoxHandler.Android і реалізуємо методи MapText і MapTextColor для Android-прапорця.

Handler для iOS

Реалізація для iOS трохи складніша. Для цієї платформи Microsoft створили власний клас MauiCheckBox, який наслідується від нативного UIButton. Це доволі поширена в них практика: так вони роблять свою надбудову над нативними елементами, щоб потім зручніше використовувати їх у Handlers. Подібне ми також робили у першій статті, створивши клас-обгортку AlertDialogView над класом-builder MaterialAlertDialogBuilder.

Ось такий вигляд має MauiCheckBox:

public class MauiCheckBox : UIButton

{

    public EventHandler? CheckedChanged;

    public MauiCheckBox();

    public bool IsChecked { get; set; }

    public bool IsEnabled { get; set; }

    public Color? CheckBoxTintColor { get; set; }

    public override bool Enabled { get; set; }

    public override UIAccessibilityTrait AccessibilityTraits { get; set; }

    public override string? AccessibilityValue { get; set; }

    public override void LayoutSubviews();

    public override CGSize SizeThatFits(CGSize size);

    protected override void Dispose(bool disposing);

    protected virtual UIImage GetCheckBoxImage();

}

У нативному прапорці для iOS відсутня властивість Text чи щось подібне. Тому доведеться додати Label до MauiCheckBox, що існує.

Створюємо клас MaterialCheckBoxHandler.iOS:

using AndriyCo.Tcu.MauiClient.Controls;

using CoreGraphics;

using Microsoft.Maui.Handlers;

using Microsoft.Maui.Platform;

using UIKit;

namespace AndriyCo.Tcu.MauiClient.Handlers;

public partial class MaterialCheckBoxHandler : CheckBoxHandler

{

    protected UILabel Label { get; private set; }

    protected override MauiCheckBox CreatePlatformView()

    {

        MauiCheckBox mauiCheckBox = base.CreatePlatformView();

        Label = new UILabel(GetPositionForLabel(mauiCheckBox));

        Label.Lines = 3;

        Label.LineBreakMode = UILineBreakMode.WordWrap;

        Label.MinimumScaleFactor = 14;

        mauiCheckBox.Add(Label);

    }

    private CGRect GetPositionForLabel(MauiCheckBox mauiCheckBox) =>

        new(mauiCheckBox.ImageView.Bounds.Width + mauiCheckBox.ImageView.LayoutMargins.Right * 2, 0, mauiCheckBox.Bounds.Width - mauiCheckBox.ImageView.Bounds.Width, mauiCheckBox.Bounds.Height);

    private static void MapText(IMaterialCheckBoxHandler handler, IMaterialCheckBox virtualView)

    {

        if (!(handler is MaterialCheckBoxHandler checkBoxHandler))

            return;

        checkBoxHandler.Label.Text = virtualView.Text;

    }

    private static void MapTextColor(IMaterialCheckBoxHandler handler, IMaterialCheckBox virtualView)

    {

        if (!(handler is MaterialCheckBoxHandler checkBoxHandler))

            return;

        checkBoxHandler.Label.TextColor = virtualView.TextColor.ToPlatform();

    }

}

Ми перевизначили метод CreatePlatformView, у якому додатково зробили UILabel і додали до створеного MauiCheckBox.

У методах мапінгу для зміни тексту ми тепер звертаємось до Label.

До речі, саме цей спосіб додавання тексту до прапорця для iOS я не тестував на MAUI. Колись я робив за схожим принципом для Xamarin, і все працювало. Думаю, шанс, що такий варіант не спрацює для MAUI, дуже малий. У будь-якому разі принцип саме такий: на рівні Handler якимось способом прив’язати нативний прапорець до нативного UILabel, а потім в Handler використати властивості UILabel для зміни тексту.

Узагалі, можна було б зробити трохи по-іншому: створити окремий клас TextMauiCheckBox (або MauiTextCheckBox), у ньому додати UILabel, а потім використовувати його в Handler як окремий нативний елемент.

Орієнтовний приклад: у теці Platforms -> iOS додамо клас MauiTextCheckBox:

using CoreGraphics;

using Microsoft.Maui.Platform;

using UIKit;

namespace AndriyCo.Tcu.MauiClient.Platforms.iOS;

public class MauiTextCheckBox : MauiCheckBox

{

    protected UILabel Label { get; set; }

    public string Text

    {

        get => Label.Text;

        set => Label.Text = value;

    }

    public UIColor TextColor

    {

        get => Label.TextColor;

        set => Label.TextColor = value;

    }

    public override void LayoutSubviews()

    {

        base.LayoutSubviews();

        SetupLabel();

    }

    private void SetupLabel()

    {

        if (Label != null)

            return;

        CGRect labelPosition = GetLabelPosition();

        Label = new UILabel(labelPosition)

        {

            Lines = 3,

            LineBreakMode = UILineBreakMode.WordWrap,

            MinimumScaleFactor = 14

        };

        Add(Label);

    }

    private CGRect GetLabelPosition() =>

        new(ImageView.Bounds.Width + ImageView.LayoutMargins.Right * 2, 0, Bounds.Width - ImageView.Bounds.Width, Bounds.Height);

}

Ми створили окремий клас, де описали позиціонування та створення мітки з текстом. Тепер буде простіше використовувати цей клас у Handler.

У загальному Handler потрібно вказати таке:

#if IOS || MACCATALYST

using PlatformView = AndriyCo.Tcu.MauiClient.Platforms.iOS.MauiTextCheckBox;

А також зробити перетворення:

PlatformView IMaterialCheckBoxHandler.PlatformView => (PlatformView)PlatformView;

Спеціалізований Handler для iOS:

using AndriyCo.Tcu.MauiClient.Controls;

using AndriyCo.Tcu.MauiClient.Platforms.iOS;

using Microsoft.Maui.Handlers;

using Microsoft.Maui.Platform;

using UIKit;

namespace AndriyCo.Tcu.MauiClient.Handlers;

public partial class MaterialCheckBoxHandler : CheckBoxHandler

{

    protected override MauiCheckBox CreatePlatformView() => new MauiTextCheckBox();

    private static void MapText(IMaterialCheckBoxHandler handler, IMaterialCheckBox virtualView) =>

        handler.PlatformView.Text = virtualView.Text;

    private static void MapTextColor(IMaterialCheckBoxHandler handler, IMaterialCheckBox virtualView) =>

        handler.PlatformView.TextColor = virtualView.TextColor.ToPlatform();

}

Тепер він має такий самий вигляд, як і в Android.

Використання готового елемента

Реєструємо Handler в класі MauiProgram:

MauiAppBuilder builder = MauiApp.CreateBuilder();

builder.ConfigureMauiHandlers(handlers =>

{

    handlers.AddHandler(typeof(MaterialCheckBox), typeof(MaterialCheckBoxHandler));

});

І тепер можна додавати MaterialCheckBox до наших сторінок.

Візьмемо будь-яку сторінку з проєкту. Не забуваймо додати простір імен:

<ContentPage …

             xmlns:controls="clr-namespace:AndriyCo.Tcu.MauiClient.Controls"

             …>

Додаємо прапорці:

<VerticalStackLayout Padding="10"

                     Spacing="5">

    <controls:MaterialCheckBox Text="Наслідувати націнку верхньої групи"

                               IsChecked="{Binding IsInheritedMarkup, Mode=TwoWay}"/>

…

    <controls:MaterialCheckBox Text="Наслідувати перелік цін від верхньої групи"

                               IsChecked="{Binding IsInheritedPriceList, Mode=TwoWay}"/>

</VerticalStackLayout

Результат:


SEQ Рисунок *ARABIC. Прапорці з текстом в Android

Висновки

Отже, у цій та попередній статтях я показав два способи створення власних елементів керування. Перший — використання ContentView зі стандартними елементами керування; другий — використання нативних елементів керування через механізм Handlers.

І в першому, і в другому випадках відображено нативні елементи, бо так працює MAUI. Але в першому способі ми використовуємо більше абстракцій, тому застосунок може витрачати більше ресурсів.

З цієї схеми видно, що прапорець із текстом складається з ContentView, у якому перебуває Grid або StackLayout для позиціонування двох окремих елементів: прапорця та мітки з текстом. У свою чергу прапорець та мітка — це два окремих елементи на нативному рівні.

Така схема є більш витратною за ресурсами. Grid або StackLayout має вирахувати розміри та позицію внутрішніх елементів. Окрім того, це все ще й розташовується в ContentView, який теж має власну поведінку відображення.

У переліках, наприклад, бажано не використовувати багато рівнів відображення, інакше існує ризик затримок під час гортання.

Другий спосіб створення власних компонентів кращий за швидкодією, але його потрібно реалізувати для кожної платформи.

У цьому випадку на загальному рівні у нас лише один клас з необхідним API для керування прапорцем. На відміну від минулого способу, де на цьому рівні використовуються ContentView + Grid (StackLayout) + CheckBox + Label. Другий спосіб спрощує ієрархію елементів, що добре впливає на швидкодію.

Переважно я надаю перевагу використанню Handlers для створення власних компонентів або розширенню тих, що існують. Але іноді зручніше використовувати ContentView з готовими компонентами. Наприклад, у мене в проєкті є BreadCrumb (навігаційна панель), який є ContentView та містить в собі Grid та CollectionView. Створювати такий комплексний візуальний компонент було б складно на нативному рівні, і таке рішення повторювало б багато функціоналу зі стандартного CollectionView.

Додатково зазначу те, що хоч MAUI і має свої недоліки, про які неодноразово згадували в коментарях, але ця технологія надає гнучкий функціонал для створення застосунків. Так, деякі компоненти не мають достатньо функцій. Але у вас є змога зробити те, що вам потрібно, декількома способами. MAUI містить в собі всі недоліки кросплатформових технологій, але також надає майже необмежений доступ до нативних функцій.

Отже, до наступних зустрічей!

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Я правильно розумію, що все описане в цій статті — показати потужність MAUI на прикладі оптимізації, коли в застосунку виявилось забагато елементів, які сповільнює роботу застосунку?

Ні, це більше як наслідок. Я хотів показати способи модифікації стандартних елементів керування на власному досвіді.

Дякую за відповідь. Із статті склалось враження, що оте все обовʼязково треба робити.

Користуюсь такую штуковиною github.com/...​indableproperty-generator
Значно зменшує кількість коду в MAUI компоненті.

В теорії, було б непогано мати щось таке і для хендлерів, але поки такого не бачив.

Вы себе добавляете таким образом дополнительную область слома вашей системы — не рекомендую импользовать кодогенераторы так как вы никак не можете повлиять на код что сгенерирован ими

Ну так само можна сказати про будь-яку лібу) Ви ніяк не зможете вплинути на код який був згенерований розробником і запакований в nuget, якщо цей вплив не продуманий самим розробником.

А взагалі для цього є issues на гітхабі де можна описати проблеми що виникають. Крім того подібний генератор можна самому написати за пару вечорів.

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

Здається мені ви не зовсім розумієте як працюють подібні кодогенератори. Це точно така ж ліба як і будь-яка інша. В ній є C# код який генерить інший C# код. Якщо є проблема, то можна зробити ПР який починить генерацію і після обновлення версії ви отримаєте новий згенерований код уже без помилки.

Но на поиск этой ошибки у вас уйдет на порядок больше времени так как вам надо искать ошбику не в вашем коде а в коде генератора по логам, и не факт что ее найдете потому-что рефлексия в которой черт ногу сломит

Може бути, якщо це закладено у код, який генерує кодогенератор)

Пан, походу, сплутав фоді та інші cecil-based штуки з кодогенераторами)

Кодогенератор генерує код, який видно прямо зі студії і є навігація через partial класи.
Якщо там буде помилка, її буде видно або під час компіляції або під час роботи додатку. Просто так воно не зламається, воно може зламатись або у конкретному кейсі або після апдейту ліби (поведінка десь кардинально змінилась), і далі варіанти, як це пофіксити можуть включати, але не обмежуватись: фіксом кодогенератора, не використовувати кодогенератор у конкретному кейсі, не оновлювати лібу.

Покажіть приклад. Бо не зовсім зрозуміло про що мова.

Нічого подібного.
Якщо у конкретному кейсі не працює — значить можна для цього кейса не юзати його.
Якщо не працює для всього або раптом перестало працювати — тут два варіанти, або відкотитись на попередню версію, або пофіксити кодогенератор ручками.

у мене кастомний кодогенератор у аплікухах парсить конфіг з json та пхає у статік клас, все що б швидше стартувало та працювало...
Загалом, доволі зручно вийшло.

=)
На минулому проекті я використовував ZXing для MAUI. В основному для сканування штрих-кодів, тому що це був проект складського обліку. Іноді доводилось і показувати штрих-коди. ZXing дуже добре сканував, але відображення штрих-кодів на той момент було не дуже стабільним. Іноді падав на спробі намалювати якийсь штрих-код (з правильною контрольною сумою).
Якщо не секрет, як ви малютєте штрих-коди? Skia чи щось нативне?
До речі, ваша бібліотека платна? Я щось не побачив ціни.

Для малювання використовується Aspose.Drawing.Common на чистому .Net без використання системных. Так платна, й досить коштовна (від 1000$). Хоча є тімчасові ліцензії, котрі треба оновлювати кожен місяць.

Я не стомлюсь бухтіти на флаттер за його багованість і сирість.
Але коли бачу таке то флаттер вже не такий і бідовий

Можете конкретизувати що вам не сподобалося?
Якщо що то для MAUI є кілька варіантів використання як комбінації так і вкладення кількох готових «контролів», окрім того є готові від DevExpress, і головне є Blazor що дозволяє шарити mobil фронт з web. Загалом з MVVM можете робити все що завгодно.

Виглядає як дуже багато коду для чекбокса

Можливо автор обрав не дуже показовий приклад. Для чекбоксу в загальному випадку нічого не треба кодити окрім самої логіки програми. Або нічого не треба розмічати, це залежить від підходу.

Моє недоопрацювання. Схоже, потрібно краще пояснювати цілі статті.

Главная проблема — вы решили показать полезность фреймворка изобретая уже готовый контролл

теперь все думают что для базовых вещей обязательно делать столько ненужных телодвижений

Для чекбокса багато коду не треба. Його взагалі не треба. Але стандартний чекбокс не має тексту. Тому доводиться додавати Label поруч з чекбоксом. Щоб не робити цю нудну роботу, можна інкапсулювати Label і чекбокс в один компонент. І можна зробити це двома шляхами.
Перший (повністю кросплатформовий) описується в попередній статті dou.ua/forums/topic/43481
Другий (через нативні компоненти) описується в цій статті.
Як я написав нижче, наступного разу спробую більш чітко формулювати цілі статті.
Дякую за відгук.

Прочел первую вашу статью — думаю этого достаточно для этой задачи. ИМХО лезть в натив нужно только в крайнем случае когда никак иначе проблему не решить

Підписатись на коментарі