다음을 통해 공유


WPF ListBox data templating/styling

Introduction

Each control, TextBlock, TextBox, ListBox etc. have their own default template associated with it. Using styles, controls can be modify from their default template associated. WPF enables you to change the look and feel of the controls and this can be achieved by using templates. This article with both C# and Visual Basic projects will show how to utilize templating and styling to display a collection of items in a ListBox were each task displayed on a row with description of a property and the value for a property coupled with a ContentControl to display three out of four properties which may be edited then on tabbing out of a TextBox display the changed value in the current selected item in the ListBox.

Citation
With Windows Presentation Foundation (WPF), you can customize an existing control's visual structure and behavior with your own reusable template. Templates can be applied globally to your application, windows and pages, or directly to controls. Most scenarios that require you to create a new control can be covered by instead creating a new template for an existing control.

Application wide styling
There are two project provided, one Visual Basic, one C#. In both projects the main window has a StackPanel within a Grid and gets styling from App.xaml for C#, Application.xaml for Visual Basic. TextBox controls have application wide styling in the same file.

Project overview

Provide a display of task in a ListBox which may be edited in TextBox controls followed by updating the current row in the ListBox. As the focus is on data templating and styling data presented is mocked data while in reality the data may come from a comma delimited file or a database, no matter were the data comes from does not affect styling or binding of data.

Data templates dictate how bound data is mapped to one or more control.  A data template can be used in two places:

  • As value of ContentTemplate property for a ContentControl  (e.g. a Label)
  • As value of ItemTemplate property for an ItemsControl  (e.g. a ListBox)

Data classes representing a Task

A Task is comprised of a name, description, task type and priority.

For Task type an enum is used.

C#

public enum  TaskType 
{
    Home,
    Work 
}

VB.NET

Public Enum TaskType
    Home
    Work
End Enum

The following class represents a Task which implementings INotifyPropertyChanged so that when a property is changed in TextBox controls within the ContentControl the ListBox source reflects changes through an ObservableCollection<TaskItem>.

C#

using System.ComponentModel;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
 
namespace WpfAppExample1.Classes
{
    public class  TaskItem : INotifyPropertyChanged
    {
        private string  _description;
        private string  _taskName;
        private int  _priority;
 
        public string  TaskName
        {
            get => _taskName;
            set
            {
                if (value == _taskName) return;
                _taskName = value;
                OnPropertyChanged();
            }
        }
        public string  Description 
        {
            get => _description;
            set
            {
                if (value == _description) return;
                _description = value;
                OnPropertyChanged();
            }
        }
         
        public TaskType TaskType { get; set; }
        public int  Priority
        {
            get => _priority;
            set
            {
                if (value == _priority) return;
                _priority = value;
                OnPropertyChanged();
            }
        }
 
        public override  string ToString() => TaskName;
        public event  PropertyChangedEventHandler PropertyChanged;
 
        [NotifyPropertyChangedInvocator]
        protected virtual  void OnPropertyChanged([CallerMemberName]  string  propertyName = null)
        {
            PropertyChanged?.Invoke(this, new  PropertyChangedEventArgs(propertyName));
        }
    }
}

VB.NET

Imports System.ComponentModel
Imports System.Runtime.CompilerServices
Imports JetBrains.Annotations
 
Namespace Classes
    Public Class TaskItem
        Implements INotifyPropertyChanged
 
        Private _description As String
        Private _taskName As String
        Private _priority As Integer
 
        Public Property TaskName() As String
            Get
                Return _taskName
            End Get
            Set(ByVal value As String)
                If value = _taskName Then
                    Return
                End If
                _taskName = value
                OnPropertyChanged()
            End Set
        End Property
        Public Property Description() As String
            Get
                Return _description
            End Get
            Set(ByVal value As String)
                If value = _description Then
                    Return
                End If
                _description = value
                OnPropertyChanged()
            End Set
        End Property
 
        Public Property TaskType() As TaskType
        Public Property Priority() As Integer
            Get
                Return _priority
            End Get
            Set(ByVal value As Integer)
                If value = _priority Then
                    Return
                End If
                _priority = value
                OnPropertyChanged()
            End Set
        End Property
 
        Public Overrides Function ToString() As String
            Return TaskName
        End Function
        Public Event PropertyChanged As PropertyChangedEventHandler _
            Implements INotifyPropertyChanged.PropertyChanged
 
        <NotifyPropertyChangedInvocator>
        Protected Overridable Sub OnPropertyChanged(<CallerMemberName>
            Optional ByVal propertyName As String = Nothing)
 
            PropertyChangedEvent?.Invoke(Me, New PropertyChangedEventArgs(propertyName))
 
        End Sub
    End Class
End Namespace

Mocked data class using ObservableCollection<TaskItem> which is instantiated as a public variable in the window containing the ListBox.

C#

using System.ComponentModel;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
 
namespace WpfAppExample1.Classes
{
    public class  TaskItem : INotifyPropertyChanged
    {
        private string  _description;
        private string  _taskName;
        private int  _priority;
 
        public string  TaskName
        {
            get => _taskName;
            set
            {
                if (value == _taskName) return;
                _taskName = value;
                OnPropertyChanged();
            }
        }
        public string  Description 
        {
            get => _description;
            set
            {
                if (value == _description) return;
                _description = value;
                OnPropertyChanged();
            }
        }
         
        public TaskType TaskType { get; set; }
        public int  Priority
        {
            get => _priority;
            set
            {
                if (value == _priority) return;
                _priority = value;
                OnPropertyChanged();
            }
        }
 
        public override  string ToString() => TaskName;
        public event  PropertyChangedEventHandler PropertyChanged;
 
        [NotifyPropertyChangedInvocator]
        protected virtual  void OnPropertyChanged([CallerMemberName]  string  propertyName = null)
        {
            PropertyChanged?.Invoke(this, new  PropertyChangedEventArgs(propertyName));
        }
    }
}

VB.NET

Imports System.Collections.ObjectModel
 
Namespace Classes
    Public Class  Tasks
        Public Function  List() As  ObservableCollection(Of TaskItem)
            Return New  ObservableCollection(Of TaskItem)() From {
                New TaskItem() With {
                        .Priority = 2,
                        .TaskType = TaskType.Work,
                        .TaskName = "Unit test data operations",
                        .Description = "Delegate to junior developer"
                    },
                New TaskItem() With {
                        .Priority = 1,
                        .TaskType = TaskType.Work,
                        .TaskName = "Prototype dashboard",
                        .Description = "Put together dashboard prototype"
                    },
                New TaskItem() With {
                        .Priority = 1,
                        .TaskType = TaskType.Home,
                        .TaskName = "Cook dinner",
                        .Description = "Ah, get a pizza"
                    },
                New TaskItem() With {
                        .Priority = 3,
                        .TaskType = TaskType.Work,
                        .TaskName = "Single signon discussion",
                        .Description = "Discuss options"
                    }
                }
        End Function
    End Class
End Namespace

In the main window constructor mocked data is assigned to an ObservableCollection.

C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using WpfAppExample1.Classes;
 
namespace WpfAppExample1
{
    /// <summary>
    /// Interaction logic for ListWindow1.xaml
    /// </summary>
    public partial  class ListWindow1 : Window
    {
         
        public ObservableCollection<TaskItem> TaskItemsList {  get; set; }
 
        public ListWindow1()
        {
            InitializeComponent();
 
            var taskOperations = new  Tasks();
            TaskItemsList = taskOperations.List();
 
            DataContext = this;
        }
        /// <summary> 
        /// Ensure only int values are entered.
        /// A robust alternate is using Data Annotations  
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void  NumberValidation(object sender, TextCompositionEventArgs e)
        {
            var regex = new  Regex("[^0-9]+");
            e.Handled = regex.IsMatch(e.Text);
        }
    }
}

VB.NET

Imports System.Collections.ObjectModel
Imports System.Text.RegularExpressions
Imports WpfAppExample2.Classes
 
Class MainWindow
 
    Public Property  TaskItemsList() As  ObservableCollection(Of TaskItem)
 
    Public Sub  New()
        InitializeComponent()
 
        Dim taskOperations = New Tasks()
        TaskItemsList = taskOperations.List()
 
        DataContext = Me
    End Sub
    ''' <summary> 
    ''' Ensure only int values are entered.
    ''' A robust alternate is using Data Annotations  
    ''' </summary>
    ''' <param name="sender"></param>
    ''' <param name="e"></param>
    Private Sub  NumberValidation(sender As Object, e As  TextCompositionEventArgs)
        Dim regex = New Regex("[^0-9]+")
        e.Handled = regex.IsMatch(e.Text)
    End Sub
End Class

XAML Code

The following data template defines grid rows/columns to present data where the type for each row is of type TaskItem (C#) (VB).

<DataTemplate x:Key="ListBoxTaskTemplate" DataType="classes:TaskItem">
 
    <Border Name="border" BorderBrush="LightGray" BorderThickness="1" Padding="2" Margin="2">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>
 
            <TextBlock Grid.Row="0" Grid.Column="0" Text="Task Name:"/>
            <TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Path=TaskName}" />
 
            <TextBlock Grid.Row="1" Grid.Column="0" Text="Description:"/>
            <TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding Path=Description}"/>
 
            <TextBlock Grid.Row="2" Grid.Column="0" Text="Priority:"/>
            <TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding Path=Priority}"/>
 
        </Grid>
    </Border>
</DataTemplate>

In the ListBox, IsSynchronizedWithCurrentItem = true will keep the current SelectedItem synchronized with the current item in the Items property, in this case a TaskItem.

<ListBox x:Name="TaskListBox" HorizontalAlignment="Left" Height="262" Margin="29,14,0,0"
         VerticalAlignment="Top" Width="417" HorizontalContentAlignment="Stretch"
         IsSynchronizedWithCurrentItem="True"
         ItemTemplate="{StaticResource ListBoxTaskTemplate}"
         ItemsSource="{Binding TaskItemsList, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">

Then the following sets the data template for the ListBox to use.

ItemTemplate="{StaticResource ListBoxTaskTemplate}"

Followed by the actual binding to the ObservableCollection from code behind. Note Mode=TwoWay as this permits editing from TextBox controls in tangent with UpdateSourceTrigger=PropertyChanged which ties into INotifiyPropertyChanged Interface implemented in the class Taskitem.

ItemsSource="{Binding TaskItemsList, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"

The following style trigger will paint each item in the ListBox based off the TaskType. An alternative is to not have both task types in the ListBox, instead filter on the list based off the TaskType. 

<ListBox.ItemContainerStyle>
 
    <Style TargetType="{x:Type ListBoxItem}">
 
        <Style.Triggers>
 
            <DataTrigger Binding="{Binding TaskType}" Value="Home">
                <Setter Property="Background" Value="Yellow" />
            </DataTrigger>
            <DataTrigger Binding="{Binding TaskType}" Value="Work">
                <Setter Property="Background" Value="White" />
            </DataTrigger>
        </Style.Triggers>
    </Style>
 
</ListBox.ItemContainerStyle>

The following constructs a data template for TextBlock and TextBox controls bound to the currently selected TaskItem in the ListBox. For the property Priority WPF automatically handles non-numeric values, leave the TextBox and the border will turn red, this does not tell much to the uneducated user so in PreviewTextInput regular expressions are used to allow only numerics.

<DataTemplate x:Key="CurrentDetailsTemplate"  DataType="classes:TaskItem">
    <Border 
        Background="#DCE7F5" BorderBrush="Silver"
        CornerRadius="4,4,4,4" Width="Auto" Height="100" Margin="32,20,30,20"  BorderThickness=".85" Padding="8">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="28*"/>
                <RowDefinition Height="28*"/>
                <RowDefinition Height="28*"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="61*"/>
                <ColumnDefinition Width="130*"/>
            </Grid.ColumnDefinitions>
 
            <TextBlock HorizontalAlignment="Right" Grid.Row="0" Grid.Column="0" Text="Name:" Padding="0,0,10,0"/>
            <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding TaskName}" Margin="0,0,0,4"/>
 
            <TextBlock  HorizontalAlignment="Right" Grid.Row="1" Grid.Column="0" Text="Description:" Padding="0,0,10,0"/>
            <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Description}" Margin="0,0,0,3"/>
 
            <TextBlock HorizontalAlignment="Right" Grid.Row="2" Grid.Column="0" Text="Priority:" Padding="0,0,10,0"/>
            <TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Priority}" Margin="0,3,0,0" PreviewTextInput="NumberValidation"  />
        </Grid>
    </Border>
</DataTemplate>

This is following by the following to paint the controls above.

<ContentControl Content="{Binding TaskItemsList}" ContentTemplate="{StaticResource CurrentDetailsTemplate}"/>

Polishing up

Presenting a window should have an active control, in this case the ListBox should be focused and ready to use. The following markup provides this along with code behind.

Code behind

C#

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
 
namespace WpfAppExample1.Classes
{
    /// <summary>
    /// attached behavior for selecting first control in a window
    /// </summary>
    public static  class FocusBehavior
    {
        public static  readonly DependencyProperty GiveInitialFocusProperty =
            DependencyProperty.RegisterAttached("GiveInitialFocus",typeof(bool),typeof(FocusBehavior),
                new PropertyMetadata(false, OnFocusFirstPropertyChanged));
 
        public static  bool GetGiveInitialFocus(Control control) => (bool)control.GetValue(GiveInitialFocusProperty);
        public static  void SetGiveInitialFocus(Control control,  bool  value) => control.SetValue(GiveInitialFocusProperty, value);
 
        private static  void OnFocusFirstPropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
        {
            if (!(sender is Control control) || !(args.NewValue is bool))
            {
                return;
            }
 
            if ((bool)args.NewValue)
            {
                control.Loaded += OnControlLoaded;
            }
            else
            {
                control.Loaded -= OnControlLoaded;
            }
        }
 
        private static  void OnControlLoaded(object sender, RoutedEventArgs e) => 
            ((Control)sender).MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
    }
}

VB.NET

Namespace Classes
    ''' <summary>
    ''' attached behavior for selecting first control in a window
    ''' </summary>
    Public NotInheritable  Class FocusBehavior
 
        Private Sub  New()
        End Sub
        Public Shared  ReadOnly GiveInitialFocusProperty As DependencyProperty =
            DependencyProperty.RegisterAttached("GiveInitialFocus",
                GetType(Boolean), GetType(FocusBehavior),
                    New PropertyMetadata(False, AddressOf  OnFocusFirstPropertyChanged))
 
        Public Shared  Function GetGiveInitialFocus(ByVal control As Control) As Boolean
            Return DirectCast(control.GetValue(GiveInitialFocusProperty), Boolean)
        End Function
        Public Shared  Sub SetGiveInitialFocus(ByVal control As Control, ByVal value As Boolean)
            control.SetValue(GiveInitialFocusProperty, value)
        End Sub
 
        Private Shared  Sub OnFocusFirstPropertyChanged(ByVal sender As DependencyObject,
                                ByVal args As DependencyPropertyChangedEventArgs)
 
            Dim tempVar As Boolean  = TypeOf  sender Is  Control
            Dim control As Control = If(tempVar, CType(sender, Control), Nothing)
            If Not  (tempVar) OrElse  Not (TypeOf args.NewValue Is Boolean) Then
                Return
            End If
 
            If DirectCast(args.NewValue, Boolean) Then
                AddHandler control.Loaded, AddressOf OnControlLoaded
            Else
                RemoveHandler control.Loaded, AddressOf OnControlLoaded
            End If
        End Sub
 
        Private Shared  Sub OnControlLoaded(ByVal sender As Object, ByVal  e As  RoutedEventArgs)
            DirectCast(sender, Control).MoveFocus(New TraversalRequest(FocusNavigationDirection.Next))
        End Sub
    End Class
End Namespace

Summary

Code, both xaml and code behind has been presented to template/style a ListBox in sync with TextBox controls which can be used in project which require displaying specific information with the ability to edit. Not part of the article is reading data from a data source and save back to a data source, the foundation is here to add this logic as a developer sees fit from file streams to Entity Framework Core.

See also

WPF: Tips - Bind to Current item of Collection
WPF Data, Item and Control Templates - Minimum Code, Maximum Awesomeness
Different ways to dynamically select DataTemplate for WPF ListView
Moving from WinForms to WPF
WPF Control Templates

Source code

GitHub repository