Related
I am trying to achieve effect where each column has its own border, but yet can not find a perfectly working solution.
This kind of look is desired but this is implemented by putting 3 borders in 3 columned Grid, which is not flexible as Grid columns and DataGrid columns are being sized sized separately
<Window x:Class="WpfApp3.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfApp3" xmlns:usercontrols="clr-namespace:EECC.UserControls"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid Background="LightGray">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="White" CornerRadius="5" BorderThickness="1" BorderBrush="Black" Margin="5"/>
<Border Background="White" CornerRadius="5" BorderThickness="1" BorderBrush="Black" Margin="5" Grid.Column="1"/>
<Border Background="White" CornerRadius="5" BorderThickness="1" BorderBrush="Black" Margin="5" Grid.Column="2"/>
<DataGrid ItemsSource="{Binding Items}" ColumnWidth="*" AutoGenerateColumns="True" Padding="10" GridLinesVisibility="None" Background="Transparent" Grid.ColumnSpan="3">
<DataGrid.Resources>
<Style TargetType="{x:Type DataGridRow}">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style TargetType="{x:Type DataGridColumnHeader}">
<Setter Property="Background" Value="Transparent"/>
</Style>
</DataGrid.Resources>
</DataGrid>
</Grid>
This is not trivial if you want to use the DataGrid. The "problem" is that the DataGrid uses a Grid to host the cells. The cell borders are drawn using the feature of the Grid to show grid lines. You can hide the grid lines but still you have the Grid controlling the layout.
Creating the gaps you want should not come easy. The layout system of the Grid makes it an effort to implement a solution that scales well. You can extend the SelectiveScrollingGrid (the hosting panel of the DataGrid) and add the gaps when laying out the items. Knowing about DataGrid internals, I can say it is possible, but not worth the effort.
The alternative solution would be to use a ListView with a GridView as host. The problem here is that the GridView is designed to show rows as single item. You have no chance to modify margins column based. You can only adjust the content. I have not tried to modify the ListView internal layout elements or override the layout algorithm, but in context of alternative solutions I would also rule the ListView using a GridView out - but it is possible. It's not worth the effort.
Solution: Custom View
The simplest solution I can suggest is to adjust the data structure to show data column based. This way you can use a horizontal ListBox. Each item makes a column. Each column is realized as vertical ListBox. You basically have nested ListBox elements.
You would have to take care of the row mapping in order to allow selecting cells of a common row across the vertical ListBox columns.
This can be easily achieved by adding a RowIndex property to the CellItem models.
The idea is to have the horizontal ListBox display a collection of ColumnItem models. Each column item model exposes a collection of CellItem models. The CellItem items of different columns but the same row must share the same CellItem.RowIndex.
As a bonus, this solution is very easy to style. ListBox template has almost no parts compared to the significantly more complex DataGrid or the slightly more complex GridView.
To make showcasing the concept less confusing I chose to implement the grid layout as UserControl. For the sake of simplicity the logic to initialize and host the models and source collections is implemented inside this UserControl. I don't recommend this. Instantiation and hosting of the items should be outside the control e.g., inside a view model. You should add a DependencyProperty as ItemsSource for the control as data source for the internal horizontal ListBox.
Usage Example
<Window>
<ColumnsView />
</Window>
First create the data structure to populate the view.
The structure is based on the type ColumnItem, which hosts a collection of
CellItem items where each CellItem has a CellItem.RowIndex.
The CellItem items of different columns, that logically form a row must share the same CellItem.RowIndex.
ColumnItem.cs
public class ColumnItem
{
public ColumnItem(string header, IEnumerable<CellItem> items)
{
Header = header;
this.Items = new ObservableCollection<CellItem>(items);
}
public CellItem this[int rowIndex]
=> this.Items.FirstOrDefault(cellItem => cellItem.RowIndex.Equals(rowIndex));
public string Header { get; }
public ObservableCollection<CellItem> Items { get; }
}
CellItem.cs
public class CellItem : INotifyPropertyChanged
{
public CellItem(int rowIndex, object value)
{
this.RowIndex = rowIndex;
this.Value = value;
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "")
=> this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
public event PropertyChangedEventHandler PropertyChanged;
public int RowIndex { get; }
private object value;
public object Value
{
get => this.value;
set
{
this.value = value;
OnPropertyChanged();
}
}
private bool isSelected;
public bool IsSelected
{
get => this.isSelected;
set
{
this.isSelected = value;
OnPropertyChanged();
}
}
}
Build and initialize the data structure.
In this example this is all implemented in the UserControl itself with the intend to keep the example as compact as possible.
ColumnsView.xaml.cs
public partial class ColumnsView : UserControl
{
public ColumnsView()
{
InitializeComponent();
this.DataContext = this;
InitializeSourceData();
}
public InitializeSourceData()
{
this.Columns = new ObservableCollection<ColumnItem>();
for (int columnIndex = 0; columnIndex < 3; columnIndex++)
{
var cellItems = new List<CellItem>();
int asciiChar = 65;
for (int rowIndex = 0; rowIndex < 10; rowIndex++)
{
var cellValue = $"CellItem.RowIndex:{rowIndex}, Value: {(char)asciiChar++}";
var cellItem = new CellItem(rowIndex, cellValue);
cellItems.Add(cellItem);
}
var columnHeader = $"Column {columnIndex + 1}";
var columnItem = new ColumnItem(columnHeader, cellItems);
this.Columns.Add(columnItem);
}
}
private void CellsHostListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
var cellsHost = sender as Selector;
var selectedCell = cellsHost.SelectedItem as CellItem;
SelectCellsOfRow(selectedCell.RowIndex);
}
private void SelectCellsOfRow(int selectedRowIndex)
{
foreach (ColumnItem columnItem in this.Columns)
{
var cellOfRow = columnItem[selectedRowIndex];
cellOfRow.IsSelected = true;
}
}
private void ColumnGripper_DragStarted(object sender, DragStartedEventArgs e)
=> this.DragStartX = Mouse.GetPosition(this).X;
private void ColumnGripper_DragDelta(object sender, DragDeltaEventArgs e)
{
if ((sender as DependencyObject).TryFindVisualParentElement(out ListBoxItem listBoxItem))
{
double currentMousePositionX = Mouse.GetPosition(this).X;
listBoxItem.Width = Math.Max(0 , listBoxItem.ActualWidth - (this.DragStartX - currentMousePositionX));
this.DragStartX = currentMousePositionX;
}
}
public static bool TryFindVisualParentElement<TParent>(DependencyObject child, out TParent resultElement)
where TParent : DependencyObject
{
resultElement = null;
DependencyObject parentElement = VisualTreeHelper.GetParent(child);
if (parentElement is TParent parent)
{
resultElement = parent;
return true;
}
return parentElement != null
? TryFindVisualParentElement(parentElement, out resultElement)
: false;
}
public ObservableCollection<ColumnItem> Columns { get; }
private double DragStartX { get; set; }
}
Create the view using a horizontal ListView that renders it's ColumnItem source collection as a list of vertical ListBox elements.
ColumnsView.xaml
<UserControl x:Class="ColumnsView">
<UserControl.Resources>
<Style x:Key="ColumnGripperStyle"
TargetType="{x:Type Thumb}">
<Setter Property="Margin"
Value="-2,8" />
<Setter Property="Width"
Value="4" />
<Setter Property="Background"
Value="Transparent" />
<Setter Property="Cursor"
Value="SizeWE" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Thumb}">
<Border Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<!-- Column host. Displays cells of a column. -->
<ListBox ItemsSource="{Binding Columns}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Padding="4"
BorderThickness="1"
BorderBrush="Black"
CornerRadius="8">
<StackPanel>
<TextBlock Text="{Binding Header}" />
<!-- Cell host. Displays cells of a column. -->
<ListBox ItemsSource="{Binding Items}"
BorderThickness="0"
Height="150"
Selector.SelectionChanged="CellsHostListBox_SelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Value}" />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<!-- Link item container selection to CellItem.IsSelected -->
<Setter Property="IsSelected" Value="{Binding IsSelected}" />
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,8,0" /> <!-- Define the column gap -->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter />
<Thumb Grid.Column="1"
Style="{StaticResource ColumnGripperStyle}"
DragStarted="ColumnGripper_DragStarted"
DragDelta="ColumnGripper_DragDelta" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</UserControl>
Notes for improvement
The ColumnsView.Columns property should be a DependencyProperty to allow to use the control as binding target.
The column gap can also be a DependencyProperty of ColumnsView.
By replacing the TextBlock that displays the column header with a Button, you can easily add sorting. Having the active column that triggers the sorting e.g. lexically, you would have to sync the other passive columns and sort them based on the CellItem.RowIndex order of the active sorted column.
Maybe choose to extend Control rather than UserControl.
You can implement the CellItem to use a generic type parameter to declare the Cellitem.Value property like CellItem<TValue>.
You can implement the ColumnItem to use a generic type parameter to declare the ColumnItem.Items property like ColumnItem<TColumn>.
Add a ColumnsView.SelectedRow property that returns a collection of all CellItem items of the current selected row
With a fair amount of elbow grease, you can get the desired look by extending the native DataGrid.
Custom header and cell templates should take care of the spacing, with the appropriate background color. The AutoGeneratingColumn behavior requires more control than could easily be achieved in XAML, so I chose to create the templates in code to be able to pass the column's PropertyName.
The observant reader will already have asked themselves: "What about the border at the end of the list?". That's right, we need to be able to distinguish the last item from all others, to be able to template its border differently.
This is done with the following contract:
public interface ICanBeLastItem
{
bool IsLastItem { get; set; }
}
Which the row object needs to implement for the bottom border to be drawn correctly.
This also requires some custom logic when sorting, to update the value of IsLastItem. The pic with the yellow background shows the result of sorting on ThirdNumber.
The native DataGrid provides a Sorting event out of the box, but no Sorted event. The template thingy combined with the need for a custom event, led me to subclass ColumnView from DataGrid instead of declaring it as a UserControl.
I added code-behind to MainWindow, for switching the background color, but that's just for illustration purposes (as I didn't feel like implementing the Command pattern) and has nothing to do with the custom control.
The ColumnView is configured through binding. As always, feel free to extend. The current implementation expects the columns to be auto generated. In either case, the code for generating the templates is provided.
<local:ColumnView ItemsSource="{Binding Items}" Background="LightSteelBlue"/>
Demo code
ColumnView
public class ColumnView : DataGrid
{
public ColumnView()
{
HeadersVisibility = DataGridHeadersVisibility.Column;
HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
// Hidden props from base DataGrid
base.ColumnWidth = new DataGridLength(1, DataGridLengthUnitType.Star);
base.AutoGenerateColumns = true;
base.GridLinesVisibility = DataGridGridLinesVisibility.None;
// Styling
ColumnHeaderStyle = CreateColumnHeaderStyle();
CellStyle = CreateCellStyle(this);
// Event handling
AutoGeneratingColumn += OnAutoGeneratingColumn;
Sorting += OnSorting;
Sorted += OnSorted;
}
#region Hidden props
[Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
public new DataGridLength ColumnWidth
{
get => base.ColumnWidth;
set => new InvalidOperationException($"{nameof(ColumnView)} doesn't allow changing {nameof(ColumnWidth)}.");
}
[Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
public new DataGridGridLinesVisibility GridLinesVisibility
{
get => base.GridLinesVisibility;
set => new InvalidOperationException($"{nameof(ColumnView)} doesn't allow changing {nameof(GridLinesVisibility)}.");
}
[Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
public new bool AutoGenerateColumns
{
get => base.AutoGenerateColumns;
set => new InvalidOperationException($"{nameof(ColumnView)} doesn't allow changing {nameof(AutoGenerateColumns)}.");
}
#endregion Hidden props
#region Styling
private static Style CreateColumnHeaderStyle()
=> new Style(typeof(DataGridColumnHeader))
{
Setters =
{
new Setter(BackgroundProperty, Brushes.Transparent),
new Setter(HorizontalAlignmentProperty, HorizontalAlignment.Stretch),
new Setter(HorizontalContentAlignmentProperty, HorizontalAlignment.Stretch)
}
};
private static Style CreateCellStyle(ColumnView columnView)
=> new Style(typeof(DataGridCell))
{
Setters =
{
new Setter(BorderThicknessProperty, new Thickness(0.0)),
new Setter(BackgroundProperty, new Binding(nameof(Background)) { Source = columnView})
}
};
#endregion Styling
#region AutoGeneratingColumn
// https://stackoverflow.com/questions/25643765/wpf-datagrid-databind-to-datatable-cell-in-celltemplates-datatemplate
private static void OnAutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e)
{
if (sender is ColumnView columnView)
{
if (e.PropertyName == nameof(ICanBeLastItem.IsLastItem))
{
e.Cancel = true;
}
else
{
var column = new DataGridTemplateColumn
{
CellTemplate = CreateCustomCellTemplate(e.PropertyName),
Header = e.Column.Header,
HeaderTemplate = CreateCustomHeaderTemplate(columnView, e.PropertyName),
HeaderStringFormat = e.Column.HeaderStringFormat,
SortMemberPath = e.PropertyName
};
e.Column = column;
}
}
}
private static DataTemplate CreateCustomCellTemplate(string path)
{
// Create the data template
var customTemplate = new DataTemplate();
// Set up the wrapping border
var border = new FrameworkElementFactory(typeof(Border));
border.SetValue(BorderBrushProperty, Brushes.Black);
border.SetValue(StyleProperty, new Style(typeof(Border))
{
Triggers =
{
new DataTrigger
{
Binding = new Binding(nameof(DataGridCell.IsSelected)) { RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGridCell), 1) },
Value = false,
Setters =
{
new Setter(BackgroundProperty, Brushes.White),
}
},
new DataTrigger
{
Binding = new Binding(nameof(DataGridCell.IsSelected)) { RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGridCell), 1) },
Value = true,
Setters =
{
new Setter(BackgroundProperty, SystemColors.HighlightBrush),
}
},
new DataTrigger
{
Binding = new Binding(nameof(ICanBeLastItem.IsLastItem)),
Value = false,
Setters =
{
new Setter(MarginProperty, new Thickness(5.0, -1.0, 5.0, -1.0)),
new Setter(BorderThicknessProperty, new Thickness(1.0, 0.0, 1.0, 0.0)),
}
},
new DataTrigger
{
Binding = new Binding(nameof(ICanBeLastItem.IsLastItem)),
Value = true,
Setters =
{
new Setter(MarginProperty, new Thickness(5.0, -1.0, 5.0, 0.0)),
new Setter(BorderThicknessProperty, new Thickness(1.0, 0.0, 1.0, 1.0)),
new Setter(Border.CornerRadiusProperty, new CornerRadius(0.0, 0.0, 5.0, 5.0)),
new Setter(Border.PaddingProperty, new Thickness(0.0, 0.0, 0.0, 5.0)),
}
}
}
});
// Set up the TextBlock
var textBlock = new FrameworkElementFactory(typeof(TextBlock));
textBlock.SetBinding(TextBlock.TextProperty, new Binding(path));
textBlock.SetValue(MarginProperty, new Thickness(10.0, 0.0, 5.0, 0.0));
// Set the visual tree of the data template
border.AppendChild(textBlock);
customTemplate.VisualTree = border;
return customTemplate;
}
private static DataTemplate CreateCustomHeaderTemplate(ColumnView columnView, string propName)
{
// Create the data template
var customTemplate = new DataTemplate();
// Set up the wrapping border
var border = new FrameworkElementFactory(typeof(Border));
border.SetValue(MarginProperty, new Thickness(5.0, 0.0, 5.0, 0.0));
border.SetValue(BackgroundProperty, Brushes.White);
border.SetValue(BorderBrushProperty, Brushes.Black);
border.SetValue(BorderThicknessProperty, new Thickness(1.0, 1.0, 1.0, 0.0));
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(5.0, 5.0, 0.0, 0.0));
// Set up the TextBlock
var textBlock = new FrameworkElementFactory(typeof(TextBlock));
textBlock.SetValue(TextBlock.TextProperty, propName);
textBlock.SetValue(MarginProperty, new Thickness(5.0));
// Set the visual tree of the data template
border.AppendChild(textBlock);
customTemplate.VisualTree = border;
return customTemplate;
}
#endregion AutoGeneratingColumn
#region Sorting
#region Custom Sorted Event
// https://stackoverflow.com/questions/9571178/datagrid-is-there-no-sorted-event
// Create a custom routed event by first registering a RoutedEventID
// This event uses the bubbling routing strategy
public static readonly RoutedEvent SortedEvent = EventManager.RegisterRoutedEvent(
nameof(Sorted), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ColumnView));
// Provide CLR accessors for the event
public event RoutedEventHandler Sorted
{
add => AddHandler(SortedEvent, value);
remove => RemoveHandler(SortedEvent, value);
}
// This method raises the Sorted event
private void RaiseSortedEvent()
{
var newEventArgs = new RoutedEventArgs(ColumnView.SortedEvent);
RaiseEvent(newEventArgs);
}
protected override void OnSorting(DataGridSortingEventArgs eventArgs)
{
base.OnSorting(eventArgs);
RaiseSortedEvent();
}
#endregion Custom Sorted Event
private static void OnSorting(object sender, DataGridSortingEventArgs e)
{
if (sender is DataGrid dataGrid && dataGrid.HasItems)
{
if (dataGrid.Items[dataGrid.Items.Count - 1] is ICanBeLastItem lastItem)
{
lastItem.IsLastItem = false;
}
}
}
private static void OnSorted(object sender, RoutedEventArgs e)
{
if (sender is DataGrid dataGrid && dataGrid.HasItems)
{
if (dataGrid.Items[dataGrid.Items.Count - 1] is ICanBeLastItem lastItem)
{
lastItem.IsLastItem = true;
}
}
}
#endregion Sorting
}
RowItem
public class RowItem : INotifyPropertyChanged, ICanBeLastItem
{
public RowItem(int firstNumber, string secondNumber, double thirdNumber)
{
FirstNumber = firstNumber;
SecondNumber = secondNumber;
ThirdNumber = thirdNumber;
}
public int FirstNumber { get; }
public string SecondNumber { get; }
public double ThirdNumber { get; }
private bool _isLastItem;
public bool IsLastItem
{
get => _isLastItem;
set
{
_isLastItem = value;
OnPropertyChanged();
}
}
#region INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion INotifyPropertyChanged
}
public interface ICanBeLastItem
{
bool IsLastItem { get; set; }
}
MainWindow.xaml
<Window x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfApp"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Window.DataContext>
<local:MainViewModel/>
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="30"/>
<RowDefinition/>
</Grid.RowDefinitions>
<Button Content="Switch Background" Click="ButtonBase_OnClick" />
<local:ColumnView x:Name="columnView" Grid.Row="1" Padding="10"
ItemsSource="{Binding Items}"
Background="LightSteelBlue"/>
</Grid>
</Window>
MainWindow.xaml.cs
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
if (columnView.Background == Brushes.LightSteelBlue)
{
columnView.Background = Brushes.DarkRed;
}
else if (columnView.Background == Brushes.DarkRed)
{
columnView.Background = Brushes.Green;
}
else if (columnView.Background == Brushes.Green)
{
columnView.Background = Brushes.Blue;
}
else if (columnView.Background == Brushes.Blue)
{
columnView.Background = Brushes.Yellow;
}
else
{
columnView.Background = Brushes.LightSteelBlue;
}
}
}
MainViewModel
public class MainViewModel
{
public MainViewModel()
{
Items = InitializeItems(200);
}
private ObservableCollection<RowItem> InitializeItems(int numberOfItems)
{
var rowItems = new ObservableCollection<RowItem>();
var random = new Random();
for (var i = 0; i < numberOfItems; i++)
{
var firstNumber = Convert.ToInt32(1000 * random.NextDouble());
var secondNumber = Convert.ToString(Math.Round(1000 * random.NextDouble()));
var thirdNumber = Math.Round(1000 * random.NextDouble());
var rowItem = new RowItem(firstNumber, secondNumber, thirdNumber);
rowItems.Add(rowItem);
}
rowItems[numberOfItems - 1].IsLastItem = true;
return rowItems;
}
public ObservableCollection<RowItem> Items { get; }
}
I'm trying to get all the DataGrid Expanders to open and close using a open/close button like the example below. Problem is when I'm using a single boolean to bind to this will break when I'm opening/closing a single expander.
I'm using a ListCollectionView to set the GroupingDescription.
I guess I'm looking for a solution to somehow get the Expander to play nice?
View
<DataGrid.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type GroupItem}">
<Expander IsExpanded="{Binding Source={StaticResource proxy}, Path=Data.Expanded, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<Expander.Header>
<StackPanel>
<!-- label -->
</StackPanel>
</Expander.Header>
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</DataGrid.GroupStyle>
ViewModel
public bool Expanded
{
get { return _expanded; }
set { _expanded = value; OnPropertyChanged(); }
}
public ListCollectionView Items
{
get
{
return _items;
}
set
{
_items = value; OnPropertyChanged();
}
}
// logic
var items = new ListCollectionView(planninglist);
items.SortDescriptions.Add(new items.GroupDescriptions.Add(new PropertyGroupDescription("AanZet"));
items.IsLiveSorting = true;
Update
Fix based on SO answer suggested in the comments by #XAMlMAX.
public class ExpanderBehavior
{
public static readonly DependencyProperty IsExpandedProperty =
DependencyProperty.RegisterAttached("IsExpanded",
typeof(bool),
typeof(ExpanderBehavior),
new PropertyMetadata(OnChanged));
public static bool GetIsExpanded(DependencyObject obj)
{
return (bool)obj.GetValue(IsExpandedProperty);
}
public static void SetIsExpanded(DependencyObject obj, bool value)
{
obj.SetValue(IsExpandedProperty, value);
}
private static void OnChanged(DependencyObject o,
DependencyPropertyChangedEventArgs args)
{
Expander tb = o as Expander;
if (null != tb)
tb.IsExpanded = (bool)args.NewValue;
}
}
As per comment conversation.
Reason why you were experiencing issue with expander staying open even though it was bound is because when you use OneWay Binding and then click on the button it then becomes disconnected. To have a better idea on when it becomes disconnected use PresentationTraceSources.TraceLevel=High.
To overcome this, one can use an attached property to stop Binding from detaching.
Now this example is for ToggleButton but the logic should apply the same.
public class TBExtender
{
public static readonly DependencyProperty IsCheckedProperty =
DependencyProperty.RegisterAttached("IsChecked",
typeof(bool),
typeof(TBExtender),
new PropertyMetadata(OnChanged));
public static bool GetIsChecked(DependencyObject obj)
{
return (bool)obj.GetValue(IsCheckedProperty);
}
public static void SetIsChecked(DependencyObject obj, bool value)
{
obj.SetValue(IsCheckedProperty, value);
}
private static void OnChanged(DependencyObject o,
DependencyPropertyChangedEventArgs args)
{
ToggleButton tb = o as ToggleButton;
if (null != tb)
tb.IsChecked = (bool)args.NewValue;
}
}
Credit goes to alex-p
and wallstreet-programmer
for responses on this SO question.
The TimeSpanUpDown (Extended WPF Toolkit) seems to have a display bug when the number of days changes from 0 to >0.
Here is a simple way to reproduce it:
<Window x:Class="TimeSpanBug.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
Height="100" Width="200">
<StackPanel>
<!-- Bind both to the same TimeSpan property in the ViewModel -->
<xctk:TimeSpanUpDown Value="{Binding TimeSpan}"/>
<xctk:TimeSpanUpDown Value="{Binding TimeSpan}"/>
</StackPanel>
</Window>
Enter a time span close to but below 24h. The number of days is automatically hidden.
Then press the up-arrow on the first control to increase the time span to >24h. The control now updates its display to include the number of days. The second control receives the property changed notification and also tries to update, but ends up in a weird state:
Obviously, this is a bug and should be fixed by Xceed, but does anyone know a quick and easy fix or workaround?
Why not roll your own? Welcome to custom control authoring! A simple starting point without the above bug, which you can customize to your heart's content:
[TemplatePart(Name = "UP", Type = typeof(ButtonBase))]
[TemplatePart(Name = "DOWN", Type = typeof(ButtonBase))]
public class TimeSpinner : Control
{
static TimeSpinner()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(TimeSpinner), new FrameworkPropertyMetadata(typeof(TimeSpinner)));
}
public TimeSpan Time
{
get { return (TimeSpan)GetValue(TimeProperty); }
set { SetValue(TimeProperty, value); }
}
// Using a DependencyProperty as the backing store for Time. This enables animation, styling, binding, etc...
public static readonly DependencyProperty TimeProperty =
DependencyProperty.Register("Time", typeof(TimeSpan), typeof(TimeSpinner), new PropertyMetadata(TimeSpan.Zero));
public TimeSpan Interval
{
get { return (TimeSpan)GetValue(IntervalProperty); }
set { SetValue(IntervalProperty, value); }
}
// Using a DependencyProperty as the backing store for Interval. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IntervalProperty =
DependencyProperty.Register("Interval", typeof(TimeSpan), typeof(TimeSpinner), new PropertyMetadata(TimeSpan.FromTicks(TimeSpan.TicksPerHour)));
private static readonly TimeSpan OneDay = TimeSpan.FromTicks(TimeSpan.TicksPerDay);
void UpTime()
{
var newTime = Time + Interval;
if (newTime >= OneDay)
Time = newTime - OneDay;
else
Time = newTime;
}
void DownTime()
{
var newTime = Time - Interval;
if (newTime < TimeSpan.Zero)
Time = newTime + OneDay;
else
Time = newTime;
}
public override void OnApplyTemplate()
{
var upButton = GetUIPart<ButtonBase>("UP");
if(upButton != null)
upButton.Click += upButton_Click;
if (_upButton != null)
_upButton.Click -= upButton_Click;
_upButton = upButton;
var downButton = GetUIPart<ButtonBase>("DOWN");
if (downButton != null)
downButton.Click += downButton_Click;
if (_downButton != null)
_downButton.Click -= downButton_Click;
_downButton = downButton;
}
void downButton_Click(object sender, RoutedEventArgs e)
{
DownTime();
}
void upButton_Click(object sender, RoutedEventArgs e)
{
UpTime();
}
private ButtonBase _upButton;
private ButtonBase _downButton;
T GetUIPart<T>(string name) where T : DependencyObject
{
return (T) GetTemplateChild(name);
}
}
and a sample template (which should put in a ResourceDictionary called Generic.xaml in a folder called Themes at the root of your project):
<Style TargetType="{x:Type local:TimeSpinner}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:TimeSpinner}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel>
<UniformGrid Columns="1" DockPanel.Dock="Right">
<Button x:Name="UP" Content="+"/>
<Button x:Name="DOWN" Content="-"/>
</UniformGrid>
<TextBox Text="{Binding Time, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"/>
</DockPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
What I am trying to achieve is essentially in-place editing of a databound object inside an ItemsControl in wpf.
my ItemsControl is a horizontal WrapPanel containing multiple instances of a usercontrol (NameControl) which displays as a little pink Glyph with a person's name. It looks like this
With a popup I am able to show an editor for this "Name" (Other properties of the bound object things like Address,Gender etc.) and this works absoluttely fine. My XAML at this point would be along the lines of
<Style x:Key="NamesStyle" TargetType="{x:Type ItemsControl}">
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemTemplate">
<Setter.Value>
<DataTemplate>
<StackPanel>
<Button Command="{Binding EditName}" BorderThickness="0" Background="Transparent" Panel.ZIndex="1">
<widgets:NameControl />
</Button>
<Popup IsOpen="{Binding IsEditMode}"
PlacementTarget="{Binding ElementName=button}"
Margin="0 5 0 0" Placement="Relative" AllowsTransparency="True" >
<Border Background="White" BorderBrush="DarkOrchid" BorderThickness="1,1,1,1" CornerRadius="5,5,5,5"
Panel.ZIndex="100">
<Grid ShowGridLines="False" Margin="5" Background="White" Width="300">
<!-- Grid Content - just editor fields/button etc -->
</Grid>
</Border>
</Popup>
</StackPanel>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
Giving an output when I click a Name looking like
With this look im quite happy (apart from my awful choice of colours!!) except that the popup does not move with the widow (resize/minimize/maximize) and that popup is above everything even other windows.
So one way to solve part of that is to "attach" or lock the popup position to the element. I have not found a good/easy/xaml way to do that. Ive come across a few code-based solutions but im not sure I like that. It just has a bit of a smell about it.
Another solution ive tried to achieve is to ditch the popup and try to emulate the behaviour of a layer/panel that sits above the other names but is position over (or below, im not fussy) the associated name control.
Ive tried a few different things, mainly around setting Panel.ZIndex on controls within a PanelControl (The Grid, the WrapPanel, a DockPanel on the very top of my MainWindow) with little success. I have implemented a simple BoolToVisibilityConverter to bind my editor Grid's Visibility property to my IsEditMode view model property and that works fine, but I cant for the life of me arrange my elements in the ItemsControl to show the editor grid over the names.
To do what is described above I simply commented out the Popup and added the following binding to the Border which contains the editor grid Visibility="{Binding IsEditMode, Converter={StaticResource boolToVisibility}}".
All that does is this:
It just shows the popup under the name but not over the others.
Any help? What am I doing wrong?
Sounds like a job for the AdornerLayer to me.
My implementation will just display one 'popup' at a time, and you can hide it by clicking the button another time. But you could also add a small close button to the ContactAdorner, or stick with your OK button, or fill the AdornerLayer behind the ContactAdorner with an element that IsHitTestVisible and reacts on click by hiding the open Adorner (so clicking anywhere outside closes the popup).
Edit: Added the small close button at your request. Changes in ContactAdorner and the ContactDetailsTemplate.
Another thing that you might want to add is repositioning of the adorner once it is clipped from the bottom (I only check for clipping from the right).
Xaml:
<UserControl x:Class="WpfApplication1.ItemsControlAdorner"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
mc:Ignorable="d"
xmlns:local="clr-namespace:WpfApplication1"
d:DesignHeight="300" d:DesignWidth="300">
<UserControl.DataContext>
<local:ViewModel />
</UserControl.DataContext>
<UserControl.Resources>
<local:EnumToBooleanConverter x:Key="EnumToBooleanConverter" />
<!-- Template for the Adorner -->
<DataTemplate x:Key="ContactDetailsTemplate" DataType="{x:Type local:MyContact}" >
<Border Background="#BBFFFFFF" BorderBrush="DarkOrchid" BorderThickness="1" CornerRadius="5" TextElement.Foreground="DarkOrchid" >
<Grid Margin="5" Width="300">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Full name" />
<TextBox Grid.Row="1" Text="{Binding FullName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="2" Text="Address" />
<TextBox Grid.Row="3" Grid.ColumnSpan="2" Text="{Binding Address}" />
<TextBlock Grid.Column="1" Text="Gender" />
<StackPanel Orientation="Horizontal" Grid.Column="1" Grid.Row="1" >
<RadioButton Content="Male" IsChecked="{Binding Gender, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter={x:Static local:Gender.Male}}" />
<RadioButton Content="Female" IsChecked="{Binding Gender, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter={x:Static local:Gender.Female}}" />
</StackPanel>
<Button x:Name="PART_CloseButton" Grid.Column="2" Height="16">
<Button.Template>
<ControlTemplate>
<Border Background="#01FFFFFF" Padding="3" >
<Path Stretch="Uniform" ClipToBounds="True" Stroke="DarkOrchid" StrokeThickness="2.5" Data="M 85.364473,6.9977109 6.0640998,86.29808 6.5333398,85.76586 M 6.9926698,7.4977169 86.293043,86.79809 85.760823,86.32885" />
</Border>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Border>
</DataTemplate>
<!-- Button/Item style -->
<Style x:Key="ButtonStyle1" TargetType="{x:Type Button}" >
<Setter Property="Foreground" Value="White" />
<Setter Property="FontFamily" Value="Times New Roman" />
<Setter Property="Background" Value="#CC99E6" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="MinHeight" Value="24" />
<Setter Property="Margin" Value="3,2" />
<Setter Property="Padding" Value="3,2" />
<Setter Property="Border.CornerRadius" Value="8" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border CornerRadius="{TemplateBinding Border.CornerRadius}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}" Margin="{TemplateBinding Margin}" >
<ContentPresenter VerticalAlignment="Center" HorizontalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ItemsControl style -->
<Style x:Key="NamesStyle" TargetType="{x:Type ItemsControl}">
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemTemplate">
<Setter.Value>
<DataTemplate>
<Button x:Name="button" Style="{StaticResource ButtonStyle1}" Content="{Binding FullName}" >
<i:Interaction.Behaviors>
<local:ShowAdornerBehavior DataTemplate="{StaticResource ContactDetailsTemplate}" />
</i:Interaction.Behaviors>
</Button>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid>
<ItemsControl ItemsSource="{Binding MyContacts}" Style="{StaticResource NamesStyle}" />
</Grid>
</UserControl>
ShowAdornerBehavior, ContactAdorner, EnumToBooleanConverter:
using System.Windows;
using System.Linq;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Interactivity;
using System.Windows.Media;
using System.Windows.Data;
using System;
namespace WpfApplication1
{
public class ShowAdornerBehavior : Behavior<Button>
{
public DataTemplate DataTemplate { get; set; }
protected override void OnAttached()
{
this.AssociatedObject.Click += AssociatedObject_Click;
base.OnAttached();
}
void AssociatedObject_Click(object sender, RoutedEventArgs e)
{
var adornerLayer = AdornerLayer.GetAdornerLayer(this.AssociatedObject);
var contactAdorner = new ContactAdorner(this.AssociatedObject, adornerLayer, this.AssociatedObject.DataContext, this.DataTemplate);
}
}
public class ContactAdorner : Adorner
{
private ContentPresenter _contentPresenter;
private AdornerLayer _adornerLayer;
private static Button _btn;
private VisualCollection _visualChildren;
private double _marginRight = 5;
private double _adornerDistance = 5;
private PointCollection _points;
private static ContactAdorner _currentInstance;
public ContactAdorner(Button adornedElement, AdornerLayer adornerLayer, object data, DataTemplate dataTemplate)
: base(adornedElement)
{
if (_currentInstance != null)
_currentInstance.Hide(); // hides other adorners of the same type
if (_btn != null && _btn == adornedElement)
{
_currentInstance.Hide(); // hides the adorner of this button (toggle)
_btn = null;
}
else
{
_adornerLayer = adornerLayer;
_btn = adornedElement;
// adjust position if sizes change
_adornerLayer.SizeChanged += (s, e) => { UpdatePosition(); };
_btn.SizeChanged += (s, e) => { UpdatePosition(); };
_contentPresenter = new ContentPresenter() { Content = data, ContentTemplate = dataTemplate };
// apply template explicitly: http://stackoverflow.com/questions/5679648/why-would-this-contenttemplate-findname-throw-an-invalidoperationexception-on
_contentPresenter.ApplyTemplate();
// get close button from datatemplate
Button closeBtn = _contentPresenter.ContentTemplate.FindName("PART_CloseButton", _contentPresenter) as Button;
if (closeBtn != null)
closeBtn.Click += (s, e) => { this.Hide(); _btn = null; };
_visualChildren = new VisualCollection(this); // this is needed for user interaction with the adorner layer
_visualChildren.Add(_contentPresenter);
_adornerLayer.Add(this);
_currentInstance = this;
UpdatePosition(); // position adorner
}
}
/// <summary>
/// Positioning is a bit fiddly.
/// Also, this method is only dealing with the right clip, not yet with the bottom clip.
/// </summary>
private void UpdatePosition()
{
double marginLeft = 0;
_contentPresenter.Margin = new Thickness(marginLeft, 0, _marginRight, 0); // "reset" margin to get a good measure pass
_contentPresenter.Measure(_adornerLayer.RenderSize); // measure the contentpresenter to get a DesiredSize
var contentRect = new Rect(_contentPresenter.DesiredSize);
double right = _btn.TranslatePoint(new Point(contentRect.Width, 0), _adornerLayer).X; // this does not work with the contentpresenter, so use _adornedElement
if (right > _adornerLayer.ActualWidth) // if adorner is clipped by right window border, move it to the left
marginLeft = _adornerLayer.ActualWidth - right;
_contentPresenter.Margin = new Thickness(marginLeft, _btn.ActualHeight + _adornerDistance, _marginRight, 0); // position adorner
DrawArrow();
}
private void DrawArrow()
{
Point bottomMiddleButton = new Point(_btn.ActualWidth / 2, _btn.ActualHeight - _btn.Margin.Bottom);
Point topLeftAdorner = new Point(_btn.ActualWidth / 2 - 10, _contentPresenter.Margin.Top);
Point topRightAdorner = new Point(_btn.ActualWidth / 2 + 10, _contentPresenter.Margin.Top);
PointCollection points = new PointCollection();
points.Add(bottomMiddleButton);
points.Add(topLeftAdorner);
points.Add(topRightAdorner);
_points = points; // actual drawing executed in OnRender
}
protected override void OnRender(DrawingContext drawingContext)
{
// Drawing the arrow
StreamGeometry streamGeometry = new StreamGeometry();
using (StreamGeometryContext geometryContext = streamGeometry.Open())
{
if (_points != null && _points.Any())
{
geometryContext.BeginFigure(_points[0], true, true);
geometryContext.PolyLineTo(_points.Where(p => _points.IndexOf(p) > 0).ToList(), true, true);
}
}
// Draw the polygon visual
drawingContext.DrawGeometry(Brushes.DarkOrchid, new Pen(_btn.Background, 0.5), streamGeometry);
base.OnRender(drawingContext);
}
private void Hide()
{
_adornerLayer.Remove(this);
}
protected override Size MeasureOverride(Size constraint)
{
_contentPresenter.Measure(constraint);
return _contentPresenter.DesiredSize;
}
protected override Size ArrangeOverride(Size finalSize)
{
_contentPresenter.Arrange(new Rect(finalSize));
return finalSize;
}
protected override Visual GetVisualChild(int index)
{
return _visualChildren[index];
}
protected override int VisualChildrenCount
{
get { return _visualChildren.Count; }
}
}
// http://stackoverflow.com/questions/397556/how-to-bind-radiobuttons-to-an-enum
public class EnumToBooleanConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return value.Equals(parameter);
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return value.Equals(true) ? parameter : Binding.DoNothing;
}
}
}
ViewModel, MyContact:
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Input;
namespace WpfApplication1
{
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string propertyName)
{
if (this.PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
private ObservableCollection<MyContact> _myContacts = new ObservableCollection<MyContact>();
public ObservableCollection<MyContact> MyContacts { get { return _myContacts; } set { _myContacts = value; OnPropertyChanged("MyContacts"); } }
public ViewModel()
{
MyContacts = new ObservableCollection<MyContact>()
{
new MyContact() { FullName = "Sigmund Freud", Gender = Gender.Male },
new MyContact() { FullName = "Abraham Lincoln", Gender = Gender.Male },
new MyContact() { FullName = "Joan Of Arc", Gender = Gender.Female },
new MyContact() { FullName = "Bob the Khann", Gender = Gender.Male, Address = "Mongolia" },
new MyContact() { FullName = "Freddy Mercury", Gender = Gender.Male },
new MyContact() { FullName = "Giordano Bruno", Gender = Gender.Male },
new MyContact() { FullName = "Socrates", Gender = Gender.Male },
new MyContact() { FullName = "Marie Curie", Gender = Gender.Female }
};
}
}
public class MyContact : INotifyPropertyChanged
{
private string _fullName;
public string FullName { get { return _fullName; } set { _fullName = value; OnPropertyChanged("FullName"); } }
private string _address;
public string Address { get { return _address; } set { _address = value; OnPropertyChanged("Address"); } }
private Gender _gender;
public Gender Gender { get { return _gender; } set { _gender = value; OnPropertyChanged("Gender"); } }
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string propertyName)
{
if (this.PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
public enum Gender
{
Male,
Female
}
Personally I hate WPF's built in Popup control for exactly those reasons, and my workaround is to use a Custom Popup UserControl
Basically I'll put the Popup in a panel that allows it's children to overlap, such as a Grid or a Canvas, and position it on top of whatever content it's supposed to be on top of.
It includes DependencyProperties to specify it's parent panel and if it's open or not, and is part of the normal VisualTree so it will move around with your Window and act the same way any regular UI element would.
Typical usage would look like this:
<Grid x:Name="ParentPanel">
<ItemsControl ... />
<local:PopupPanel Content="{Binding PopupContent}"
local:PopupPanel.PopupParent="{Binding ElementName=ParentPanel}"
local:PopupPanel.IsPopupVisible="{Binding IsPopupVisible}" />
</Grid>
The code for the UserControl can be found on my blog along with a downloadable example of its use, but I'll also post a copy of it here.
The XAML for the UserControl is:
<UserControl x:Class="PopupPanelSample.PopupPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:PopupPanelSample"
FocusManager.IsFocusScope="True"
>
<UserControl.Template>
<ControlTemplate TargetType="{x:Type local:PopupPanel}">
<ControlTemplate.Resources>
<!-- Converter to get Popup Positioning -->
<local:ValueDividedByParameterConverter x:Key="ValueDividedByParameterConverter" />
<!-- Popup Visibility -->
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<Style x:Key="PopupPanelContentStyle" TargetType="{x:Type Grid}">
<Setter Property="Grid.Visibility" Value="{Binding Path=IsPopupVisible,
RelativeSource={RelativeSource AncestorType={x:Type local:PopupPanel}},
Converter={StaticResource BooleanToVisibilityConverter}}"/>
</Style>
</ControlTemplate.Resources>
<Grid x:Name="PopupPanelContent" Style="{StaticResource PopupPanelContentStyle}">
<Grid.Resources>
<!-- Storyboard to show Content -->
<Storyboard x:Key="ShowEditPanelStoryboard" SpeedRatio="5">
<DoubleAnimation
Storyboard.TargetName="PopupPanelContent"
Storyboard.TargetProperty="RenderTransform.(ScaleTransform.ScaleX)"
From="0.00" To="1.00" Duration="00:00:01"
/>
<DoubleAnimation
Storyboard.TargetName="PopupPanelContent"
Storyboard.TargetProperty="RenderTransform.(ScaleTransform.ScaleY)"
From="0.00" To="1.00" Duration="00:00:01"
/>
</Storyboard>
</Grid.Resources>
<!-- Setting up RenderTransform for Popup Animation -->
<Grid.RenderTransform>
<ScaleTransform
CenterX="{Binding Path=PopupParent.ActualWidth, Converter={StaticResource ValueDividedByParameterConverter}, ConverterParameter=2, RelativeSource={RelativeSource AncestorType={x:Type local:PopupPanel}}}"
CenterY="{Binding Path=PopupParent.ActualHeight, Converter={StaticResource ValueDividedByParameterConverter}, ConverterParameter=2, RelativeSource={RelativeSource AncestorType={x:Type local:PopupPanel}}}"
/>
</Grid.RenderTransform>
<!-- Grayscale background & prevents mouse input -->
<Rectangle
Fill="Gray"
Opacity="{Binding Path=BackgroundOpacity, RelativeSource={RelativeSource AncestorType={x:Type local:PopupPanel}}}"
Height="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:PopupPanel}}, Path=Height}"
Width="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:PopupPanel}}, Path=Width}"
/>
<!-- Popup Content -->
<ContentControl x:Name="PopupContentControl"
KeyboardNavigation.TabNavigation="Cycle"
PreviewKeyDown="PopupPanel_PreviewKeyDown"
PreviewLostKeyboardFocus="PopupPanel_LostFocus"
IsVisibleChanged="PopupPanel_IsVisibleChanged"
HorizontalAlignment="Center" VerticalAlignment="Center"
>
<ContentPresenter Content="{TemplateBinding Content}" />
</ContentControl>
</Grid>
</ControlTemplate>
</UserControl.Template>
</UserControl>
And the code-behind the UserControl looks like this:
using System;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
namespace PopupPanelSample
{
/// <summary>
/// Panel for handling Popups:
/// - Control with name PART_DefaultFocusControl will have default focus
/// - Can define PopupParent to determine if this popup should be hosted in a parent panel or not
/// - Can define the property EnterKeyCommand to specifify what command to run when the Enter key is pressed
/// - Can define the property EscapeKeyCommand to specify what command to run when the Escape key is pressed
/// - Can define BackgroundOpacity to specify how opaque the background will be. Value is between 0 and 1.
/// </summary>
public partial class PopupPanel : UserControl
{
#region Fields
bool _isLoading = false; // Flag to tell identify when DataContext changes
private UIElement _lastFocusControl; // Last control that had focus when popup visibility changes, but isn't closed
#endregion // Fields
#region Constructors
public PopupPanel()
{
InitializeComponent();
this.DataContextChanged += Popup_DataContextChanged;
// Register a PropertyChanged event on IsPopupVisible
DependencyPropertyDescriptor dpd = DependencyPropertyDescriptor.FromProperty(PopupPanel.IsPopupVisibleProperty, typeof(PopupPanel));
if (dpd != null) dpd.AddValueChanged(this, delegate { IsPopupVisible_Changed(); });
dpd = DependencyPropertyDescriptor.FromProperty(PopupPanel.ContentProperty, typeof(PopupPanel));
if (dpd != null) dpd.AddValueChanged(this, delegate { Content_Changed(); });
}
#endregion // Constructors
#region Events
#region Property Change Events
// When DataContext changes
private void Popup_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
DisableAnimationWhileLoading();
}
// When Content Property changes
private void Content_Changed()
{
DisableAnimationWhileLoading();
}
// Sets an IsLoading flag so storyboard doesn't run while loading
private void DisableAnimationWhileLoading()
{
_isLoading = true;
this.Dispatcher.BeginInvoke(DispatcherPriority.Render,
new Action(delegate() { _isLoading = false; }));
}
// Run storyboard when IsPopupVisible property changes to true
private void IsPopupVisible_Changed()
{
bool isShown = GetIsPopupVisible(this);
if (isShown && !_isLoading)
{
FrameworkElement panel = FindChild<FrameworkElement>(this, "PopupPanelContent");
if (panel != null)
{
// Run Storyboard
Storyboard animation = (Storyboard)panel.FindResource("ShowEditPanelStoryboard");
animation.Begin();
}
}
// When hiding popup, clear the LastFocusControl
if (!isShown)
{
_lastFocusControl = null;
}
}
#endregion // Change Events
#region Popup Events
// When visibility is changed, set the default focus
void PopupPanel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if ((bool)e.NewValue)
{
ContentControl popupControl = FindChild<ContentControl>(this, "PopupContentControl");
this.Dispatcher.BeginInvoke(DispatcherPriority.Render,
new Action(delegate()
{
// Verify object really is visible because sometimes it's not once we switch to Render
if (!GetIsPopupVisible(this))
{
return;
}
if (_lastFocusControl != null && _lastFocusControl.Focusable)
{
_lastFocusControl.Focus();
}
else
{
_lastFocusControl = FindChild<UIElement>(popupControl, "PART_DefaultFocusControl") as UIElement;
// If we can find the part named PART_DefaultFocusControl, set focus to it
if (_lastFocusControl != null && _lastFocusControl.Focusable)
{
_lastFocusControl.Focus();
}
else
{
_lastFocusControl = FindFirstFocusableChild(popupControl);
// If no DefaultFocusControl found, try and set focus to the first focusable element found in popup
if (_lastFocusControl != null)
{
_lastFocusControl.Focus();
}
else
{
// Just give the Popup UserControl focus so it can handle keyboard input
popupControl.Focus();
}
}
}
}
)
);
}
}
// When popup loses focus but isn't hidden, store the last element that had focus so we can put it back later
void PopupPanel_LostFocus(object sender, RoutedEventArgs e)
{
DependencyObject focusScope = FocusManager.GetFocusScope(this);
_lastFocusControl = FocusManager.GetFocusedElement(focusScope) as UIElement;
}
// Keyboard Events
private void PopupPanel_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
PopupPanel popup = FindAncester<PopupPanel>((DependencyObject)sender);
ICommand cmd = GetPopupEscapeKeyCommand(popup);
if (cmd != null && cmd.CanExecute(null))
{
cmd.Execute(null);
e.Handled = true;
}
else
{
// By default the Escape Key closes the popup when pressed
var expression = this.GetBindingExpression(PopupPanel.IsPopupVisibleProperty);
var dataType = expression.DataItem.GetType();
dataType.GetProperties().Single(x => x.Name == expression.ParentBinding.Path.Path)
.SetValue(expression.DataItem, false, null);
}
}
else if (e.Key == Key.Enter)
{
// Don't want to run Enter command if focus is in a TextBox with AcceptsReturn = True
if (!(e.KeyboardDevice.FocusedElement is TextBox &&
(e.KeyboardDevice.FocusedElement as TextBox).AcceptsReturn == true))
{
PopupPanel popup = FindAncester<PopupPanel>((DependencyObject)sender);
ICommand cmd = GetPopupEnterKeyCommand(popup);
if (cmd != null && cmd.CanExecute(null))
{
cmd.Execute(null);
e.Handled = true;
}
}
}
}
#endregion // Popup Events
#endregion // Events
#region Dependency Properties
// Parent for Popup
#region PopupParent
public static readonly DependencyProperty PopupParentProperty =
DependencyProperty.Register("PopupParent", typeof(FrameworkElement),
typeof(PopupPanel), new PropertyMetadata(null, null, CoercePopupParent));
private static object CoercePopupParent(DependencyObject obj, object value)
{
// If PopupParent is null, return the Window object
return (value ?? FindAncester<Window>(obj));
}
public FrameworkElement PopupParent
{
get { return (FrameworkElement)this.GetValue(PopupParentProperty); }
set { this.SetValue(PopupParentProperty, value); }
}
// Providing Get/Set methods makes them show up in the XAML designer
public static FrameworkElement GetPopupParent(DependencyObject obj)
{
return (FrameworkElement)obj.GetValue(PopupParentProperty);
}
public static void SetPopupParent(DependencyObject obj, FrameworkElement value)
{
obj.SetValue(PopupParentProperty, value);
}
#endregion
// Popup Visibility - If popup is shown or not
#region IsPopupVisibleProperty
public static readonly DependencyProperty IsPopupVisibleProperty =
DependencyProperty.Register("IsPopupVisible", typeof(bool),
typeof(PopupPanel), new PropertyMetadata(false, null));
public static bool GetIsPopupVisible(DependencyObject obj)
{
return (bool)obj.GetValue(IsPopupVisibleProperty);
}
public static void SetIsPopupVisible(DependencyObject obj, bool value)
{
obj.SetValue(IsPopupVisibleProperty, value);
}
#endregion // IsPopupVisibleProperty
// Transparency level for the background filler outside the popup
#region BackgroundOpacityProperty
public static readonly DependencyProperty BackgroundOpacityProperty =
DependencyProperty.Register("BackgroundOpacity", typeof(double),
typeof(PopupPanel), new PropertyMetadata(.5, null));
public static double GetBackgroundOpacity(DependencyObject obj)
{
return (double)obj.GetValue(BackgroundOpacityProperty);
}
public static void SetBackgroundOpacity(DependencyObject obj, double value)
{
obj.SetValue(BackgroundOpacityProperty, value);
}
#endregion ShowBackgroundProperty
// Command to execute when Enter key is pressed
#region PopupEnterKeyCommandProperty
public static readonly DependencyProperty PopupEnterKeyCommandProperty =
DependencyProperty.RegisterAttached("PopupEnterKeyCommand", typeof(ICommand),
typeof(PopupPanel), new PropertyMetadata(null, null));
public static ICommand GetPopupEnterKeyCommand(DependencyObject obj)
{
return (ICommand)obj.GetValue(PopupEnterKeyCommandProperty);
}
public static void SetPopupEnterKeyCommand(DependencyObject obj, ICommand value)
{
obj.SetValue(PopupEnterKeyCommandProperty, value);
}
#endregion PopupEnterKeyCommandProperty
// Command to execute when Enter key is pressed
#region PopupEscapeKeyCommandProperty
public static readonly DependencyProperty PopupEscapeKeyCommandProperty =
DependencyProperty.RegisterAttached("PopupEscapeKeyCommand", typeof(ICommand),
typeof(PopupPanel), new PropertyMetadata(null, null));
public static ICommand GetPopupEscapeKeyCommand(DependencyObject obj)
{
return (ICommand)obj.GetValue(PopupEscapeKeyCommandProperty);
}
public static void SetPopupEscapeKeyCommand(DependencyObject obj, ICommand value)
{
obj.SetValue(PopupEscapeKeyCommandProperty, value);
}
#endregion PopupEscapeKeyCommandProperty
#endregion Dependency Properties
#region Visual Tree Helpers
public static UIElement FindFirstFocusableChild(DependencyObject parent)
{
// Confirm parent is valid.
if (parent == null) return null;
UIElement foundChild = null;
int childrenCount = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < childrenCount; i++)
{
UIElement child = VisualTreeHelper.GetChild(parent, i) as UIElement;
// This is returning me things like ContentControls, so for now filtering to buttons/textboxes only
if (child != null && child.Focusable && child.IsVisible)
{
foundChild = child;
break;
}
// recursively drill down the tree
foundChild = FindFirstFocusableChild(child);
// If the child is found, break so we do not overwrite the found child.
if (foundChild != null) break;
}
return foundChild;
}
public static T FindAncester<T>(DependencyObject current)
where T : DependencyObject
{
// Need this call to avoid returning current object if it is the same type as parent we are looking for
current = VisualTreeHelper.GetParent(current);
while (current != null)
{
if (current is T)
{
return (T)current;
}
current = VisualTreeHelper.GetParent(current);
};
return null;
}
/// <summary>
/// Looks for a child control within a parent by name
/// </summary>
public static T FindChild<T>(DependencyObject parent, string childName)
where T : DependencyObject
{
// Confirm parent and childName are valid.
if (parent == null) return null;
T foundChild = null;
int childrenCount = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < childrenCount; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
// If the child is not of the request child type child
T childType = child as T;
if (childType == null)
{
// recursively drill down the tree
foundChild = FindChild<T>(child, childName);
// If the child is found, break so we do not overwrite the found child.
if (foundChild != null) break;
}
else if (!string.IsNullOrEmpty(childName))
{
var frameworkElement = child as FrameworkElement;
// If the child's name is set for search
if (frameworkElement != null && frameworkElement.Name == childName)
{
// if the child's name is of the request name
foundChild = (T)child;
break;
}
else
{
// recursively drill down the tree
foundChild = FindChild<T>(child, childName);
// If the child is found, break so we do not overwrite the found child.
if (foundChild != null) break;
}
}
else
{
// child element found.
foundChild = (T)child;
break;
}
}
return foundChild;
}
#endregion
}
// Converter for Popup positioning
public class ValueDividedByParameterConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
double n, d;
if (double.TryParse(value.ToString(), out n)
&& double.TryParse(parameter.ToString(), out d)
&& d != 0)
{
return n / d;
}
return 0;
} public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
I've adopted what appears to be the standard way of validating textboxes in WPF using the IDataErrorInfo interface and styles as shown below. However, how can I disable the Save button when the page becomes invalid? Is this done somehow through triggers?
Default Public ReadOnly Property Item(ByVal propertyName As String) As String Implements IDataErrorInfo.Item
Get
Dim valid As Boolean = True
If propertyName = "IncidentCategory" Then
valid = True
If Len(IncidentCategory) = 0 Then
valid = False
End If
If Not valid Then
Return "Incident category is required"
End If
End If
Return Nothing
End Get
End Property
<Style TargetType="{x:Type TextBox}">
<Setter Property="Margin" Value="3" />
<Setter Property="Height" Value="23" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Validation.ErrorTemplate">
<Setter.Value>
<ControlTemplate>
<DockPanel LastChildFill="True">
<Border BorderBrush="Red" BorderThickness="1">
<AdornedElementPlaceholder Name="MyAdorner" />
</Border>
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="true">
<Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" />
</Trigger>
</Style.Triggers>
</Style>
A couple of things:
First, I would recommend using the RoutedCommand ApplicationCommands.Save for implementing the handling of the save button.
If you haven't checked out the WPF Command model, you can get the scoop here.
<Button Content="Save" Command="Save">
Now, to implement the functionality, you can add a command binding to the Window/UserControl or to the Button itself:
<Button.CommandBindings>
<CommandBinding Command="Save"
Executed="Save_Executed" CanExecute="Save_CanExecute"/>
</Button.CommandBindings>
</Button>
Implement these in code behind:
private void Save_Executed(object sender, ExecutedRoutedEventArgs e)
{
}
private void Save_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
}
In Save_CanExecute, set e.CanExecute based on the validity of the binding on the text box.
If you want to implement using the MVVM (Model-View-ViewModel) design pattern, check out Josh Smith's post on CommandSinkBinding.
One final note: If you want the enable/disable to be updated as soon as the value in the TextBox is changed, set UpdateSourceTrigger="PropertyChanged" on the binding for the TextBox.
EDIT: If you want to validate/invalidate based on all of the bindings in the control, here are a few suggestions.
1) You are already implementing IDataErrorInfo. Try implementing the IDataErrorInfo.Error property such that it returns the string that is invalid for all of the properties that you are binding to. This will only work if your whole control is binding to a single data object. Set e.CanExecute = string.IsNullOrEmpty(data.Error);
2) Use reflection to get all of the public static DependencyProperties on the relevant controls. Then call BindingOperations.GetBindingExpression(relevantControl, DependencyProperty) in a loop on each property so you can test the validation.
3) In the constructor, manually create a collection of all bound properties on nested controls. In CanExecute, iterate through this collection and validate each DependencyObject/DepencyProperty combination by using BindingOperation.GetBindingExpression() to get expressions and then examining BindingExpression.HasError.
I've created attached property just for this:
public static class DataErrorInfoHelper
{
public static object GetDataErrorInfo(ButtonBase obj)
{
return (object)obj.GetValue(DataErrorInfoProperty);
}
public static void SetDataErrorInfo(ButtonBase obj, object value)
{
obj.SetValue(DataErrorInfoProperty, value);
}
// Using a DependencyProperty as the backing store for DataErrorInfo. This enables animation, styling, binding, etc...
public static readonly DependencyProperty DataErrorInfoProperty =
DependencyProperty.RegisterAttached("DataErrorInfo", typeof(object), typeof(DataErrorInfoHelper), new PropertyMetadata(null, OnDataErrorInfoChanged));
private static void OnDataErrorInfoChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var button = d as ButtonBase;
if (button.Tag == null)
button.Tag = new DataErrorInfoContext { Button = button };
var context = button.Tag as DataErrorInfoContext;
if(e.OldValue != null)
{
PropertyChangedEventManager.RemoveHandler(((INotifyPropertyChanged)e.OldValue), context.Handler, string.Empty);
}
var inotify = e.NewValue as INotifyPropertyChanged;
if (inotify != null)
{
PropertyChangedEventManager.AddHandler(inotify, context.Handler, string.Empty);
context.Handler(inotify, new PropertyChangedEventArgs(string.Empty));
}
}
private class DataErrorInfoContext
{
public ButtonBase Button { get; set; }
public void Handler(object sender, PropertyChangedEventArgs e)
{
var dei = sender as IDataErrorInfo;
foreach (var property in dei.GetType().GetProperties())
{
if (!string.IsNullOrEmpty(dei[property.Name]))
{
Button.IsEnabled = false;
return;
}
}
Button.IsEnabled = string.IsNullOrEmpty(dei.Error);
}
}
}
I'm using it like this on my forms:
<TextBlock Margin="2">e-mail:</TextBlock>
<TextBox Margin="2" Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"/>
<!-- other databindings--->
<Button Margin="2" local:DataErrorInfoHelper.DataErrorInfo="{Binding}" Commands="{Binding SaveCommand}">Create account</Button>