Automaticky generované GUI pomocí reflexe

Seznam článků

Reflexe umožňuje získávat metainformace o třídách, jejich vlastnostech, metodách i událostech a ty pak používat, aniž bychom museli vědět či deklarovat, o jakou konkrétní třídu se vlastně jedná. Při vhodném použití této technologie pak můžeme generovat tabulkové přehledy dat a formuláře pro editaci záznamů univerzálním způsobem tak, že jeden kód dokáže vygenerovat funkční GUI pro instanci libovolné třídy. V informačním systému používajícím tento princip pak není třeba programovat stovky oken, ale pouze vhodně nedeklarovat třídy a propojit je s databází, což víceméně automatizovaně obstará LINQ to SQL. 

Reflexe

Třídy, struktury, výčty apod. většinou deklarujeme a používáme přímo pro konkrétní případy použití. To je samozřejmě v pořádku. Vytvoříme si například třídu Osoba s vlastnostmi jako Jmeno, Prijmeni, DatumNarozeni atd. (viz kód níže), pro načtení hodnot do instancí této třídy, ať již zadáním, ze souboru, z databáze, z internetu..., použijeme konstruktor (var osoba = new Osoba();) a přímý přístup k jejím vlastnostem (např. osoba.Jmeno = "Alois";). 

namespace Reflexe
{
    public class Osoba
    {
        public string Jmeno { get; set; }
        public string Prijmeni { get; set; }
        public DateTime DatumNarozeni { get; set; }
        public int Vyska { get; set; }
    }
}

Pro zobrazení tohoto seznamu pak vytvoříme zvláštní okno (nebo webovou stránku, mobilní obrazovku atd.) s tabulkou (Grid, Table...) či přehledem (ListView, DataList...) a pro zobrazení detailních informací popř. editaci konkrétního záznamu nějaký formulář (form, FormView, DetailView, Grid či StackLayout s editory; záleží na použité technologii), s individuálním editorem pro každou vlastnost. Máme-li nějaký rozsáhlejší systém třeba s dvaceti takovýmito třídami, pak je pro každou z nich obvykle nezbytné tato dvě okna (přehled a detail) navrhnout, vytvořit, odladit a udržovat. To už je nějakých 40 oken (či stránek) + úvodní nabídka s menu popř. nějaká pokročilejší navigace. Co kdyby se ale tato okna vytvářela sama?

Existuje totiž, alespoň tedy v .NET C#, sytém zvaný Reflexe (Reflection), který umožňuje pracovat s metadaty tříd, struktur atd., obecně tedy datových typů (Type). Vše potřebné se nachází ve jmenném prostoru (namespace) System.Reflection. Funguje to jak v klasickém .NET tak i v Xamarin.Forms, tedy u multiplatformních aplikací (UWP, Android, iOS), byť s drobnými odlišnostmi v syntaxi (v XF třída Type nemá všechny potřebné atributy, ty jsou skryty až ve třídě TypeInfo). Tyto rozdíly shrnuje následující tabulka.

Popis.NETXamarin.Forms
Typ
Název třídy s informacemi (metadaty) o třídě, struktuře atd. Type TypeInfo
Získání Type/TypeInfo z objektu (instance třídy) Type t = osoba.GetType(); TypeInfo t = osoba.GetType().GetTypeInfo();
Získání Type/TypeInfo ze třídy Type t = typeof(Osoba); TypeInfo t = typeof(Osoba).GetTypeinfo();
Konstruktor
Vytvoření instance třídy z Type/TypeInfo (přes bezparametrický konstruktor) var c = GetConstructor(new Type[]{});
var osoba = c.Invoke(null);
var osoba = new Activator.CreateInstance(t);

Další tabulka už pak ukazuje jednotný kód, protože je pro .NET i Xamarin.Forms shodný. Rozdíl je pouze v získání typu t (viz předchozí tabulka), který je buď Type v .NET nebo TypeInfo v Xamarin.Forms. 

PopisKód
Typ
Název typu string nazev = t.Name;
Celý název typu (včetně namespace) string nazev = t.FullName;
Vlastnosti
PropertyInfo konkrétní vlastnosti daného názvu var prop = t.GetProperty("Jmeno");
Název vlastnosti string nazev = prop.Name;
Datový typ vlastnosti Type tp = prop.PropertyType;
Je datový typ vlastnosti string? bool isString = tp == typeof(string);
Je vlastnost pro čtení (má get kód),
a pro zápis (má set kód)?
bool hasGet = prop.CanRead;
bool hasSet = prop.CanWrite;
Získání hodnoty vlastnosti daného objektu var jmeno = prop.GetValue(osoba);
Nastavení hodnoty vlastnosti danému objektu prop.SetValue(osoba, "Alois");
Seznam vlastností (PropertyInfo) třídy včetně vlastností předků var props = t.GetProperties();
Seznam pouze veřejných (public) vlastností var props = t.GetProperties(BindingFlags.Public);
Metody
MethodInfo konkrétní nepřetížené metody daného názvu var meth = t.GetMethod("GetJmeno");
MethodInfo konkrétní přetížené metody daného názvu a typů vstupních parametrů var meth2 = t.GetMethod("SetJmeno", new Type[] {typeof(string)});
Typ návratové hodnoty metody Type rt = meth.ReturnType;
Spuštění metody: bezparametrické,
se získáním návratové hodnoty,
se vstupními parametry
meth.Invoke(this, new object[] {});
var result = meth.Invoke(this, new object[] {});
meth2.Invoke(this, new object[] {"Alois"});
Assembly
Získání Assembly (informací o celém projektu) Assembly a = t.Assembly;
Získání Assembly hlavního spouštěncího (executing/Startup) projektu Assembly a = Assembly.GetExecutingAssembly();
Všechny typy v projektu Type[] types = a.GetTypes();
Typ daného názvu (celého názvu, včetně namespace "Reflexe") Type t = a.GetType("Reflexe.Osoba");
Typ daného názvu (jen názvu třídy, bez namespace; název by se neměl v projektu opakovat, nebo by to mohlo vrátit typ jiné třídy než očekáváme) Type t = a.GetTypes().FirstOrDefault(x => x.Name == "Osoba");
Atributy
Všechny vlastní atributy člena třídy či třídy samotné (mi = prop/meth/t/a/...; mi - MemberInfo je předek PropertyInfo, MethodInfo, Type, Assembly...) IEnumerable<CustomAttributeData> atrs = mi.CustomAttributes;
Všechny vlastní atributy člena daného typu (třídy) T IEnumerable<T> atrs = mi.GetCustomAttributes<T>();
Vlastní atribut člena daného typu (třídy) T, je-li jen jeden T atr = mi.GetCustomAttribute<T>();

 


Automaticky generované GUI - Přehled objektů

Se znalostmi shrnutými v předchozích tabulkách, dokonce jen s některými z nich, by pak neměl být problém vytvořit jednotný kód, který vygeneruje přehled objektů. Pokud jde o jednoduchý jednosloupcový přehled typu ListView, stačí u třídy, jejíž instance má přehled zobrazovat, přepsat metodu ToString (metoda předka Object) tak, aby zobrazovala vhodný název objektu, například tak, jak ukazuje následující kód. 

public override string ToString() => $"{Prijmeni} {Jmeno} ({DatumNarozeni:yyyy})";  // "Novák Alois (1979)"

Pak už stačí jen tomuto ListView do vlastnosti ItemsSource přiřadit načtený seznam objektů (např. List<Osoba>) a základní seznam (bez šablony pro sofistikovanější strukturované zobrazení jednotlivých položek) je hotov (viz následující obrázek s náhodně vygenerovanými údaji).

Přehled záznamů v ListView

Výhodou komponenty ListView je jednak integrovaný vertikální posuvník a také podpora označování řádků tohoto přehledu, přičemž obvyklé je reagovat na výběr záznamu přepnutím se na stránku s detailním výpisem tohoto záznamu s možností editace jeho hodnot. Druhou možností je vlastní tlačítko pro zobrazení detailu záznamu, avšak i to je vázáno na řádek vybraný v přehledu. ListView také umožňuje definovat vlastní šablonu pro zobrazení jednotlivých řádků, takže nemusí nutně obsahovat pouze jednu textovou hodnotu, ale lze k nim třeba přidat obrázek, rozdělit je do více řádků apod. Nevýhodu ovšem je, že při pokusu o rozmísťování jednotlivých hodnot každého řádku do sloupců tak, aby vznikla obdoba tabulky (mřížky/gridu), bude každý řádek samostatnou tabulkou a případné přizpůsobení šířky sloupce některé delší hodnotě se na ostatních řádcích tato změna neprojeví a v přehledu tak vznikají "nevzhledné zuby".

Pokud bychom chtěli objekty zobrazit v tabulkovém přehledu, v XF tedy v mřížce (Grid), pak je důležité, kolik jich zhruba bude. Je-li počet únosný (např. do cca 50ti), lze jej zobrazit všechny v rámci jednoho přehledu uvnitř ScrollView. Mělo-li by však položek (řádků) být více, a to nejen v okamžiku publikování aplikace, ale i třeba po několika letech jejího používání a plnění daty, bude nezbytné ke stávajícímu příkladu přidat podporu stránkování, tzn. načítání vždy jen určité podčásti dat (např. po 20ti záznamech) a v tabulce zobrazovat pouze ty.

Jelikož v tomto případě budeme často přidávat prvky do gridu na příslušné souřadnice řádku a sloupce, vytvoříme si nejprve následující metodu, která nám tuto činnost usnadní. Ta umožní do Gridu přímo vložit prvek na konkrétní souřadnice (řádek a sloupec mřížky), v případě potřeby jej roztáhnout přes více buněk (-span), nastavit prvku konkrétní horizontální odsazení (margin) a vertikálně jej vycentrovat. To vše v rámci jedné metody, která navíc vkládaný prvek vrací a umožňuje na něj větvit další případné metody. Přitom defaultní hodnoty vstupních parametrů metody jsou voleny tak, aby je v drtivé většině případů vůbec nebylo nutné definovat. 

public static class LayoutUtils
{
    public static View AddChild(this Grid grd, View child, int row, int col, int rowSpan = 1, int colSpan = 1,
        double? horizontalMargin = 10, bool centerVertically = true)
    {
        Grid.SetRow(child, row);               // Řadek
        Grid.SetColumn(child, col);            // Sloupec
        Grid.SetRowSpan(child, rowSpan);       // Roztažení přes více řádků
        Grid.SetColumnSpan(child, colSpan);    // Roztažení přes více sloupců
        if (centerVertically)                  // Vertikální vycentrování (pokud není zadáno, nechá jej takové, jaké je)
            child.VerticalOptions = LayoutOptions.Center;   
        if (horizontalMargin != null)          // Horizontální odsazení (zleva a zprava), vertikální nechá se stávajícími hodnotami
            child.Margin = new Thickness((double)horizontalMargin, child.Margin.Top, (double)horizontalMargin, child.Margin.Bottom); 
        grd.Children.Add(child);               // Přidání prvku do gridu
        return child;                          // Vrácení prvku (rpo případnou další práci s ním, zvláště bude-li vytvořen přímo v rámci této metody)
    }
}

Vytvoření tabulky (Grid) i s obsahem lze pojmout několika způsoby. Jednou z hlavních možností by asi byla statická metoda, která by celý grid z vytvořila na základě vstupního seznamu objektů a vrátila jej jako výstupní hodnotu. Její hlavička by pak mohla vypadat třeba následovně. 

public static Grid CreateGridWithData<T>(IEnumerable<T> data) { ... }

Grid, který by metoda vrátila, bychom pak přes C# kód vložili na místo, kam potřebujeme. Pokud bychom však raději Grid chtěli umístit již v rámci XAML návrhu designu, bude výhodnější jej připravit jako komponentu. Jelikož jej celý generujeme dynamicky, není potřeba této komponentě vytvářet XAML definici a můžeme ji zapsat pouze v C# tak, jak ukazuje následující kód třídy DataGrid

public class DataGrid : Grid
{
    public delegate void OnButtonClickDelegate(object clickedItem); // Delegát (typ události) ohlašující požadavek na editaci záznamu
    public event OnButtonClickDelegate OnButtonClick;               // Událost, která oznámí požadavek na editaci objektu (clickedItem)

    public void SetData<T>(IEnumerable<T> data)
    {
        // Vymazat stávající obsah gridu
        Children.Clear();            // Vymaže komponenty vložené v gridu
        ColumnDefinitions.Clear();   // Zruší všechny stávající sloupce
        RowDefinitions.Clear();      // Zruší všechny stávající řádky
        if (data == null || data.Count() == 0) return;         // Nejsou-li zadaná vstupní data, není co generovat

        // Typ položek
        var t = data.FirstOrDefault().GetType().GetTypeInfo(); // TypeInfo tříd objektů zjistíme z první položky
        var props = t.GetProperties();                         // Seznam vlastností třídy

        // Vytvoření sloupců a jejich záhlaví
        RowDefinitions.Add(new RowDefinition());               // První řádek se záhlavím
        for (int j = 0; j < props.Length; j++)                 // Pro všechny vlastnosti třídy...
        {
            ColumnDefinitions.Add(new ColumnDefinition());     // Vytvoření sloupce (jen v prvním řádku)
            // Pokud je sloupec určen pro numerické hodnoty, jeho šířka bude určena nejširší hodnotou, nikoli podílem na šířce okna jako u textů
            if (props[j].PropertyType == typeof(int))       
                ColumnDefinitions.Last().Width = new GridLength(1, GridUnitType.Auto);
            // Přidání popisku (názvu vlastnosti) v záhlaví (v prvním řádku) sloupce
            this.AddChild(new Label() { Text = props[j].Name, FontAttributes = FontAttributes.Bold }, 0, j);
        }
        // Sloupec pro tlačítka umožňující editaci záznamu
        ColumnDefinitions.Add(new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Auto) });
        this.AddChild(new Label() { Text = "Detail", FontAttributes = FontAttributes.Bold }, 0, ColumnDefinitions.Count - 1);

        // Řádky s daty
        foreach (var item in data)
        {
            RowDefinitions.Add(new RowDefinition()); // Přidání řádku pro záznam
            for (int j = 0; j < props.Length; j++)   // Pro všechny vlastnosti třídy...
            {
                var val = props[j].GetValue(item);   // Získání hodnoty vlastnosti z objektu
                var lbl = new Label();               // Label, který bude hodnotu zobrazovat
                // Naformátování hodoty na základě jejího datového typu
                if (props[j].PropertyType == typeof(DateTime)) // Datum
                    lbl.Text = (val as DateTime?)?.ToString("dd.MM.yyyy");
                else // Vše ostatní lze (zatím) přeložit na text klasickým způsobem
                    lbl.Text = val?.ToString();
                // Zarovnání čísel doprava
                if (props[j].PropertyType == typeof(int))
                    lbl.HorizontalOptions = LayoutOptions.End;
                // Přidání do gridu na správné místo
                this.AddChild(lbl, RowDefinitions.Count - 1, j);
            }
            // Tlačítko pro editaci do posledního sloupce
            var btnEdit = new Button() { Text = "Edit", BindingContext = item };
            btnEdit.Clicked += btnEdit_Clicked;
            this.AddChild(btnEdit, RowDefinitions.Count - 1, ColumnDefinitions.Count - 1);
        }
    }

    private void btnEdit_Clicked(object sender, EventArgs e)
    {
        OnButtonClick?.Invoke(((Button)sender).BindingContext); // Oznámení, že došlo ke kliknutí na tlačítko Edit u záznamu
    }

    // Vytvoření nového Gridu i s obsahem na základě seznamu objektů
    public static DataGrid CreateGridWithData<T>(IEnumerable<T> data)
    {
        var grid = new DataGrid();
        grid.SetData(data);
        return grid;
    }
}

Tato naše nová třída DataGrid dědí ze třídy Grid a získává tak veškeré její vlastnosti, metody a události, které již nemusíme nijak řešit. Přidává ji navíc událost OnButtonClick, která bude oznamovat, že uživatel klikl na (nebo "tapnul" či jinak stiskl) tlačítko "Edit", které bude vygenerováno u každého řádku záznamu. Toto lze samozřejmě řešit mnoha způsoby i bez tlačítka, například zachytáváním kliknutí na celý řádek každého záznamu (přes TapGestureRecognizer), implementovat lze v podstatě i podporu výběru (označení) záznamu, podobně jako to umí ListView (např. změnou barvy nějakého panelu, třeba BoxView, v pozadí celého řádku, jež by právě mohl i zachytávat kliknutí).

Další přidanou položkou ve třídě DataGrid je metoda SetData. Tu je třeba volat pro vygenerování (popř. přegenerování) Gridu (sloupců, řádků, hodnot), pokud se data jakkoli změnila (např. po editaci, výmazu či přidání záznamu), podobně jako při nastavení vlastnosti ItemsSource u ListView. Stejný princip by se dal implementovat i zde, přidáním této vlastnosti (ItemsSource) do naší třídy, kde by se v jejím set-kódu volala právě tato metoda (SetData(value);). Dalším rozšířením by také mohla být podpora seznamů typu ObservableCollection, u něhož by přehled dat na změny v seznamu reagoval automaticky překreslením těch, kterých se změna týkala. To však (tentokrát) není cílem tohoto tutoriálu.

Třída DataGrid obsahuje dokonce i dříve zmíněnou statickou metodu CreateGridWithData, která v případě, že by měl být grid generován dynamicky a ne přes XAML, ušetří nějaké řádky a vygeneruje, naplní a vrátí sám sebe. Pokud však preferujete umístění tabulky v XAML definici, pak by to mohlo vypadat například takto. 

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Reflexe.PrehledDat"
             xmlns:local="clr-namespace:Reflexe"
             Title="Osoby">
    <ContentPage.Content>
        <ScrollView>
            <Grid>
                <local:DataGrid x:Name="gOsoby" Margin="20" />
            </Grid>
        </ScrollView>
    </ContentPage.Content>
</ContentPage>

Možný výsledek (opět s náhodně vygenerovanými údaji) po nastavení obsahu DataGridu metodou SetData ukazuje následující obrázek.

reflexe gridPřehled záznamů v Gridu 

Z výsledku je patrné několik detailů, které by ještě stály za doladění. Například popisky v záhlaví sloupců jsou pouhými názvy vlastností, takže neobsahují diakritiku, mezery mezi slovy atd. Toto se dá doladit s využitím atributů a resources, čemuž se budeme věnovat později. Tímtéž postupem by šlo řešit více individuálně nastavené šířky sloupců, vynechání některých vlastností z tabulkového přehledu (vidět by byly až v detailu záznamu), změnu pořadí sloupců atd. Nejdříve se ale podíváme na to, jak vygenerovat formulář pro editaci jednotlivých záznamů. 

 


Formulář pro editaci detailu objektu

Komponentu zobrazující formulář pro editaci hodnot objektu můžeme připravit podobným způsobem. Tentokrát nám na vstupu stačí již pouze jeden objekt, nikoli celý seznam. Z jeho třídy (typu) se dozvíme vše potřebné pro vytvoření prvků formuláře, a z tohoto objektu (z instance třídy) získáme data, která v tomto formuláři zobrazíme a umožníme jejich editaci.
Co se týče rozložení prvků ve formuláři, tak v současné době je moderní a zároveň i velmi praktické, jednotlivé prvky řadit pod sebe tak, že nejprve je uveden popisek (název editované vlastnosti) a pod ní editor hodnoty vlastnosti. Pro definici takovéhoto typu rozmístění je v Xamarin.Forms nejvhodnější použít polohovací prvek StackLayout (ve WPF existuje totéž pod názvem StackPanel). Grid by se hodil pro starší typ rozložení, kdy popisky byly vlevo (před) prvkem pro editaci hodnoty, které pak byly vpravo v témže řádku, obojí zarovnáno do sloupce. Tento typ formuláře se sice tak neroztahoval do výšky, ale za to do šířky. Navíc pokud měla některá z položek delší popis, nezbyl na samotné editory (všechny) dostatek horizontálního prostoru a celé rozložení, zvláště na menších displejích to tak značně komplikovalo. Novější typ řazení prvků pod sebe možná není tak vzhledný na větších monitorech, ale větší množství prvků formuláře se dá vždy vyřešit obyčejným ScrollViewem (s možností vertikálního posouvání), popř. nějakým rozdělením do kategorií (ty pak mohou být i vedle sebe) či záložek např. pomocí TabbedPage.
V příkladu se ale zaměříme na prostý jednostránkový formulář bez kategorií, nejprve v jeho nejjednodušší formě. 

public class DataForm : StackLayout
{
    public void SetData(object record)
    {
        var typ = record.GetType().GetTypeInfo();
        foreach (var prop in typ.GetProperties())
        {               
            // Editor
            View editor = null;
            if (prop.PropertyType == typeof(string) || prop.PropertyType == typeof(int))
            {
                editor = new Entry();
                editor.SetBinding(Entry.TextProperty, prop.Name);
            }
            else if (prop.PropertyType == typeof(DateTime))
            {
                editor = new DatePicker();
                editor.SetBinding(DatePicker.DateProperty, prop.Name);
                editor.HorizontalOptions = LayoutOptions.Start;
            }
            // Donastavení a přidání editoru
            if (editor != null)
            {
                Children.Add(new Label() { Text = prop.Name }); // Popisek editoru
                editor.Margin = new Thickness(0, 2, 0, 10);     // Odsazení
                Children.Add(editor);                           // Přidání do StackLayoutu
            }
        }
        // Provázání editovaného objektu s formulářem
        BindingContext = record;
    }

    public object GetData() => BindingContext;

    // Vytvoření nového formuláře na základě seznamu objektů
    public static DataForm CreateFormWithData(object record)
    {
        var form = new DataForm();
        form.SetData(record);
        return form;
    }
}

Pokud bychom chtěli za předka třídy naší komponenty místo StackLayout třeba Grid, nebo i cokoli jiného, může být, StackLayout bychom v metodě SetData vytvořili a vložili jej do tohoto jiného prvku jako jediný podprvek (Children) či obsah (Content).
Metoda SetData si tedy, podobně jako tomu bylo u generování přehledu záznamů, zjistí informace o třídě (typu) záznamu/objektu (record) a z něj získá seznam všech jeho vlastností. Pro každou z nich pak vytvoří vhodný editor (pro text a celé číslo je zde použito Entry, pro datum pak DatePicker), prováže ho s vlastností objektu (SetBinding), editor doformátuje, vloží nad něj popisek (název vlastnosti) a hned pod něj přidá i tento editor.
Komponentu typu DataForm pak lze vložit do XAML kódu, popř. vygenerovat dynamicky v C# kódu statickou metodou CreateFormWithData

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:Reflexe"
             x:Class="Reflexe.DetailPage"
             Title="Detail osoby">
    <ContentPage.Content>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <ScrollView>
                <Grid>
                    <local:DataForm x:Name="dForm" Margin="20,10" />
                </Grid>
            </ScrollView>
            <StackLayout Grid.Row="1" HorizontalOptions="End" Margin="20,10" Orientation="Horizontal">
                <Button Text="OK" WidthRequest="70" />
                <Button Text="Zrušit" WidthRequest="70" Margin="10,0,0,0" />
            </StackLayout>
        </Grid>
    </ContentPage.Content>
</ContentPage>

Následně stačí už jen metodou SetData ("dForm.SetData(osoba);") nastavit objekt pro editaci a formulář je hotov.

Automaticky vygenerovaný formulář pro editaci dat

Výhodou je, že ať přes SetData vložíme formuláři jakýkoli objekt jakékoli třídy, formulář se tomu vždy přizpůsobí. Jelikož jsou prvky pro editaci provázány s editovaným objektem pomocí vázání dat (Binding), jakákoli změna kteréhokoli údaje ve formuláři se okamžitě ukládá zpět do objektu. To sice může být problém v případě, že uživatel hodnoty změní a pak okno/stránku opustí tlačítkem Zrušit, ale dá se snadno řešit buď klonováním objektu, nebo v případě databáze tím, že místo Commit se zavolá Rollback a data objektu se znovu načtou z databáze, což by při návratu na přehled objektů stejně nepochybně následovalo v každém případě.

 


Video-tutoriál Reflexe a lokalizace

YouTube, GitHub

on 27 květen 2018