Více tlačítkový dialog pro Windows Phone

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... 

Standardní MessageDialog ve Windows Phone (vertikální) Standardní MessageDialog ve Windows Phone (horizontální)

 

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ě.

 Standardní MessageDialog ve Windows Store

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.

Standardní MessageDialog ve Windows Phone (vertikální) Standardní MessageDialog ve Windows Phone (horizontální)


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...

on 11 srpen 2014