Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to Create a Generic ListBox Dialog Control

Skip to answer to see how to implement the ListDialogBox

I have a reusable dialog/window that prompts the user to select an item from a list box hit 'OK' to confirm the selection.

It works great; however, the list box doesn't know what type of data it is working with ahead of time. Because of this, the list is bound to an ObservableCollection<Object> that can be set by the caller of the dialog.

In addition, the list box has a custom item template which allows the user to delete items from the list.

Here is the dialog that I'm describing:

ListDialogBox

Ideally, I would like to take advantage of the DisplayMemberPath for the list box, but I am not allowed since I am creating my own item template. This is a problem because the caller should be able to specify which property he/she wants to bind to the custom item template I've set up.

Since that approach cannot work my first questions is this:

1. Can I specify at runtime the path for a data-bound value?

In XAML, I'd expect to see something like this, but it is wrong:

<ListBox.ItemTemplate>
    <Label Content="{Binding Path={Binding CustomPath}}"/>
    <Button Width="20" Height="20" FontWeight="Bold" Content="×"/>
</ListBox.ItemTemplate>

(some properties omitted for brevity)

Supposing that the first question is resolved, I'm still left with another problem. The list box is working with a non-generic type Object which will not have the property the caller wants to bind to. The list box is not able to cast the object into a custom type and access the desired property. This leads me to my second question.

2. How can I instruct the ListBox to be able to work with an unknown data type, but be able to choose the path for the data-bound value?

Perhaps this should be left for another question on SO, but it would be nice to be able to specify whether or not the binding uses ToString() or a property.


The only solution I can think of is to create an interface which has a property (named DisplayText) that the caller must use. The list would then bind to an instance of ObservableCollection<CustomInterface>.

However, it isn't desired to wrap already existing data types into this interface just so this works. Is there a better way to do this?


EDIT: How an implementer uses the ListDialogBox

Here is how I would like the caller to be able to setup the dialog box (or something near the same simplicity):

public CustomItem PromptForSelection()
{
    ListDialogBox dialog = new ListDialogBox();
    dialog.Items = GetObservableCollection();
    dialog.ListDisplayMemberPath = "DisplayName";
    dialog.ShowDialog();
    if(!dialog.IsCancelled)
    {
        return (CustomItem) dialog.SelectedItem;
    }
}

public ObservableCollection<Object> GetObservableCollection()
{
    ObservableCollection<Object> coll = new ObservableCollection<Object>();

    CustomItem item = new CustomItem(); 
    item.DisplayName = "Item1";
    CustomItem item2 = new CustomerItem();
    item2.DisplayName = "Item2";
    //...

    coll.Add(item);
    coll.Add(item2);
    //...

    return coll;
}

The code will not work because the DisplayName property doesn't make sense if the ObservableCollection<Object> is used for the ListDialogBox. This is because Object doesn't define that property.

In the ListDialogBox class, I would like to bind the item template's label to the DisplayName property, because that was the ListDisplayMemberPath value that is provided.

How can I overcome this?

like image 229
Nicholas Miller Avatar asked Aug 07 '14 14:08

Nicholas Miller


1 Answers

This answer is intended to address the issues in the original question and to provided an example of how to implement the ListDialogBox for future readers.

The problems in the original question deal with being able to specify how to display information in the ListBox. Since the ListBox doesn't know what type of data it is displaying until runtime, then there isn't a straightforward way to specify a "path" that points to the desired property being shown.

This simplest solution to the problem is to create an interface that the ListDialogBox uses exclusively, and then the caller only needs to create instances of that interface to customize how information is displayed.

The only drawback to this solution is that the caller needs to cater his/her data to comply with the ListDialogBox; however, this is easily accomplished.


How to create and implement the ListDialogBox:

The goal of the ListDialogBox is to resemble the OpenFileDialog or SaveFileDialog in that you initialize the dialog, prompt for a result, then process the result.

First, I'll show & explain the code for the ListDialogBox (XAML and code-behind).
The XAML below has been trimmed to show only the structure of the dialog box and necessary properties.

<Window
    //You must specify the namespace that contains the the converters used by
    //this dialog
    xmlns:local="clr-namespace:<your-namespace>"
    //[Optional]: Specify a handler so that the ESC key closes the dialog.
    KeyDown="Window_KeyDown">
<Window.Resources>
    //These converters are used to control the dialog box.
    <BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
    <local:NullToBooleanConverter x:Key="NullToBool"/>
</Window.Resources>
<Grid>
     //This displays a custom prompt which can be set by the caller.
    <TextBlock Text="{Binding Prompt}" TextWrapping="Wrap" />

    //The selection button is only enabled if a selection is made (non-null)
    <Button IsEnabled="{Binding Path=SelectedItem, 
                                ElementName=LstItems,
                                Converter={StaticResource NullToBool}}" 
        //Display a custom message for the select button.
        Content="{Binding SelectText}" 
        //Specify a handler to close the dialog when a selection is confirmed.
        Click="BtnSelect_Click" Name="BtnSelect" />

    //The cancel button specifies a handler to close the dialog.
    <Button Content=" Cancel" Name="BtnCancel" Click="BtnCancel_Click" />

    //This list box displays the items by using the 'INamedItem' interface
    <ListBox ItemsSource="{Binding Items}" Name="LstItems"        
             ScrollViewer.HorizontalScrollBarVisibility="Disabled">
        <ListBox.ItemContainerStyle>
            <Style TargetType="ListBoxItem">
                <Setter Property="HorizontalContentAlignment"  Value="Stretch"/>
            </Style>
        </ListBox.ItemContainerStyle>
        <ListBox.ItemTemplate>
            <DataTemplate>
                <DockPanel>

            <Button DockPanel.Dock="Right" 

            //The delete button is only available when the 'CanRemoveItems'
            //property  is true.  See usage for more details.
            Visibility="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}, 
                                    Path=CanRemoveItems, 
                                    Converter={StaticResource BoolToVisibility}}" 
            //Visual properties for correctly displaying the red 'x'.
            //The 'x' is actually the multiplication symbol: '×'
            FontFamily="Elephant" Foreground="Red" FontWeight="Bold" FontStyle="Normal" 
            FontSize="18" Padding="0,-3,0,0" Content="×" 
            //[Optional]: Align button on the right end.
            HorizontalAlignment="Right" 
            //Specify handler that removes the item from the list (internally)
            Click="BtnRemove_Click" />

            //The DockPanel's last child fills the remainder of the template
            //with the one and only property from the INamedItem interface.
            <Label Content="{Binding DisplayName}"                          
                //[Optional]: This handler allows double-clicks to confirm selection.
                MouseDoubleClick="LstItem_MouseDoubleClick"/>

                </DockPanel>
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</Grid>

The NullToBooleanConverter is essentially the same as this answer found on SO. It is used to enable/disable the confirm selection button based on whether or not the ListBox.SelectedItem is null. The difference with this converter is that it returns true when the converted value is NOT null.

ListDialogBox Code-Behind:

This class defines all of the properties that the caller can modify to customize the way the ListDialogBox displayed and the functionality it has.

public partial class ListDialogBox : Window, INotifyPropertyChanged
{   
    /* The DataContext of the ListDialogBox is itself.  It implements
     * INotifyPropertyChanged so that the dialog box bindings are updated when
     * the caller modifies the functionality.
     */
    public event PropertyChangedEventHandler PropertyChanged;
    protected void RaisePropertyChanged(string name)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(name));
        }
    }

    /* Optionally, the ListDialogBox provides a callback mechanism that allows
     * the caller to cancel the removal of any of the items.
     * See usage for more details.
     */
    public event RemoveItemEventHandler RemoveItem;
    protected void RaiseRemoveItem(RemoveItemEventArgs args)
    {
        if (RemoveItem != null)
        {
            RemoveItem(this, args);
        }
    }

    //Local copies of all the properties. (with default values)
    private string prompt = "Select an item from the list.";
    private string selectText = "Select";
    private bool canRemoveItems = false;
    private ObservableCollection<INamedItem> items;
    private INamedItem selectedItem = null;

    public ListDialogBox()
    {
        InitializeComponent();
        DataContext = this;  //The DataContext is itself.
    }

    /* Handles when an item is double-clicked.
     * The ListDialogBox.SelectedItem property is set and the dialog is closed.
     */
    private void LstItem_MouseDoubleClick(object sender, MouseButtonEventArgs e)
    {
        SelectedItem = ((FrameworkElement)sender).DataContext as INamedItem;
        Close();
    }

    /* Handles when the confirm selection button is pressed.
     * The ListDialogBox.SelectedItem property is set and the dialog is closed.
     */        
    private void BtnSelect_Click(object sender, RoutedEventArgs e)
    {
        SelectedItem = LstItems.SelectedItem as INamedItem;
        Close();
    }

    /* Handles when the cancel button is pressed.
     * The lsitDialogBox.SelectedItem remains null, and the dialog is closed.
     */
    private void BtnCancel_Click(object sender, RoutedEventArgs e)
    {
        Close();
    }

    /* Handles when any key is pressed.  Here we determine when the user presses
     * the ESC key.  If that happens, the result is the same as cancelling.
     */
    private void Window_KeyDown(object sender, KeyEventArgs e)
    {   //If the user presses escape, close this window.
        if (e.Key == Key.Escape)
        {
            Close();
        }
    }

    /* Handles when the 'x' button is pressed on any of the items.
     * The item in question is found and the RemoveItem event subscribers are notified.
     * If the subscribers do not cancel the event, then the item is removed.
     */
    private void BtnRemove_Click(object sender, RoutedEventArgs e)
    {   //Obtain the item that corresponds to the remove button that was clicked.
        INamedItem removeItem = ((FrameworkElement)sender).DataContext as INamedItem;

        RemoveItemEventArgs args = new RemoveItemEventArgs(removeItem);
        RaiseRemoveItem(args);

        if (!args.Cancel)
        {   //If not cancelled, then remove the item.
            items.Remove(removeItem);
        }
    }

    //Below are the customizable properties.

    /* This property specifies the prompt that displays at the top of the dialog. */
    public string Prompt
    {
        get { return prompt; }
        set
        {
            if (prompt != value)
            {
                prompt = value;
                RaisePropertyChanged("Prompt");
            }
        }
    }

    /* This property specifies the text on the confirm selection button. */
    public string SelectText
    {
        get { return selectText; }
        set
        {
            if (selectText != value)
            {
                selectText = value;
                RaisePropertyChanged("SelectText");
            }
        }
    }

    /* This property controls whether or not items can be removed.
     * If set to true, the the 'x' button appears on the ItemTemplate.
     */
    public bool CanRemoveItems
    {
        get { return canRemoveItems; }
        set
        {
            if (canRemoveItems != value)
            {
                canRemoveItems = value;
                RaisePropertyChanged("CanRemoveItems");
            }
        }
    }

    /* This property specifies the collection of items that the user can select from.
     * Note that this uses the INamedItem interface.  The caller must comply with that
     * interface in order to use the ListDialogBox.
     */
    public ObservableCollection<INamedItem> Items
    {
        get { return items; }
        set
        {
            items = value;
            RaisePropertyChanged("Items");
        }
    }

    //Below are the read only properties that the caller uses after
    //prompting for a selection.

    /* This property contains either the selected INamedItem, or null if
     * no selection is made.
     */
    public INamedItem SelectedItem
    {
        get { return selectedItem; }
        private set
        {
            selectedItem = value;
        }
    }

    /* This property indicates if a selection was made.
     * The caller should check this property before trying to use the selected item.
     */
    public bool IsCancelled
    {   //A simple null-check is performed (the caller can do this too).
        get { return (SelectedItem == null); }
    }
}

//This delegate defines the callback signature for the RemoveItem event.
public delegate void RemoveItemEventHandler(object sender, RemoveItemEventArgs e);

/* This class defines the event arguments for the RemoveItem event.
 * It provides access to the item being removed and allows the event to be cancelled.
 */  
public class RemoveItemEventArgs
{
    public RemoveItemEventArgs(INamedItem item)
    {
        RemoveItem = item;
    }

    public INamedItem RemoveItem { get; private set; }
    public bool Cancel { get; set; }
}

INamedItem Interface:

Now that the ListDialogBox has been presented, we need to look at how the caller can make use of it. As mentioned before, the simplest way to do this is to create an interface.

The INamedItem interface provides only one property (called DisplayName) and the ListDialogBox requires a list of these in order to display information. The ListDialogBox depends on the caller to setup a meaningful value for this property.

The interface is incredibly simple:

public interface INamedItem
{
    string DisplayName { get; set; }
}

Usage:

At this point, all of the classes related to the functionality of ListDialogBox have been covered, and it is now time to look and implementing it inside of a program.

To do this, we need to instantiate ListDialogBox, then set customize any desired properties.

ListDialogBox dialog = new ListDialogBox();
dialog.Prompt = "Select a pizza topping to add from the list below:";
dialog.SelectText = "Choose Topping";
dialog.CanRemoveItems = true; //Setting to false will hide the 'x' buttons.

The ListDialogBox requires an ObservableCollection<INamedItem>, so we must generate that before we can continue. To do this, we create a 'wrapper class' for the data type we want to work with. In this example, I'll create a StringItem class which implements INamedItem and sets the DisplayName to an arbitrary string. See below:

public class StringItem : INamedItem
{    //Local copy of the string.
    private string displayName;

    //Creates a new StringItem with the value provided.
    public StringItem(string displayName)
    {   //Sets the display name to the passed-in string.
        this.displayName = displayName;
    }

    public string DisplayName
    {   //Implement the property.  The implementer doesn't need
        //to provide an implementation for setting the property.
        get { return displayName; }
        set { }
    }
}

The StringItem is then used to create the ObservableCollection<INamedItem>:

ObservableCollection<INamedItem> toppings = new ObservableCollection<INamedItem>();
toppings.Add(new StringItem("Pepperoni"));
toppings.Add(new StringItem("Ham"));
toppings.Add(new StringItem("Sausage"));
toppings.Add(new StringItem("Chicken"));
toppings.Add(new StringItem("Mushroom"));
toppings.Add(new StringItem("Onions"));
toppings.Add(new StringItem("Olives"));
toppings.Add(new StringItem("Bell Pepper"));
toppings.Add(new StringItem("Pineapple"));

//Now we can set the list property:
dialog.Items = toppings;

The basic implementation has been set up at this point. We just need to call dialog.ShowDialog(), and process the result. However, since the example allow the user to remove items from the list, we may want prompt for a confirmation. To do this we need to subscribe to the RemoveItem event.

RemoveItemEventHandler myHandler = (object s, RemoveItemEventArgs args) =>
{
    StringItem item = args.RemoveItem as StringItem;
    MessageBoxResult result = MessageBox.Show("Are you sure that you would like" + 
        " to permanently remove \"" + item.DisplayName + "\" from the list?",
        "Remove Topping?", 
        MessageBoxButton.YesNo, MessageBoxImage.Question);

    if (result == MessageBoxResult.No)
    {    //The user cancelled the deletion, so cancel the event as well.
        args.Cancel = true;
    }
};

//Subscribe to RemoveItem event.
dialog.RemoveItem += myHandler;

Finally, we can show the ListDialogBox and process the result. We must also remember to unsubscribe to the RemoveItem event:

dialog.ShowDialog();
dialog.RemoveItem -= myHandler;

//Process the result now.
if (!dialog.IsCancelled)
{
    StringItem item = dialog.SelectedItem as StringItem;
    MessageBox.Show("You added the topping \"" + item.DisplayName +
        "\" to your pizza!");
}

All that is left is to place this code in your application and run it yourself. The above example creates the following ListDialogBox:

ListDialogBox Example

Also, when clicking the 'x' on pepperoni, a prompt is displayed:

RemoveItem Event Prompt

like image 81
Nicholas Miller Avatar answered Oct 22 '22 10:10

Nicholas Miller