Una semplice richiesta da parte di un cliente di Managed Designs relativamente ad una applicazione WPF che deve visualizzare degli elementi in posizioni ben precise ed eseguire delle operazioni in base agli items selezionati ha dato origine a questo post, il cui scopo è quello di mostrare come sia fondamentale cambiare il modo di pensare ad una interfaccia WPF/Silverlight rispetto alla vecchia tecnologia Windows Forms.
Il cliente aveva sviluppato l’idea aggiungendo e posizionando dinamicamente gli elementi ad un Canvas per poi rendersi conto durante lo sviluppo che la soluzione utilizzata portava ad un vicolo cieco.
La soluzione che abbiamo proposto è molto semplice e sfrutta il fatto che un elemento contenitore che ha il concetto di selezione esiste già e si chiama ListBox, e, cosa forse poco nota, la listbox decide la strategia di posizionamento dei propri items in base alla proprietà ItemsPanelTemplate che by default è uno StackPanel con orientamento verticale.
Supponiamo di voler realizzare una UI che mostri i tavoli di un ristorante opportunamente posizionati indicando visivamente quali sono disponibili, il tutto ovviamente in puro M-V-VM style.

Partiamo dal ViewModel che descrive il generico tavolo:

   1: public class TableViewModel : ViewModelBase
   2:     {
   3:         public Size Size { get; set; }
   4:         public int TableNumber { get; set; }
   5:         public bool IsSelected { get; set; }
   6:         public bool IsAvailable { get; set; }
   7:         public string ReservedBy { get; set; }
   8:         public int X { get; set; }
   9:         public int Y { get; set; }
  10:     }

 

Nota: Ho usato delle auto-properties per brevità, in realtà il codice usa delle proprietà che nel setter invocano il metodo RaisePropertyChanged(“PropertyName”) contenuto nella classe base ViewModelBase la quale implementa INotifyPropertyChanged.

A questo punto ecco il RoomViewModel che espone l’insieme di tavoli:

   1: public class RoomViewModel2 : ViewModelBase
   2:     {
   3:         private ObservableCollection<TableViewModel> tables = new ObservableCollection<TableViewModel>();
   4:  
   5:         public ObservableCollection<TableViewModel> Tables
   6:         {
   7:             get { return tables; }
   8:         }
   9:  
  10:         public RoomViewModel2()
  11:         {
  12:             TableViewModel tvm1 = new TableViewModel() { IsAvailable = true, ReservedBy = "Fam. Rossi", X = 30, Y = 50, 
  13:                                                                         Size = new Size(200, 150), TableNumber = 1 };
  14:             TableViewModel tvm2 = new TableViewModel() { IsAvailable = false, ReservedBy = "Sig. Verdi", X = 420, Y = 80, 
  15:                                                                         Size = new Size(200, 130), TableNumber = 2 };
  16:             TableViewModel tvm3 = new TableViewModel() { IsAvailable = true, ReservedBy = "Mauro e Monica", X = 300, Y = 250, 
  17:                                                                         Size = new Size(200, 100), TableNumber = 3 };
  18:             this.Tables.Add(tvm1);
  19:             this.Tables.Add(tvm2);
  20:             this.Tables.Add(tvm3);
  21:         }
  22:     }
  23: }

Come vedete nel costruttore aggiungo dei tavoli fittizi, il tutto andrebbe ovviamente fatto quando siamo a design-time, ho omesso il check per comodità. smile_regular

A questo punto rendiamo disponibile a design time il nostro RoomViewModel usando la tecnica che ho descritto in un post precedente e passiamo ora alla vera novità, ovvero usare un Canvas come contenitore degli items associati alla listbox, lo XAML che realizza il tutto è il seguente:

   1: <Grid Background="#FF323030">
   2:         <Grid.RowDefinitions>
   3:             <RowDefinition Height="562" />
   4:         </Grid.RowDefinitions>
   5:         <ListBox ItemsSource="{Binding Tables}"
   6:                  ItemContainerStyle="{DynamicResource ListBoxItemStyle1}"
   7:                  ItemTemplate="{DynamicResource TableItemTemplate}"
   8:                  Background="{x:Null}">
   9:             <ListBox.ItemsPanel>
  10:                 <ItemsPanelTemplate>
  11:                     <Canvas />
  12:                 </ItemsPanelTemplate>
  13:             </ListBox.ItemsPanel>
  14:         </ListBox>
  15:     </Grid>

Dobbiamo ora ‘posizionare’ opportunamente i vari items all’interno del canvas in base al valore delle proprietà X e Y dei vari ViewModels e questo lo facciamo all’interno dell’ItemContainerStyle chiama, con molta fantasia, ListBoxItemStyle1:

   1: <Style x:Key="ListBoxItemStyle1"
   2:        TargetType="{x:Type ListBoxItem}">
   3:     <Setter Property="IsSelected"
   4:             Value="{Binding IsSelected, Mode=TwoWay}" />
   5:     <Setter Property="Canvas.Left"
   6:             Value="{Binding X}" />
   7:     <Setter Property="Canvas.Top"
   8:             Value="{Binding Y}" />
   9:     <Setter Property="Background"
  10:             Value="Transparent" />
  11:     <Setter Property="HorizontalContentAlignment"
  12:             Value="{Binding HorizontalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}" />
  13:     <Setter Property="VerticalContentAlignment"
  14:             Value="{Binding VerticalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}" />
  15:     <Setter Property="Padding"
  16:             Value="2,0,0,0" />
  17:     <Setter Property="Template">
  18:         <Setter.Value>
  19:             <ControlTemplate TargetType="{x:Type ListBoxItem}">
  20:                 <Border x:Name="Bd"
  21:                         SnapsToDevicePixels="true"
  22:                         Background="{TemplateBinding Background}"
  23:                         BorderBrush="{TemplateBinding BorderBrush}"
  24:                         BorderThickness="{TemplateBinding BorderThickness}"
  25:                         Padding="{TemplateBinding Padding}">
  26:                     <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
  27:                                       VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
  28:                                       SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
  29:                 </Border>
  30:             </ControlTemplate>
  31:         </Setter.Value>
  32:     </Setter>
  33: </Style>

A parte un po’ di ‘fuffa’ accessoria, il posizionamento avviene attraverso queste due righe:

   1: <Setter Property="Canvas.Left" Value="{Binding X}" />
   2: <Setter Property="Canvas.Top"  Value="{Binding Y}" />

Notate inoltre come nello style la proprietà IsSelected del ViewModel venga collegata all’effettiva selezione dell’elemento nella listbox questo per poterla poi facilmente utilizzare successivamente senza passare attraverso l’assurda sintassi richiesta da RelativeSource e per poter facilmente recuperare l’elemento selezionato all’interno del RoomViewModel stesso.

Ora non ci resta che creare il DataTemplate da usare per renderizzare le varie istanze di TableViewModel presenti nella listbox:

   1: <DataTemplate x:Key="TableItemTemplate">
   2:            <DataTemplate.Resources>
   3:                <Storyboard x:Key="TableSelectedAnimation">
   4:                     ...
   5:                </Storyboard>
   6:                <Storyboard x:Key="TableUnSelectedAnimation">
   7:                     ...
   8:                </Storyboard>
   9:            </DataTemplate.Resources>
  10:            <Border x:Name="bd"
  11:                    Width="{Binding Size.Width}"
  12:                    Height="{Binding Size.Height}"
  13:                    BorderBrush="White"
  14:                    BorderThickness="5"
  15:                    CornerRadius="20"
  16:                    Background="#FF25F404"
  17:                    RenderTransformOrigin="0.5,0.5">
  18:                <Border.RenderTransform>
  19:                    <TransformGroup>
  20:                        ...
  21:                    </TransformGroup>
  22:                </Border.RenderTransform>
  23:                <Grid d:DesignWidth="260"
  24:                      d:DesignHeight="197">
  25:                    <Grid.RowDefinitions>
  26:                        <RowDefinition Height="Auto"
  27:                                       MinHeight="56.057" />
  28:                        <RowDefinition Height="Auto"
  29:                                       MinHeight="23.94" />
  30:                        <RowDefinition Height="57.003" />
  31:                    </Grid.RowDefinitions>
  32:                    <TextBlock Text="{Binding ReservedBy}"
  33:                               TextWrapping="Wrap"
  34:                               VerticalAlignment="Stretch"
  35:                               HorizontalAlignment="Center"
  36:                               FontWeight="Bold"
  37:                               FontSize="16"
  38:                               TextTrimming="CharacterEllipsis"
  39:                               TextAlignment="Center"
  40:                               Foreground="White"
  41:                               Grid.Row="1" />
  42:                    <Grid Margin="0,10.293,0,10.294">
  43:                        <TextBlock VerticalAlignment="Center"
  44:                                   RenderTransformOrigin="0.53,0.162"
  45:                                   FontSize="26.667"
  46:                                   FontWeight="Bold"
  47:                                   Foreground="Black"
  48:                                   Text="{Binding TableNumber, Mode=Default}"
  49:                                   TextAlignment="Center"
  50:                                   TextWrapping="Wrap"
  51:                                   Margin="0,0,0,3" />
  52:                        <Ellipse Fill="{x:Null}"
  53:                                 Stroke="#FFEFEFEF"
  54:                                 StrokeThickness="3"
  55:                                 Margin="77,0" />
  56:                    </Grid>
  57:                </Grid>
  58:            </Border>
  59:            <DataTemplate.Triggers>
  60:                <DataTrigger Binding="{Binding IsAvailable}"
  61:                             Value="False">
  62:                    <Setter TargetName="bd"
  63:                            Property="Background"
  64:                            Value="Red" />
  65:                </DataTrigger>
  66:                <DataTrigger Binding="{Binding IsSelected}"
  67:                             Value="True">
  68:                    <Setter TargetName="bd"
  69:                            Value="Yellow"
  70:                            Property="BorderBrush" />
  71:                    <DataTrigger.EnterActions>
  72:                        <BeginStoryboard Storyboard="{StaticResource TableSelectedAnimation }" />
  73:                    </DataTrigger.EnterActions>
  74:                    <DataTrigger.ExitActions>
  75:                        <BeginStoryboard Storyboard="{StaticResource TableUnSelectedAnimation }" />
  76:                    </DataTrigger.ExitActions>
  77:                </DataTrigger>
  78:            </DataTemplate.Triggers>
  79:        </DataTemplate>

Ho omesso lo XAML relativo alle animazioni in quanto poco interessante, quello che conta è notare come il template altro non sia che un border con un TextBlock opportunamente bindato e che lo stesso faccia uso di DataTriggers sia cambiare il colore del border in base alla disponibilità sia per far partire delle animazioni quando l’elemento viene selezionato, peccato che la parte relativa ai DataTrigger sia da scrivere a mano in tutt’ora quanto non supportata da Expression Blend 3.
Il risultato finale è questo:
image  
che a prima vista, tutto ricorda, tranne che una ListBox smile_wink ma che è decisamente più semplice e funzionale rispetto alla soluzione originale.