The ItemsSource of a DataGrid is bound to an ObservableCollection. Two of the DataGridColumns are bound to a DateTime property in the collection while a third column is used to display the date difference between them using an IMultiValue Converter. The behaviour I'm trying to understand and resolve is found in Scenario 2.
Scenario 1: No Issue
View is opened and the DataGrid already contains records because the collection is NOT empty. If a new object is added to the collection, it gets displayed in the DataGrid and the last column displays the date difference value correctly.
Scenario 2: Has Issue
View is opened but DataGrid contains no records because the collection is empty. If a new object is added to the collection, it gets displayed in the DataGrid but the last column (containing the converter) is empty. However, if the view is then closed and re-opened, the date difference displays correcty in the DataGrid.
I would like the date difference value to display in the DataGridcolumn when an object is first added to an empty collection. What am I missing?
Object Class
public class Evaluation
{
public int ID { get; set; }
public DateTime BirthDate { get; set; }
public DateTime TestDate { get; set; }
}
ViewModel
public class EvaluationViewModel : ViewModelBase
{
private ObservableCollection<Evaluation> evaluations;
public class EvaluationViewModel()
{
evaluations = Utility.Convert<Evaluation>(db.evaluationRepository.GetAllById(Subject.ID));
TestView = (CollectionView)new CollectionViewSource { Source = Evaluations }.View;
TestView.SortDescriptions.Add(new SortDescription("TestDate", ListSortDirection.Ascending));
}
public ObservableCollection<Evaluation> Evaluations
{
get { return evaluations; }
}
public CollectionView TestView { get; set; }
}
View
public class Evaluation
{
public int ID { get; set; }
public DateTime BirthDate { get; set; }
public DateTime TestDate { get; set; }
}
<Window.Resources>
<converters:DateDiffMonthMultiConverter x:Key="DateConverter"/>
</Window.Resources>
<DataGrid ItemsSource="{Binding TestView}">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Path=ID}" Visibility="Hidden"/>
<DataGridTextColumn Header="Birth Date" Binding="{Binding BirthDate}"/>
<DataGridTextColumn Header="Test Date" Binding="{Binding TestDate}"/>
<DataGridTemplateColumn Header="Age When Tested">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock>
<TextBlock.Text>
<MultiBinding Converter="{StaticResource DateConverter}">
<Binding Path="BirthDate"/>
<Binding Path="TestDate"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
Converter
public class DateDiffMonthMultiConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
string result = string.Empty;
if(values[0] is DateTime && values[1] is DateTime)
{
DateTime start = (DateTime)values[1];
DateTime end = (DateTime)values[0];
TimeSpan ts = start - end;
double avgDaysPerMonth = 30.4;
double months = (double)ts.Days / avgDaysPerMonth;
string suffix = months > 1 ? "mths" : "mth";
result = string.Format("{0} {1}", months.ToString("0.0"), suffix);
}
return result;
}
public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture)
{
return null;
}
}
I tried your code (adding an item to the ObservableCollection after 2 seconds), and it's working for me. Here is my code:
MainWindow.xaml.cs
public MainWindow()
{
InitializeComponent();
DataContext = new EvaluationViewModel();
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
Task.Factory.StartNew(() => Thread.Sleep(2000))
.ContinueWith((t) =>
{
(DataContext as EvaluationViewModel).Evaluations.Add(
new Evaluation() { ID = 2, BirthDate = DateTime.Now.AddYears(-22), TestDate = DateTime.Now });
}, TaskScheduler.FromCurrentSynchronizationContext());
}
ViewModel
public EvaluationViewModel()
{
Evaluations = new ObservableCollection<Evaluation>();
TestView = (CollectionView)new CollectionViewSource { Source = Evaluations }.View;
TestView.SortDescriptions.Add(new SortDescription("TestDate", ListSortDirection.Ascending));
}
public ObservableCollection<Evaluation> Evaluations { get; }
public CollectionView TestView { get; set; }
As it turns out the converter was not the issue, but instead one of the values used by the converter is a DependencyProperty (my bad for not recognizing this until now) and was throwing a DependencyProperty.UnsetValue error. I was able to resolve the issue by using the CreateNew() method when adding a new entity to the collection, so the navigation property was known at the time of loading the object into the DataGrid.
Related
I'm trying to wrap my head around how to properly bind a datagrid column with data in a subclass. To clear things up, I've done a little sample which, if resolved, would greatly help in making the code work.
Here are the classes defined:
public class SubItem
{
public string Data { get; set; }
}
public class Item
{
public int Value { get; set; }
public SubItem Data { get; set; }
}
I then create an observablecollection as follows:
public class IntData : ObservableCollection<Item>
{
public IntData() : base()
{
Item i = new Item() { Value = 56, Data = new SubItem() { Data = "testdata" } };
Add(i);
}
}
And here is my MainWindow code:
public partial class MainWindow : Window
{
public IntData Integers { get; set; }
public MainWindow()
{
Integers = new IntData();
InitializeComponent();
dataGrid1.ItemsSource = Integers; // This is an important line
}
}
The XAML code is kept simple:
<DataGrid Name="dataGrid1" AutoGenerateColumns="False" Margin="12">
<DataGrid.Columns>
<DataGridTextColumn Header="Integers" Binding="{Binding Value}"/>
<DataGridTextColumn Header="Data" Binding="{Binding Data}"/>
</DataGrid.Columns>
</DataGrid>
Running the above, you will notice that the Integers is working as it should but not the Data column.
Any ideas from anyone on how to make that column show the Data property?
Thanks in advance!
The easiest way to fix it is to override ToString() method in SubItem class and return Data property
public class SubItem
{
public string Data { get; set; }
public override string ToString()
{
return Data;
}
}
Another option is update DataGridTextColumn binding and use Data sub-property of Data property from Item class (you'll probably need to update a naming:))
<DataGridTextColumn Header="Data" Binding="{Binding Data.Data}"/>
More complicated option is to use DataGridTemplateColumn and define your own template to represent a data
<DataGridTemplateColumn Header="Data">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox Text="{Binding Data.Data}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
Given the following View Model example
public class MyViewModel
{
public ObservableCollection<MyObjType> BoundItems { get; }
}
and MyObjType
public class MyObjType
{
public string Name { get; set; }
public int Id { get; set; }
}
I have added a Validation rule to a DataGrid Column, where the DataGrid is bound to the BoundItems collection in my ViewModel, and the Text property in the Template Column is bound to the Name.
<DataGrid ItemsSource="{Binding BoundItems}">
<DataGrid.Columns>
<DataGridTemplateColumn>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TexBox>
<TextBox.Text>
<Binding Path="Name" ValidatesOnDataErrors="True">
<Binding.ValidationRules>
<xns:MyValidationRule>
<xns:MyValidationRule.SomeDependencyProp>
<xns:SomeDependencyProp SubProp={Binding Id} /> <!-- Not Working -->
</xns:MyValidationRule.SomeDependencyProp>
</xns:MyValidationRule>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
...
</DataGrid.Columns>
</DataGrid>
I want to pass another property Id of my collection type (MyObjType) to the validation rule, how do I access that from the rule. I know about the freezable and getting the context of the view model, but i need another property of my collection type that is bound to the Datagrid.
The ValidationRule and SomeDependencyProp is modeled after the example here: https://social.technet.microsoft.com/wiki/contents/articles/31422.wpf-passing-a-data-bound-value-to-a-validation-rule.aspx
public class SomeDependencyProp : DependencyObject
{
public static readonly SubPropProperty =
DependencyProperty.Register("SubProp", typeof(int),
typeof(SomeDependencyProp), new FrameworkPropertyMetadata(0));
public int SubProp{
get { return (int)GetValue(SubPropProperty ); }
set { SetValue(SubPropProperty, value); }
}
}
public class MyValidationRule: System.Windows.Controls.ValidationRule
{
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
...
}
public SomeDependencyProp SomeDependencyProp { get; set; }
}
The solution to this situation is to use a BindingProxy.
My target is to create a combobox which displays any list of strings when opened (so the standard behavior), but when the user selects one of the strings, it gets added at the top of the list under a "Recently used" separator.
Essentially, I want a control that behaves exactly like the one to select fonts in MS Word:
My start was to create a custom control with an additional Dependency property which holds the recently selected items. This list gets updated when the user selects an item from the list. I don't want to modify the original list of items, since I aim to get a reusable control where the user does not have to manage the most recent items themselves.
private static readonly DependencyPropertyKey LastSelectedItemsPropertyKey =
DependencyProperty.RegisterReadOnly(
"LastSelectedItems",
typeof (Dictionary<string, int>),
typeof (MemoryCombobox),
new FrameworkPropertyMetadata(default(ObservableCollection<string>), FrameworkPropertyMetadataOptions.None));
public static readonly DependencyProperty LastSelectedItemsProperty = LastSelectedItemsPropertyKey.DependencyProperty;
My question now is: how can I display all items (labels and both lists) in a single dropdown of the combobox, like this:
---------------------
Label: Recently Selected
---------------------
<All items from the 'LastSelectedItems' DependencyProperty>
---------------------
Label: All Items
---------------------
<All items from the 'ItemsSource' property of the combobox
---------------------
I don't want to use grouping for this, since the items would not be repeated in the "all items" list below the recently used ones, like the user would expect them to be.
Have you tried something along these lines. It uses grouping, but does it in a special way, so that mru-items are not removed from the total list/group:
XAML:
<ComboBox Name="MyCombo" SelectionChanged="MyCombo_SelectionChanged" VerticalAlignment="Top">
<ComboBox.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" Background="DarkGray" Foreground="White" FontWeight="Bold" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ComboBox.GroupStyle>
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" Margin="0,0,5,0" />
<TextBlock Text="{Binding Value}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
Code Behind:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
m_preventFeedback = true;
ItemsList = new ObservableCollection<VMItem>
{
new VMItem(new Item("John", 1234), 2),
new VMItem(new Item("Peter", 2345), 2),
new VMItem(new Item("Michael", 3456), 2),
};
ListCollectionView view = new ListCollectionView(ItemsList);
view.GroupDescriptions.Add(new PropertyGroupDescription("CategoryId", new ItemGroupValueConverter()));
MyCombo.ItemsSource = view;
m_preventFeedback = false;
}
private ObservableCollection<VMItem> ItemsList = new ObservableCollection<VMItem>();
bool m_preventFeedback = false;
private void MyCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (m_preventFeedback) return;
if (MyCombo.SelectedItem is VMItem item)
{
m_preventFeedback = true;
VMItem mru = ItemsList.FirstOrDefault(i => i.Name == item.Name && i.CategoryId == 1) ?? new VMItem(item.Item, 1);
ItemsList.Remove(mru);
ItemsList.Insert(0, mru);
MyCombo.SelectedItem = mru;
m_preventFeedback = false;
}
}
}
public class ItemGroupValueConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
switch ((int)value)
{
case 1: return "Last Used";
case 2: return "Available Items";
}
return "N/A";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value;
}
}
public class VMItem : INotifyPropertyChanged
{
private Item m_item;
public VMItem(Item item, int categoryId)
{
m_item = item;
m_categoryId = categoryId;
}
public string Name
{
get { return m_item.Name; }
set
{
m_item.Name = value;
OnPropertyChanged("Name");
}
}
public int Value
{
get { return m_item.Value; }
set
{
m_item.Value = value;
OnPropertyChanged("Value");
}
}
private int m_categoryId;
public int CategoryId
{
get { return m_categoryId; }
set
{
m_categoryId = value;
OnPropertyChanged("CategoryId");
}
}
public Item Item => m_item;
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string property)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
}
}
public class Item
{
public Item(string name, int value)
{
Name = name;
Value = value;
}
public string Name { get; set; }
public int Value { get; set; }
}
I've been searching for a while, but I'm thinking maybe I am looking in the wrong direction. I have a DataGrid where the ItemsSource is set to a list of Book objects. At the same time, the Book object contains a list of Chapter objects.
public class Book {
public string Title;
public List<Chapter> AvailableChapters;
}
public class Chapter {
public int ChapterNumber;
public int NumberOfPages;
}
I use them this way in a DataGrid:
<DataGrid SelectionMode="Extended"
HeadersVisibility="Column"
CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Title" Binding="{Binding Title}" />
<DataGridTextColumn Header="Available chapters" Binding="{Binding AvailableChapters}" />
<!-- This is what I tried
<DataGridTemplateColumn Header="Available chapters">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<ListBox ItemsSource="{Binding AvailableChapters}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>-->
</DataGrid.Columns>
</DataGrid>
My aim is to show the list of chapters inside the book, as a list of strings, where each string is the chapter number (ChapterNumber property). For this reason, I was trying to access in some way the ChapterNumber property in the ChapterList property. Tried to use even the ListBox or AvailableChapters.ChapterNumber, but it makes no sense).
Example:
Edit
Tried with a converter but, although the value is not null, it says its length is zero.
public class ChapterListToStringListConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
List<Chapter> chapters = value as List<Chapter>;
if (chapters == null)
{
chapters = new List<Chapter>();
}
Debug.Print(chapters.Count.ToString()); // "0"
Debug.Print(chapters[0].ChapterNumber); // Error: index out of range
string result = string.Join(" ", chapters.Select(chap => chap.Name));
return result;
}
}
}
In case you are adding Chapters after the view was initialized, AvailableChapters should be an ObservableCollection:
public class Book
{
public string Title { get; set; }
public ObservableCollection<Chapter> AvailableChapters { get; }
= new ObservableCollection<Chapter>();
}
In your converter just check if value is an IEnumerable:
public object Convert(
object value, Type targetType, object parameter, CultureInfo culture)
{
var chapters = value as IEnumerable<Chapter>;
return chapters == null
? "-"
: string.Join(" ", chapters.Select(chap => chap.ChapterNumber));
}
The source for data-binding needs to be a property, not a field as you currently have.
To get a single string with all of the available chapters, just add a calculated property to your Book class, which combines all of the chapter items from the list.
public class Book
{
public string Title {get; set;}
public List<Chapter> AvailableChapters; {get;}
public string AvailableChaptersDisplay
{ get {return string.Join( " ", AvailableChapters.Select( c => c.ChapterNumber )); } }
}
You could add a property that calculates that for you.
public class Book {
public string Title;
public List<Chapter> AvailableChapters;
public string AvailableChaptersString
{
get {return list_concatenated_in_desired_format; }
}
}
GitHub Link: https://github.com/babakin34/wpf_test/tree/master/WpfApp1
I have the following classes:
VIEWMODELS:
public class PersonVM : BindableBase
{
public int ID { get; set; }
private string _lastName;
public string LastName
{
get { return _lastName; }
set { SetProperty(ref _lastName, value); }
}
}
public class MainVM : BindableBase
{
public ObservableCollection<PersonVM> People { get; set; }
private PersonVM _selectedPerson;
public PersonVM SelectedPerson
{
get { return _selectedPerson; }
set { SetProperty(ref _selectedPerson, value); }
}
public MainVM()
{
People = new ObservableCollection<PersonVM>()
{
new PersonVM()
{
ID = 1,
LastName = "AA"
},
new PersonVM()
{
ID = 2,
LastName = "BB"
},
};
SelectedPerson = People.First();
}
}
VIEW:
<Grid>
<StackPanel>
<ComboBox ItemsSource="{Binding People}"
SelectedItem="{Binding SelectedPerson}"
DisplayMemberPath="LastName"
Margin="0,5,0,25"/>
<DataGrid ItemsSource="{Binding People}"/>
</StackPanel>
</Grid>
How can I achieve that the "MainVM.SelectedPerson" from ComboBox is notified when user selects the empty element, which is caused by the Datagrid's default last entry?
PS: I am using Prism, but the problem is not Prism related. You can replace BindableBase by INotifyPropertyChanged.
Edit:
The real issue here is a bit different than i thought at first. When selecting the insert row, you see in the output window that WPF is unable to cast a "NamedObject" into a "PersonVM". The Datagrid creates a NamedObject for the insert row to work with, but the binding simply does not work since it's of the wrong type.
The clean and easy solution is to create a converter to check if the object is of type PersonVM, otherwise return null (and not the NamedObject instance).
public class MyConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is PersonVM)
return value;
return null;
}
}
And in the xaml
<DataGrid x:Name="DataGrid"
ItemsSource="{Binding People}"
SelectedCellsChanged="DataGrid_OnSelectedCellsChanged"
SelectedItem="{Binding SelectedPerson,
Converter={StaticResource myConverter}}"
Old and dirty
Not the nicest way, but if you don't mind using the viewmodel (or abstracting it via an interface) in the codebehind you can use the "SelectionChanged" event to set the SelectedPerson in the ViewModel to null if any of the selected items are not of the type you need.
private void Selector_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
bool invalidStuffSelected = false;
//throw new System.NotImplementedException();
foreach (var obj in DataGrid.SelectedItems)
{
if (!(obj is PersonVM))
invalidStuffSelected = true;
}
MainVM vm = (MainVM) this.DataContext;
if (invalidStuffSelected)
vm.SelectedPerson = null;
}
In your example the selectionmode "single" would make more sense since the combobox can only show one selected value.