Během programování update Osmisměrek jsem již po několikáté narazil na nepříjemné omezení, že standardní MessageDialog umožňuje zobrazit maximálně dvě tlačítka...
MessageDialog je v Universal apps sjednocen a týž příkaz pak funguje jak na Windows Phone tak na Windows 8, vždy v příslušném designu. Ač lze do MessageDialogu v kódu přidat libovolné množství tlačítek (UICommand), Windows Phone zobrazí maximálně dvě a Windows Store maximálně tři. Pokud jich je přidáno víc, při pokusu o zobrazení dialogu dojde k vyvolání výjimky a není-li ošetřena, tak i k pádu aplikace.
var md = new MessageDialog("Skutečně si přejete vymazat nalezená slova v této osmisměrce a začít ji tak luštit od začátku?", "Restart osmisměrky"); md.Commands.Add(new UICommand("ano resetovat") { Id = "Y" }); md.Commands.Add(new UICommand("ne") { Id = "N" }); var cmd = await md.ShowAsync(); bool restart = cmd != null && (string)cmd.Id == "Y";
Tentýž dialog ve Windows Store aplikaci pak vypadá následovně.
Ve Windows Phone Toolkit existuje sice CustomMessageDialog, který umožňuje volně definovat veškerý jeho obsah (místo standardního nadpisu a textu do dialogu vložit třeba CheckBox, TextBox, Image, Button apod.), ale ten jednak aktuálně není optimalizován pro projekty typu Universall apps a stejně standardně podporuje také pouze dvě tlačítka vedle sebe.
Zkusil jsem tedy vytvořit dialog vlastní, konkrétně pouze pro Windows Phone (ve Windows Store verzi se dalo vystačit se třemi tlačítky a vícenásobným dialogem) a to tak, aby se do tlačítek vešel i delší text. Třídu jsem nazval MessageBox, tak se jmenuje i třída pro dialogy ve Windows Forms a zde se alespoň nebude plést s MessageDilog. Předkem této třídy bude kontejner Grid, který rovnou dodá vlastnosti potřebné pro nastavení designu dialogu. Vše bude vytvořeno pouze v C# kódu, tj. zcela bez XAML definice designu v dalším souboru.
public class MessageBox : Grid { ... }
Pro vnitřní potřeby třídy jsou deklarovány následující datové položky, do kterých budou uloženy reference na příslušné ovládací prvky, aby se dalo přistupovat k jejich vlastnostem i později.
private TextBlock txbCaption, txbText; private StackPanel stpButtons;
Vlastnosti této třídy pak využijí těchto datových položek a budou pracovat přímo s jejich hodnotami (první dvě).
public string Caption { get { return txbCaption.Text; } set { txbCaption.Text = value; } } public string Text { get { return txbText.Text; } set { txbText.Text = value; } } public List<Command> Commands { get; private set; }
Vlastnost Commands bude sloužit pro definici seznamu tlačítek, resp. jejich textů a identifikátorů. Jde tedy o seznam třídy Command, jejíž definice je následující.
public class Command { public string Id { get; set; } public string Text { get; set; } public Command(string id, string text) { Id = id; Text = text; } }
Tuto třídu je praktičtější definovat samostatně, tedy ne jako podtřídu MessageBox, ale třeba až za ní, a to kvůli kratšímu zápisu při vytváření jejích instancí. Tato třída by samozřejmě mohla být definována i jako strukturovaná proměnná (struct). V C# 6.0 by pak kód této třídy měl být ještě mnohem elegantnější.
Do seznamu ve vlastnosti Commands se jednotlivé instance třídy Command již pouze přidávají (Add), celý seznam najednou do ní přiřadit nelze (set kód je private). Vytvoření tohoto prázdného seznamu proběhne v konstruktoru třídy.
public MessageBox() { Commands = new List<Command>(); Prepare(); }
Konstruktor také zavolá metodu Prepare, která vytvoří veškerý design a ovládací prvky potřebné pro další práci s dialogem.
private void Prepare() { // Definice řádků gridu: 1. automatická velikost, 2. přes zbytek výšky RowDefinitions.Add(new RowDefinition() { Height = new GridLength(1, Windows.UI.Xaml.GridUnitType.Auto) }); RowDefinitions.Add(new RowDefinition() { Height = new GridLength(1, Windows.UI.Xaml.GridUnitType.Star) }); // Pozadí převzaté ze standardní definice pro WP, reflektující uživatelem zvolené barevné téma SolidColorBrush back = Application.Current.Resources["PhoneChromeBrush"] as SolidColorBrush; // Poloprůhledné pozadí (bude se týkat pouez spodní části) Background = new SolidColorBrush(Color.FromArgb(127, back.Color.R, back.Color.G, back.Color.B)); // Grid s textem v horní části - zajistí správnou barvu pozadí i po odsazených okrajích Grid grdMain = new Grid(); grdMain.Background = back; grdMain.Margin = new Thickness(-100, -100, -100, 0); // Posun Gridu i pod horní (či postranní) oznamovací pruh this.Children.Add(grdMain); // StackPanel, do kterého se bude vkládat samotný obsah dialogu StackPanel stpMain = new StackPanel(); stpMain.Margin = new Thickness(120, 110, 120, 10); // Vyrovnat posunutí a přidat standardní okraje grdMain.Children.Add(stpMain); // TextBlock pro nadpis (caption/title) dialogu txbCaption = new TextBlock(); txbCaption.Style = Application.Current.Resources["MessageDialogTitleStyle"] as Style; txbCaption.Margin = new Thickness(0, 0, 0, 24); // Mezera pod nadpisem stpMain.Children.Add(txbCaption); // TextBlock pro text dialogu txbText = new TextBlock(); txbText.Style = Application.Current.Resources["MessageDialogContentStyle"] as Style; txbText.Margin = new Thickness(0, 0, 0, 24); // Mezera pod nadpisem stpMain.Children.Add(txbText); // Kontejner pro tlačítka (budou přidána později) stpButtons = new StackPanel(); stpMain.Children.Add(stpButtons); }
Styly pro textové bloky a barva pozadí dialogu jsou převzaty z předdefinovaných stylů systému. Jejich názvy (klíče), přes které je k nim přistupováno jsou dostupné pouze pro Windows Phone, při pokusu je načíst ve Windows Store verzi by byla vyvolána výjimka. Seznam a náhledy těchto stylů jsou například zde, barva pozadí dialogu je pak popsána zde.
Grid s barvou pozadí je roztažen do tří stran tak, aby s dostatečnou rezervou podbarvil i pruh s oznamovacími ikonami (signál, stav baterky, atd.), ať už je kdekoli (záleží na aktuálním otočení telefonu). StackPanel, který již přímo obsahuje jednotlivé ovládací prvky, pak toto rozšíření zase vyrovnává a přidává standardní mezeru od okrajů displeje. Pokud by měl dialog obsahovat delší text nebo tolik tlačítek, že by se nevešel do jedné obrazovky displeje, měl by být tento StackPanel ještě obalen komponentou ScrollViewer s fixní výškou, odvozenou třeba od rozměrů displeje.
Pro tlačítka byl připraven pouze kontejner (StackPanel) nazvaný stpButtons. O jeho naplnění na základě obsahu seznamu Commands se postará soukromá metoda CreateButtons.
private void CreateButtons() { stpButtons.Children.Clear(); // Vamazat dříve vložená tlačítka foreach (var cmd in Commands) // Pro včechny zadané příkazy { if (cmd == null) continue; // Nedefinovaná tlačítka přeskočit Button btn = new Button(); // Nové tlačítko btn.Content = cmd.Text; // Text zobrazený ve tlačítku btn.HorizontalContentAlignment = HorizontalAlignment.Left; // Text zarovnat doleva btn.HorizontalAlignment = HorizontalAlignment.Stretch; // Tlačítka přes celou šířku btn.Tag = cmd.Id; // Identifikátor příkazu skrýt do pomocné vlastnosti Tag btn.Click += CommandBtn_Click; // Obsluha události při kliknutí na tlačítko stpButtons.Children.Add(btn); // Přidání tlačítka do kontejneru } }
Aby se dialog dal efektivně asynchronně používat, bez nutnosti zachytávání událost kliknutí na tlačítko zvenčí, pomocí klíčového slova await, je třeba tuto obsluhu vyřešit v rámci této třídy. V jednom příkazu by tak mělo dojít k zobrazení dialogu a získání identifikátoru příkazu (tlačítka) na které uživatel kliknul. Mezi tím samozřejmě může uplynout mnoho času a dojít k řadě událostí, včetně ukončení aplikace uživatelem. Pro tyto potřeby je zde generická třída TaskCompletionSource, jejíž instance má asynchronní vlastnost Task. Ta asynchronně pozastaví běh vlákna, dokud není této vlastnosti nastavena nějaká hodnota. Datový typ této hodnoty se určuje při její deklaraci (v tomto případě si vystačíme se textovým řetězcem string).
private TaskCompletionSource<string> buttonClicked; // Pomocná proměnná pro "čekač" private async Task<string> WaitForClick() { buttonClicked = new TaskCompletionSource<string>(); // Vytvoření "čekače" return await buttonClicked.Task; // Po nastavení hodnoty "čekači" bude tato vrácena } private void CommandBtn_Click(object sender, RoutedEventArgs e) { if (buttonClicked != null) buttonClicked.SetResult((string)((Button)sender).Tag); // Nastavit "čekači" hodnotu z tagu tlačítka na které se kliklo }
Vše podstatné je tedy hotové, nyní již schází pouze metoda Show pro zobrazení dialogu. Ta nejprve vytvoří tlačítka podle definice příkazů v seznamu Commands, skryje aplikační menu (je-li nějaké), které nelze překrýt, přidá komponentu dialogu do hlavního kontejneru aktuální stránky, přidá obsluhu harwarového tlačítka zpět, asynchronně počká na stisk tlačítka a získá jeho identifikátor, zruší obsluhu hardwarového tlačítka zpět, odebere dialog (sama sebe) z hlavního kontejneru stránky, znovu zobrazí aplikační menu a vrátí získanou hodnotu.
public async Task<string> Show() { CreateButtons(); // Vytvořit tlačítka podle seznamu Commands var page = ((Frame)Window.Current.Content).Content as Page; // Aktuální stránka var parent = page.Content as Panel; // Kontejner pro dialog if (page.BottomAppBar != null) // Skrýt applikační menu (existuje-li) page.BottomAppBar.Visibility = Visibility.Collapsed; parent.Children.Add(this); // Zobrazit (přidat) dialog #if WINDOWS_PHONE_APP Windows.Phone.UI.Input.HardwareButtons.BackPressed += OnBackKeyPress; // Obsluha HW tlačítka zpět #endif string result = await WaitForClick(); // Počkat na stisk tlačítka a zíkat jeho identifikátor #if WINDOWS_PHONE_APP Windows.Phone.UI.Input.HardwareButtons.BackPressed -= OnBackKeyPress; // Ukončení obsluhy HW tlačítka zpět #endif parent.Children.Remove(this); // Skrýt (odebrat) dialog if (page.BottomAppBar != null) // Zase zobrazit applikační menu (existuje-li) page.BottomAppBar.Visibility = Visibility.Visible; return result; // Vrátit identifikátor ze stisknutého tlačítka }
Postup získání hlavního kontejneru stránky je takový, že z aktuálního okna je vzat jeho obsah, což je rámeček (Frame) ve kterém se zobrazují jednotlivé stránky. Jeho obsahem je tedy aktuální stránka (Page), jejímž obsahem je vždy právě jeden kontejner (Panel), ať už je to Grid, StackPanel nebo třeba ScrollViewer.
Obsluha harwarového tlačítka zpět je možná pouze ve Windows Phone, jelikož ve Windows Store (na tabletech) takové tlačítko není. Kód, který se o toto stará tak používá jmenné prostory, které jsou dostupné pouze pro mobilní část aplikace. Je-li tedy tato třída (MessageBox) vytvářená ve sdílené (shared) části projektu, pak musí být tyto příkazy podmíneny v #if direktivě. Pokud by třída byla pouze součástí WindowsPhone projektu, pak tam tyto podmínky být nemusí. Metoda OnBackKeyPress, která se stará o obsluhu tlačítka zpět pak má následující kód.
#if WINDOWS_PHONE_APP protected void OnBackKeyPress(object sender, Windows.Phone.UI.Input.BackPressedEventArgs e) { if (buttonClicked != null && !e.Handled) // Existuje-li "čekač" a nebyl-li již stisk tlačítka zpět obsloužen { e.Handled = true; // Nastavit příznak, že stisk tlačítka zpět byl již obsloužen buttonClicked.SetResult(String.Empty); // Nastavit "čekači" návratovou hodnotu "prázdný textový řetězec" } } #endif
Při používání dialogu je pak třeba počítat s tím, že kromě zadaných příkazů může vrátit také prázdný textový řetězec, v případě že uživatel neklikl na žádné z nabízených tlačítek, ale stiskl harwarové tlačítko zpět.
Při použití metody Show je tedy třeba nejprve dialog vytvořit, nastavit jeho vlastnosti, včetně seznamu příkazů (tlačítek) a zobrazit zavolání metody Show.
var dlg = new MessageBox(); dlg.Text = "Přejete si zakoupit všechny sady osmisměrek (včetně těch v budoucnu přidaných) pouze pro aktuální jazyk (Čeština), nebo pro všechny jazyky?"; dlg.Caption = "Koupit všechny sady"; dlg.Commands.Add(new Command("L", "koupit všechny české sady")); dlg.Commands.Add(new Command("E", "koupit všechny sady ve všech jazycích")); dlg.Commands.Add(new Command("C", "možná později")); string result = await dlg.Show();
Pro jednodušší práci s dialogem ale lze přidat ještě statickou metodu, která všechnu tuto práci obstará sama, podobně jako tomu bylo při používání MessageBox ve Windows Forms. Tato metoda dostane všechny potřebné hodnoty ve vstupních parametrech, včetně libovolného počtu příkazů pro tlačítka jako params pole, a s nimi pak již jen vykoná toto rutinní nastavení.
public static async Task<string> Show(string text, string caption, params Command[] commands) { var dlg = new MessageBox(); // Vytvoření daialogu dlg.Text = text; // Nastavení textu dlg.Caption = caption; // Nastavení nadpisu foreach (var cmd in commands) // Přidání příkazů do seznamu Commands dlg.Commands.Add(cmd); return await dlg.Show(); // Zobrazení dialogu a vrácení identifikátoru stisknutého tlačítka }
Dialog tak lze použít zavoláním jediného příkazu.
string result = await MessageBox.Show( "Přejete si zakoupit všechny sady osmisměrek (včetně těch v budoucnu přidaných) pouze pro aktuální jazyk (Čeština), nebo pro všechny jazyky?", "Koupit všechny sady", new Command("L", "koupit všechny české sady"), new Command("E", "koupit všechny sady ve všech jazycích"), new Command("C", "možná později"));
Výsledek pak vypadá následovně, přičemž záleží na uživatelem zvoleném barevném tématu v jeho mobilním telefonu, jak si lze prohlédnout v následující galerii.
Tento dialog lze tedy používat pouze pro Windows Phone. Při použití ve sdíleném (shared) projektu v Universal apps je tedy třeba použití tohoto dialogu obalit #if direktivou a pro Windows Store použít klasický MessageDialog (s max. 3 tlačítky).
string result = "C"; #if WINDOWS_PHONE_APP result = await MessageBox.Show( "Přejete si zakoupit všechny sady osmisměrek (včetně těch v budoucnu přidaných) pouze pro aktuální jazyk (Čeština), nebo pro všechny jazyky?", "Koupit všechny sady", new Command("L", "koupit všechny české sady"), new Command("E", "koupit všechny sady ve všech jazycích"), new Command("C", "možná později")); #endif #if WINDOWS_APP var md = new MessageDialog( "Přejete si zakoupit všechny sady osmisměrek (včetně těch v budoucnu přidaných) pouze pro aktuální jazyk (Čeština), nebo pro všechny jazyky?", "Koupit všechny sady"); md.Commands.Clear(); md.Commands.Add(new UICommand("koupit všechny české sady") { Id = "L" }); md.Commands.Add(new UICommand("koupit všechny sady ve všech jazycích") { Id = "E" }); md.Commands.Add(new UICommand("možná později") { Id = "C" }); var cmd = await md.ShowAsync(); result = cmd != null ? (string)cmd.Id : "C"; #endif
Případně lze tuto funckionalitu přímo přesunout do metody Show třídy MessageBox. Pak by se z dialog zobrazoval vždy stejným příkazem, jen by bylo třeba vždy pamatovat na to, že ve Windows Store se zobrazí maximálně 3 tlačítka.
public static async Task<string> Show(string text, string caption, params Command[] commands) { #if WINDOWS_PHONE_APP var dlg = new MessageBox(); dlg.Text = text; dlg.Caption = caption; foreach (var cmd in commands) dlg.Commands.Add(cmd); return await dlg.Show(); #endif #if WINDOWS_APP var md = new MessageDialog(text, caption); md.Commands.Clear(); foreach (var cmd in commands) md.Commands.Add(new UICommand(cmd.Text) { Id = cmd.Id }); var result = await md.ShowAsync(); return result == null ? String.Empty : (string)result.Id; #endif }
Případně by šlo i celou třídu rozšířit, resp. udělat její alternativu s vlastním dialogem pro Windows Store, který by také podporoval neomezený počet tlačítek...