Nested ObservableCollection data binding in WPF

前端 未结 1 1857
渐次进展
渐次进展 2020-12-21 19:00

I am very new to WPF and trying to create a self learning application using WPF. I am struggling to understand concepts like data binding,data templates,ItemControls to a fu

相关标签:
1条回答
  • 2020-12-21 19:28

    You initial attempt gets you 80% of the way there. Hopefully, my answer will get you a little closer.

    To start with, INotifyPropertyChanged is an interface an object supports to notify the Xaml engine that data has been modified and the user interface needs to be updated to show the change. You only need to do this on standard clr properties.

    So if your data traffic is all one way, from the ui to the model, then there is no need for you to implement INotifyPropertyChanged.

    I have created an example that uses the code you supplied, I have modified it and created a view to display it. The ViewModel and the data classes are as follows public enum QuestionType { OppositeMeanings, LinkWords }

    public class Instruction
    {
        public string Name { get; set; }
        public ObservableCollection<Question> Questions { get; set; }
    }
    
    public class Question : INotifyPropertyChanged
    {
        private Choice selectedChoice;
        private string instruction;
    
        public Question()
        {
            Choices = new ObservableCollection<Choice>();
    
        }
        public string Name { set; get; }
        public bool IsInstruction { get { return !string.IsNullOrEmpty(Instruction); } }
        public string Instruction
        {
            get { return instruction; }
            set
            {
                if (value != instruction)
                {
                    instruction = value;
                    OnPropertyChanged();
                    OnPropertyChanged("IsInstruction");
                }
            }
        }
        public string Clue { set; get; }
        public ObservableCollection<Choice> Choices { set; get; }
        public QuestionType Qtype { set; get; }
    
        public Choice SelectedChoice
        {
            get { return selectedChoice; }
            set
            {
                if (value != selectedChoice)
                {
                    selectedChoice = value;
                    OnPropertyChanged();
                }
            }
        }
        public int Marks { set; get; }
    
        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            var handler = PropertyChanged;
            if (handler != null)
            {
                handler.Invoke(this, new PropertyChangedEventArgs(propertyName));
            }
        }
        public event PropertyChangedEventHandler PropertyChanged;
    }
    
    public class Choice
    {
        public string Name { get; set; }
        public bool IsCorrect { get; set; }
    }
    
    public class NestedItemsViewModel
    {
        public NestedItemsViewModel()
        {
            Questions = new ObservableCollection<Question>();
            for (var h = 0; h <= 1; h++)
            {
                Questions.Add(new Question() { Instruction = string.Format("Instruction {0}", h) });
                for (int i = 1; i < 5; i++)
                {
                    Question qn = new Question() { Name = "Qn" + ((4 * h) + i) };
                    for (int j = 0; j < 4; j++)
                    {
                        qn.Choices.Add(new Choice() { Name = "Choice" + j, IsCorrect = j == i - 1 });
                    }
                    Questions.Add(qn);
                }
            }
        }
    
        public ObservableCollection<Question> Questions { get; set; }
    
        internal void SelectChoice(int questionIndex, int choiceIndex)
        {
            var question = this.Questions[questionIndex];
            question.SelectedChoice = question.Choices[choiceIndex];
        }
    }
    

    Notice that Answer has been changed to a SelectedChoice. This may not be what you require but it made the example a little easier. i have also implemented the INotifyPropertyChanged pattern on the SelectedChoice so I can set the SelectedChoice from code (notably from a call to SelectChoice).

    The main windows code behind instantiates the ViewModel and handles a button event to set a choice from code behind (purely to show INotifyPropertyChanged working).

    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            ViewModel = new NestedItemsViewModel();
            InitializeComponent();
        }
    
        public NestedItemsViewModel ViewModel { get; set; }
    
        private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
        {
            ViewModel.SelectChoice(3, 3);
        }
    }
    

    The Xaml is

    <Window x:Class="StackOverflow._20984156.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
            xmlns:learn="clr-namespace:StackOverflow._20984156"
            DataContext="{Binding RelativeSource={RelativeSource Self}, Path=ViewModel}"
            Title="MainWindow" Height="350" Width="525">
        <Window.Resources>
    
            <learn:SelectedItemIsCorrectToBooleanConverter x:Key="SelectedCheckedToBoolean" />
    
            <Style x:Key="ChoiceRadioButtonStyle" TargetType="{x:Type RadioButton}" BasedOn="{StaticResource {x:Type RadioButton}}">
                <Style.Triggers>
                    <DataTrigger Value="True">
                        <DataTrigger.Binding>
                            <MultiBinding Converter="{StaticResource SelectedCheckedToBoolean}">
                                <Binding Path="IsCorrect" />
                                <Binding RelativeSource="{RelativeSource Self}" Path="IsChecked" />
                            </MultiBinding>
                        </DataTrigger.Binding>
                        <Setter Property="Background" Value="Green"></Setter>
                    </DataTrigger>
                    <DataTrigger Value="False">
                        <DataTrigger.Binding>
                            <MultiBinding Converter="{StaticResource SelectedCheckedToBoolean}">
                                <Binding Path="IsCorrect" />
                                <Binding RelativeSource="{RelativeSource Self}" Path="IsChecked" />
                            </MultiBinding>
                        </DataTrigger.Binding>
                        <Setter Property="Background" Value="Red"></Setter>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
    
            <DataTemplate x:Key="InstructionTemplate" DataType="{x:Type learn:Question}">
                <TextBlock Text="{Binding Path=Instruction}" />
            </DataTemplate>
    
            <DataTemplate x:Key="QuestionTemplate" DataType="{x:Type learn:Question}">
                <StackPanel Margin="10 0">
                    <TextBlock Text="{Binding Path=Name}" />
                    <ListBox ItemsSource="{Binding Path=Choices}" SelectedItem="{Binding Path=SelectedChoice}" HorizontalAlignment="Stretch">
                        <ListBox.ItemsPanel>
                            <ItemsPanelTemplate>
                                <StackPanel Orientation="Horizontal" />
                            </ItemsPanelTemplate>
                        </ListBox.ItemsPanel>
                        <ListBox.ItemTemplate>
                            <DataTemplate DataType="{x:Type learn:Choice}">
                                <RadioButton Content="{Binding Path=Name}" IsChecked="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListBoxItem}}, Path=IsSelected}" Margin="10 1" 
                                             Style="{StaticResource ChoiceRadioButtonStyle}" />
                            </DataTemplate>
                        </ListBox.ItemTemplate>
                    </ListBox>
                </StackPanel>
            </DataTemplate>
        </Window.Resources>
    
        <DockPanel>
            <StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom">
                <Button Content="Select Question 3 choice 3" Click="ButtonBase_OnClick" />
            </StackPanel>
            <ItemsControl ItemsSource="{Binding Path=Questions}">
                <ItemsControl.ItemTemplateSelector>
                    <learn:QuestionTemplateSelector QuestionTemplate="{StaticResource QuestionTemplate}" InstructionTemplate="{StaticResource InstructionTemplate}" />
                </ItemsControl.ItemTemplateSelector>
            </ItemsControl>
        </DockPanel>
    </Window>
    

    Note: My learn namespace is different from yours so if you use this code, you will need to modify it to your namespace.

    So, the primary ListBox display a list of Questions. Each item in the ListBox (each Question) is rendered using a DataTemplate. Similarly, in the DataTemplate, a ListBox is used to display the choices and a DataTemplate is used to render each choice as a radio button.

    Points of interest.

    • Each choice is bound to the IsSelected property of the ListBoxItem it belongs to. It may not appear in the xaml but there will be a ListBoxItem for each choice. The IsSelected property is kept in sync with the SelectedItem property of the ListBox (by the ListBox) and that is bound to the SelectedChoice in your question.
    • The choice ListBox has an ItemsPanel. This allows you to use the layout strategy of a different type of panel to layout the items of the ListBox. In this case, a horizontal StackPanel.
    • I have added a button to set the choice of question 3 to 3 in the viewmodel. This will show you INotifyPropertyChanged working. If you remove the OnPropertyChanged call from the setter of the SelectedChoice property, the view will not reflect the change.

    The example above does not handle the Instruction Type.

    To handle instructions, I would either

    1. Insert the instruction as a question and change the question DataTemplate so it does not display the choices for an instruction; or
    2. Create a collection of Instructions in the view model where the Instruction type has a collection of questions (the view model would no longer have a collection of questions).

    The Instruction class would be something like

    public class Instruction
    {
        public string Name { get; set; }
        public ObservableCollection<Question> Questions { get; set; }
    }
    

    Addition based on comment regarding timer expiration and multiple pages.

    The comments here are aimed at giving you enough information to know what to search for.

    INotifyPropertyChanged

    If in doubt, implement INotifyPropertyChanged. My comment above was to let you know why you use it. If you have data already displayed that will be manipulated from code, then you must implement INotifyPropertyChanged.

    The ObservableCollection object is awesome for handling the manipulation of lists from code. Not only does it implement INotifyPropertyChanged, but it also implements INotifyCollectionChanged, both of these interfaces ensure that if the collection changes, the xaml engine knows about it and displays the changes. Note that if you modify a property of an object in the collection, it will be up to you to notify the Xaml engine of the change by implementing INotifyPropertyChanged on the object. The ObservableCollection is awesome, not omnipercipient.

    Paging

    For your scenario, paging is simple. Store the complete list of questions somewhere (memory, database, file). When you go to page 1, query the store for those questions and populate the ObservableCollection with those questions. When you go to page 2, query the store for page 2 questions, CLEAR the ObservableCollection and re populate. If you instantiate the ObservableCollection once and then clear and repopulate it while paging, the ListBox refresh will be handled for you.

    Timers

    Timers are pretty resource intensive from a windows point of view and as such, should be used sparingly. There are a number of timers in .net you can use. I tend to play with System.Threading.Timer or System.Timers.Timer. Both of these invoke the timer callback on a thread other than the DispatcherThread, which allows you to do work without affecting the UI responsiveness. However, if during the work you need to modify the UI, you will need to Dispatcher.Invoke or Dispatcher.BeginInvoke to get back on the Dispatcher thread. BeginInvoke is asynchronous and therefore, should not hang the thread while it waits for the DispatcherThread to become idle.

    Addition based on comment regarding separation of data templates.

    I added an IsInstruction to the Question object (I did not implement a Instruction class). This shows an example of raising the PropertyChanged event from property A (Instruction) for Property B (IsInstruction).

    I moved the DataTemplate from the list box to the Window.Resources and gave it a key. I also created a second DataTemplate for the instruction items.

    I created a DataTemplateSelector to choose which DataTemplate to use. DataTemplateSelectors are good when you need to select a DataTemplate as the Data is being loaded. Consider it a OneTime selector. If you required the DataTemplate to change during the scope of the data it is rendering, then you should use a trigger. The code for the selector is

    public class QuestionTemplateSelector : DataTemplateSelector
    {
        public override DataTemplate SelectTemplate(object item, DependencyObject container)
        {
            DataTemplate template = null;
    
            var question = item as Question;
            if (question != null)
            {
                template = question.IsInstruction ? InstructionTemplate : QuestionTemplate;
                if (template == null)
                {
                    template = base.SelectTemplate(item, container);
                }
            }
            else
            {
                template = base.SelectTemplate(item, container);
            }
    
            return template;
        }
    
        public DataTemplate QuestionTemplate { get; set; }
    
        public DataTemplate InstructionTemplate { get; set; }
    }
    

    The selector is bound to the ItemTemplateSelector of the ItemsControl.

    Finally, I converted the ListBox into an ItemsControl. The ItemsControl has most of the functionality of a ListBox (the ListBox control is derived from an ItemsControl) but it lacks the Selected functionality. It will make your questions seem more like a page of questions than a list.

    NOTE: Although I only added the code of the DataTemplateSelector to the addition, I updated the code snipits throughout the rest of the answer to work with the new DataTemplateSelector.

    Addition based on comment regarding setting the background for right and wrong answers

    Setting the background dynamically based on values in the model requires a trigger, in this case, multiple triggers.

    I have updated the Choice object to include an IsCorrect and during the creation of the questions in the ViewModel, I have assigned IsCorrect on one of the Choices for each answer.

    I have also updated the MainWindow to include style triggers on the RadioButton. There are a few points to not about triggers 1. The style or the RadioButton sets the backgound when the mouse is over. A fix would require recreating the style of the RadioButton. 1. Since the trigger is based on 2 values, we can either create another property on the model to combine the 2 properties, or use MultiBinding and a MultValueConverter. I have used the MultiBinding and the MultiValueConverter is as follows.

    public class SelectedItemIsCorrectToBooleanConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            var boolValues = values.OfType<bool>().ToList();
    
            var isCorrectValue = boolValues[0];
            var isSelected = boolValues[1];
    
            if (isSelected)
            {
                return isCorrectValue;
            }
    
            return DependencyProperty.UnsetValue;
        }
    
        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
    

    I hope this helps

    0 讨论(0)
提交回复
热议问题