Search code examples
c#wpfxamlcaliburn.micro

How to Create a User Control that Contains a List of Another User Control


I recently asked this question (How to Populate a User Control with a Reusable User Control) and received help to create a user control that takes the descriptions from a ViewModel and uses them as the Label (or TextBlock if you will) of textboxes on a form.

I am now looking for help in creating a similar form, but with a slight change: each "row" of the form (label, textbox, etc.) will also include three buttons, an Edit button, and then Ok and Cancel buttons, which are hidden. Clicking the Edit button will toggle a StackPanel with the Ok and Cancel buttons. Clicking the Cancel button will once again show the Edit button. The only button of the three that will perform any meaningful action is the Ok button. Each row's label should come from the DescriptionAttribute of each property in the ViewModel. Also, I am assuming that I can use the Property name to toggle the visibility.

The reason for the user control is because I have three of these forms to create, all with very similar code, and want to try to reuse code where I can.

Here is the form I am trying to create:

KitchenInfoViewModel

And here is one of the rows, after pressing Edit:

ControllerName Row

I've used code for a similar form, but can't seem to even get anything to display. How would I go about creating this?

Here is what I have so far:

DescriptionInfoControl.xaml and DescriptionInfoControl.xaml.cs

<ResourceDictionary 
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:desc="clr-namespace:StagingApp.Controls.Library.Custom"
    xmlns:converter="clr-namespace:StagingApp.Controls.Library.Converters"
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    xmlns:cal="http://www.caliburnproject.org">
    <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="pack://application:,,,/StagingApp.Styling;component/Styles/Staging.TextBlocks.xaml" />
        <ResourceDictionary Source="pack://application:,,,/StagingApp.Styling;component/Styles/Staging.TextBoxes.xaml" />
        <ResourceDictionary Source="pack://application:,,,/StagingApp.Styling;component/Styles/Staging.Buttons.xaml" />
    </ResourceDictionary.MergedDictionaries>

    <converter:BoolToVisConverter x:Key="BoolToVisConverter" />
    <Style TargetType="{x:Type desc:DescriptionInfoControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type desc:DescriptionInfoControl}">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="Auto" SharedSizeGroup="DescriptionsColumn" />
                            <ColumnDefinition />
                            <ColumnDefinition Width="*"/>
                        </Grid.ColumnDefinitions>
                        <TextBlock
                            Grid.Column="0"
                            Text="{Binding DescriptionSource.Description, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}"
                            Style="{StaticResource DeviceInfoPropertyTextStyle}" />
                        <TextBox
                            x:Name="PART_TextBox"
                            Grid.Column="1"
                            HorizontalContentAlignment="Center"
                            Style="{StaticResource DeviceInfoTextBoxStyle}" />
                        <Grid
                            Grid.Column="2">
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="Auto" />
                            </Grid.ColumnDefinitions>
                            <StackPanel
                                Style="{StaticResource EditButtonStackPanelStyle}"
                                Grid.Column="0">
                                <Button
                                    Visibility="{Binding DescriptionSource.BindingName, Converter={StaticResource BoolToVisConverter}, FallbackValue=Visible}"
                                    Style="{StaticResource DeviceInfoEditButtonStyle}">
                                    <i:Interaction.Triggers>
                                        <i:EventTrigger EventName="Click">
                                            <cal:ActionMessage MethodName="Edit">
                                                <cal:Parameter Value="{Binding DescriptionSource.Property.Name}" />
                                            </cal:ActionMessage>
                                        </i:EventTrigger>
                                    </i:Interaction.Triggers>
                                    Edit
                                </Button>
                            </StackPanel>
                            <StackPanel
                                Style="{StaticResource EditButtonStackPanelStyle}"
                                Grid.Column="0"
                                Visibility="{Binding DescriptionSource.BindingName, Converter={StaticResource BoolToVisConverter}, FallbackValue=Visible}">
                                <Button
                                    Style="{StaticResource DeviceInfoEditOkButtonStyle}">
                                    <i:Interaction.Triggers>
                                        <i:EventTrigger EventName="Click">
                                            <cal:ActionMessage MethodName="OkEdit">
                                                <cal:Parameter Value="{Binding DescriptionSource.Property.Name}" />
                                            </cal:ActionMessage>
                                        </i:EventTrigger>
                                    </i:Interaction.Triggers>
                                    OK
                                </Button>
                                <Button
                                    Style="{StaticResource DeviceInfoEditCancelButtonStyle}">
                                    <i:Interaction.Triggers>
                                        <i:EventTrigger EventName="Click">
                                            <cal:ActionMessage MethodName="CancelEdit">
                                                <cal:Parameter Value="{Binding DescriptionSource.Property.Name}" />
                                            </cal:ActionMessage>
                                        </i:EventTrigger>
                                    </i:Interaction.Triggers>
                                    CANCEL
                                </Button>
                            </StackPanel>
                        </Grid>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

namespace StagingApp.Controls.Library.Custom;

[TemplatePart(Name = _textBoxTemplateName, Type = typeof(TextBox))]
public class DescriptionInfoControl : Control
{
    private const string _textBoxTemplateName = "PART_TextBox";
    private TextBox? _partTextBox;
    private Binding? _textBinding;

    public override void OnApplyTemplate()
    {
        _partTextBox = GetTemplateChild(_textBoxTemplateName) as TextBox;
        SetBindingPartTextbox();
    }

    private void SetBindingPartTextbox()
    {
        if (_partTextBox is TextBox tbox)
        {
            if (_textBinding is null)
            {
                BindingOperations.ClearBinding(tbox, TextBox.TextProperty);
            }
            else
            {
                tbox.SetBinding(TextBox.TextProperty, _textBinding);
            }
        }
    }

    static DescriptionInfoControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(DescriptionInfoControl),
            new FrameworkPropertyMetadata(typeof(DescriptionInfoControl)));
    }

    public DescriptionDto DescriptionSource
    {
        get => (DescriptionDto)GetValue(DescriptionSourceProperty);
        set => SetValue(DescriptionSourceProperty, value);
    }

    // Using a DependencyProperty as the backing store for Description.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty DescriptionSourceProperty =
        DependencyProperty.Register(
        nameof(DescriptionSource),
        typeof(DescriptionDto),
        typeof(DescriptionInfoControl),
        new PropertyMetadata(null, DescriptionSourceChangedCallback));

    private static readonly PropertyPath _newValuePropertyPath =
        new(typeof(DescriptionDto).GetProperty(nameof(DescriptionDto.NewValue)));

    private static void DescriptionSourceChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        DescriptionInfoControl control = (DescriptionInfoControl)d;
        Binding? binding = null;
        if (e.NewValue is DescriptionDto description)
        {
            binding = new Binding
            {
                Path = _newValuePropertyPath,
                Source = description.Source,
                Mode = BindingMode.TwoWay
            };
        }
        control._textBinding = binding;
        control.SetBindingPartTextbox();
    }
}

DescriptionsInfoListControl.xaml and DescriptionsInfoListControl.xaml.cs

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:desc="clr-namespace:StagingApp.Controls.Library.Custom"
                    xmlns:models="clr-namespace:StagingApp.Controls.Library.Models">

    <Style TargetType="{x:Type desc:DescriptionsInfoListControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type desc:DescriptionsInfoListControl}">
                    <ItemsControl
                        ItemsSource="{TemplateBinding Descriptions}"
                        HorizontalContentAlignment="Stretch"
                        Grid.IsSharedSizeScope="True">
                        <ItemsControl.ItemTemplate>
                            <DataTemplate
                                DataType="{x:Type models:DescriptionDto}">
                                <desc:DescriptionInfoControl
                                   DescriptionSource="{Binding}" />
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
    
</ResourceDictionary>

namespace StagingApp.Controls.Library.Custom;
public class DescriptionsInfoListControl : Control
{
    static DescriptionsInfoListControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(DescriptionsInfoListControl),
            new FrameworkPropertyMetadata(typeof(DescriptionsInfoListControl)));
    }

    public ReadOnlyCollection<DescriptionDto> Descriptions
    {
        get => (ReadOnlyCollection<DescriptionDto>)GetValue(DescriptionsProperty);
        set { SetValue(_descriptionsPropertyKey, value); }
    }

    private static readonly ReadOnlyCollection<DescriptionDto> _descriptionsEmpty = Array.AsReadOnly(Array.Empty<DescriptionDto>());

    private static readonly DependencyPropertyKey _descriptionsPropertyKey =
        DependencyProperty.RegisterReadOnly(
            nameof(Descriptions),
            typeof(ReadOnlyCollection<DescriptionDto>),
            typeof(DescriptionsInfoListControl),
            new PropertyMetadata(_descriptionsEmpty));

    // Using a DependencyProperty as the backing store for MyProperty.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty DescriptionsProperty = _descriptionsPropertyKey.DependencyProperty;

    public DescriptionsInfoListControl()
    {
        DataContextChanged += OnDataContextChanged;
    }

    private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        ReadOnlyCollection<DescriptionDto> descriptions;
        if (e.NewValue is null)
        {
            descriptions = _descriptionsEmpty;
        }
        else
        {
            descriptions = DescriptionPropertyList.GetDescriptions(e.NewValue);
        }

        Descriptions = descriptions;
    }
}

For the full code, please look at https://github.com/hmsiegel/StagingApp

Any help in getting these InfoControls and InfoViews to work and display would be great.

Thanks


Solution

  • I added methods to remember the entered value and reset the entered value to DescriptionDto.

    namespace StagingApp.Controls.Library.Models;
    public class DescriptionDto : INotifyPropertyChanged
    {
    
        public DescriptionDto(string? description,
                                  PropertyInfo property,
                                  object? source)
        {
            if (property.GetGetMethod() is not MethodInfo method)
            {
                throw new ArgumentException("Property must have a public getter.", nameof(property));
            }
            if (!method.IsStatic)
            {
                if (source is null)
                    throw new ArgumentException("For Source=null, Property must be static.", nameof(property));
    
                if (property != source.GetType().GetProperty(property.Name))
                    throw new ArgumentException("This property is not from this Source.", nameof(property));
            }
    
            Description = description ?? string.Empty;
            Property = property;
            Source = source;
            RefreshNewValue();
        }
    
        public string? Description { get; }
        public PropertyInfo Property { get; }
        public object? Source { get; }
    
        private string? _newValue;
        public string? NewValue
        {
            get => _newValue;
            set
            {
                _newValue = value;
                PropertyChanged?.Invoke(this, NewValueEventArgs);
            }
        }
        private static readonly PropertyChangedEventArgs NewValueEventArgs = new PropertyChangedEventArgs(nameof(NewValue));
    
        public event PropertyChangedEventHandler? PropertyChanged;
    
        public DescriptionDto SetSource(object? newSource) =>
            new(Description!, Property, newSource);
    
        public override string ToString() =>
            $"{(string.IsNullOrWhiteSpace(Description) ? string.Empty : $"[{Description}] ")}({Source?.GetType().Name}).{Property.Name}: {NewValue}";
    
    
        public void RefreshNewValue()
        {
            NewValue = Property.GetValue(Source)?.ToString();
        }
    
        public void UpdateProperty()
        {
            Property.SetValue(Source, NewValue);
        }
    
    }
    

    I added commands for the row buttons to the DescriptionControl.

    using System.Windows.Input;
    
    namespace StagingApp.Controls.Library.Custom;
    
    public partial class DescriptionControl : Control
    {
        /// <summary>Sets the DescriptionControl to edit mode: <see cref="DescriptionControl.IsReadOnly"/> = <see langword="false"/>.</summary>
        public static RoutedUICommand Edit { get; } = new RoutedUICommand("Go to edit mode.", nameof(Edit), typeof(DescriptionControl));
    
        /// <summary>Updates the value of the source property <see cref="DescriptionDto.Property"/> with the value received
        /// from the input field <see cref="DescriptionDto.NewValue"/>.</summary>
        public static RoutedUICommand OK { get; } = new RoutedUICommand("Accept changes.", nameof(OK), typeof(DescriptionControl));
    
        /// <summary>Returns the value of the input field <see cref="DescriptionDto.NewValue"/>
        /// to the value of the source property <see cref="DescriptionDto.Property"/>
        /// and cancels the edit mode: <see cref="DescriptionControl.IsReadOnly"/> = <see langword="true"/>.</summary>
        public static RoutedUICommand Cancel { get; } = new RoutedUICommand("Undo changes and edit mode.", nameof(Cancel), typeof(DescriptionControl));
    }
    
    using System.Windows.Input;
    
    namespace StagingApp.Controls.Library.Custom;
    
    public partial class DescriptionControl : Control
    {
        public DescriptionControl()
        {
            ProtectedIsReadOnly = IsReadOnly;
    
            // Initializing a Routed Commands Binding.
            CommandBinding editCommand = new CommandBinding() { Command = Edit };
            editCommand.CanExecute += OnCanExecute;
            editCommand.Executed += OnExecuted;
    
            CommandBinding cancelCommand = new CommandBinding() { Command = Cancel };
            cancelCommand.CanExecute += OnCanExecute;
            cancelCommand.Executed += OnExecuted;
    
            CommandBinding OkCommand = new CommandBinding() { Command = OK };
            OkCommand.CanExecute += OnCanExecute;
            OkCommand.Executed += OnExecuted;
    
            // Save a Routed Commands Binding.
            CommandBindings.Add(editCommand);
            CommandBindings.Add(cancelCommand);
            CommandBindings.Add(OkCommand);
        }
    
        private void OnCanExecute(object sender, CanExecuteRoutedEventArgs e)
        {
            if (e.Command == Edit)
            {
                e.CanExecute = ProtectedIsReadOnly;
            }
            else if (e.Command == Cancel || e.Command == OK)
            {
                e.CanExecute = !ProtectedIsReadOnly;
            }
        }
    
        private void OnExecuted(object sender, ExecutedRoutedEventArgs e)
        {
            if (e.Command == Edit)
            {
                IsReadOnly = false;
            }
            else if (e.Command == Cancel || e.Command == OK)
            {
                if (e.Command == Cancel)
                {
                    IsReadOnly = true;
                    ProtectedDescriptionSource?.RefreshNewValue();
    
                    if (_partTextBox is not null)
                    {
                        BindingOperations.GetBindingExpressionBase(_partTextBox, TextBox.TextProperty)
                            ?.UpdateTarget();
                    }
                }
                else
                {
                    ProtectedDescriptionSource?.UpdateProperty();
                }
            }
        }
    }
    

    I added buttons for the row to the DescriptionControl Template. I did not style them - do it yourself as you like.

    <ResourceDictionary 
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:desc="clr-namespace:StagingApp.Controls.Library.Custom">
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="pack://application:,,,/StagingApp.Styling;component/Styles/Staging.TextBlocks.xaml" />
            <ResourceDictionary Source="pack://application:,,,/StagingApp.Styling;component/Styles/Staging.TextBoxes.xaml" />
        </ResourceDictionary.MergedDictionaries>
        <BooleanToVisibilityConverter x:Key="booleanToVisibility"/>
        <Style TargetType="{x:Type desc:DescriptionControl}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type desc:DescriptionControl}">
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="Auto" SharedSizeGroup="DescriptionsColumn"/>
                                <ColumnDefinition/>
                                <ColumnDefinition Width="Auto"/>
                                <ColumnDefinition Width="Auto"/>
                            </Grid.ColumnDefinitions>
                            <TextBlock Text="{Binding DescriptionSource.Description, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}"
                                       Style="{StaticResource ConfigureTextBlockStyle}"/>
                            <TextBox x:Name="PART_TextBox" Grid.Column="1"
                                     Style="{StaticResource ConfigureTextBox}"
                                     IsReadOnly="{TemplateBinding IsReadOnly}"/>
                            <Button Grid.Column="2" Padding="15 5" 
                                    Command="{x:Static desc:DescriptionControl.Edit}"
                                    Content="{Binding Command.Name, RelativeSource={RelativeSource Self}}"
                                    ToolTip="{Binding Command.Text, RelativeSource={RelativeSource Self}}"
                                    Visibility="{Binding IsEnabled, RelativeSource={RelativeSource Self}, Converter={StaticResource booleanToVisibility}}"/>
                            <Button Grid.Column="2" Padding="15 5" 
                                    Command="{x:Static desc:DescriptionControl.OK}"
                                    Content="{Binding Command.Name, RelativeSource={RelativeSource Self}}"
                                    ToolTip="{Binding Command.Text, RelativeSource={RelativeSource Self}}"
                                    Visibility="{Binding IsEnabled, RelativeSource={RelativeSource Self}, Converter={StaticResource booleanToVisibility}}"/>
                            <Button Grid.Column="3" Padding="15 5" 
                                    Command="{x:Static desc:DescriptionControl.Cancel}"
                                    Content="{Binding Command.Name, RelativeSource={RelativeSource Self}}"
                                    ToolTip="{Binding Command.Text, RelativeSource={RelativeSource Self}}"
                                    Visibility="{Binding IsEnabled, RelativeSource={RelativeSource Self}, Converter={StaticResource booleanToVisibility}}"/>
    
                        </Grid>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ResourceDictionary>
    

    Fixed a few more bugs. I will not describe them here. Pay attention to getting the filename correctly (Bootstrapper.cs):

        public const string SettingsFileName = "appsettings.json";
        public static readonly string SettingsFileFullName = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty, SettingsFileName);
        private static IConfiguration AddConfiguration()
        {
            IConfigurationBuilder builder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile(SettingsFileFullName, false, false);
    
            return builder.Build();
        }
    

    I committed all the changes to the eldhasp branch.