All posts in XAML

Create an MVVM-based MAUI Application

Categories: .Net, C#, MAUI, MVVM, WPF, XAML
Comments: No

What is MAUI?

Microsoft released MAUI only a few weeks ago and it serves as the successor to UWP and Xamarin in the platform-agnostic space. Consider it Microsoft’s competitor with the Javascript-based UI frameworks we have all become familiar with over the last 10 years. You can read more about MAUI here:

https://docs.microsoft.com/en-us/dotnet/maui/what-is-maui

Paths to building the MAUI front-end

Microsoft offers 2 different paths to building your MAUI front-end: XAML or Blazor. In this article we will be looking at the default XAML application and we will modify it to utilize MVVM like we have been doing for years with other XAML implementation. The end result will offer better abstraction and simpler UI implementation. If you don’t know what MVVM is, read about it here:

https://docs.microsoft.com/en-us/xamarin/xamarin-forms/enterprise-application-patterns/mvvm

Let’s get to it…

Prerequisites

  • Visual Studio 17.3+ (2022+)

Step 1 – Create a new MAUI project

  1. In Visual Studio 2022 for to File -> New -> Project.
  2. Search for .NET MAUI App, select, and click Next.
  3. Make the Project Name MauiMvvmDemo, choose your repository location, check Place solution and project in the same directory, and click Next.
  4. For Framework choose .NET 6.0 (Long-term support) and click Create.

Step 2 – Install Libraries

  1. Open the Nuget Browser, by navigating to Tools -> NuGet Package Manager -> Manage Nuget Packages for Solution…

    Step 2 - Open Nuger Browser

  2. Select Browse and search for Xcalibur.Extensions.MVVM.V2.
  3. Select your project in the right pane (MauiMvvmDemo) and Choose Install. Accept the licensing prompts (if they appear).

    Step 2 - Install Library

  4. Close the Nuget Solution tab.

Step 3 – Create the ViewModel

  1. Create a new class in the project root and call it MainPageViewModel.cs.
  2. Paste in the following code:
using System.Windows.Input;
using Xcalibur.Extensions.MVVM.V2.Models;

namespace MauiMvvmDemo
{
    /// <summary>
    /// View Model - Main
    /// </summary>
    /// <seealso cref="ModelBase" />
    internal class MainViewModel : ModelBase
    {
        #region Members

        private int _count;
        private string _buttonText;

        #endregion

        #region Properties

        /// <summary>
        /// Gets or sets the button text.
        /// </summary>
        /// <value>
        /// The button text.
        /// </value>
        public string ButtonText
        {
            get => _buttonText;
            set => NotifyOfChange(value, ref _buttonText);
        }

        /// <summary>
        /// Gets or sets the button command.
        /// </summary>
        /// <value>
        /// The button command.
        /// </value>
        public ICommand ButtonCommand => new Command(UpdateCount);

        #endregion

        #region Constructors

        /// <summary>
        /// Initializes a new instance of the <see cref="MainViewModel"/> class.
        /// </summary>
        public MainViewModel()
        {
            ButtonText = "Click me!";
        }

        #endregion

        #region Methods

        /// <summary>
        /// Updates the count command.
        /// </summary>
        /// <returns></returns>
        private void UpdateCount()
        {
            _count++;

            // Update text
            var suffix = _count == 1 ? "time" : "times";
            var text = $"Clicked this {_count} {suffix}!";
            ButtonText = text;

            // Announce to screen reader
            SemanticScreenReader.Announce(text);
        }

        #endregion
    }
}

Deep Dive – What are the key pieces of this ViewModel?

Since we are working with a ViewModel, it is important to notice we are only working with properties and methods rather than control-based events.

ModelBase

https://xcalibursystems.com/wp-content/Documentation/Xcalibur.Extensions.MVVM/html/bb64d759-d714-fe71-b1c3-7ec574cdd8c2.htm

This class does a lot of the INPC work for us and makes implementation a lot simpler.

Here is the key:

/// <summary>
/// Gets or sets the button text.
/// </summary>
/// <value>
/// The button text.
/// </value>
public string ButtonText
{
	get => _buttonText;
	set => NotifyOfChange(value, ref _buttonText);
}

All of the INPC goodness can be simplified to a property using NotifyOfChange with a backing field.
https://xcalibursystems.com/creating-a-viewmodel-base-part-i-cleaning-up-your-custom-objects/

ICommand

Back in the WPF days we used to have to create a separate class to inherit from ICommand in order to make clean commands for WPF to trigger. Since those days this has been dramatically simplified as such:

/// <summary>
/// Gets or sets the button command.
/// </summary>
/// <value>
/// The button command.
/// </value>
public ICommand ButtonCommand => new Command(UpdateCount);

All you need to do is invoke a new Command and call your method, which can be made private.

UpdateCount

Lastly, we have our UpdateCount method which handles updating the button text via a bound property rather than affecting the control directly.

/// <summary>
/// Updates the count command.
/// </summary>
/// <returns></returns>
private void UpdateCount()
{
	_count++;

	// Update text
	var suffix = _count == 1 ? "time" : "times";
	var text = $"Clicked this {_count} {suffix}!";
	ButtonText = text;

	// Announce to screen reader
	SemanticScreenReader.Announce(text);
}

Anyhow, let’s continue…

Step 4 – Update MainPage.xaml

Let’s update the XAML file with the following code to use our ViewModel:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MauiMvvmDemo"
             x:Class="MauiMvvmDemo.MainPage">

    <ContentPage.BindingContext>
        <vm:MainViewModel />
    </ContentPage.BindingContext>

    <ScrollView>
        <VerticalStackLayout
            Spacing="25"
            Padding="30,0"
            VerticalOptions="Center">

            <Image
                Source="dotnet_bot.png"
                SemanticProperties.Description="Cute dot net bot waving hi to you!"
                HeightRequest="200"
                HorizontalOptions="Center" />

            <Label
                Text="Hello, World!"
                SemanticProperties.HeadingLevel="Level1"
                FontSize="32"
                HorizontalOptions="Center" />

            <Label
                Text="Welcome to .NET Multi-platform App UI"
                SemanticProperties.HeadingLevel="Level2"
                SemanticProperties.Description="Welcome to dot net Multi platform App U I"
                FontSize="18"
                HorizontalOptions="Center" />

            <Button
                Text="{Binding Path=ButtonText}"
                SemanticProperties.Hint="Counts the number of times you click"
                Command="{Binding Path=ButtonCommand}"
                HorizontalOptions="Center" />

        </VerticalStackLayout>
    </ScrollView>

</ContentPage>

Deep Dive – What are the key changes?

Now that we have a ViewModel instead of direct calls in the codebehind, we will need to make a few key changes.

The Binding Context

In WPF we typically did this from the codebehind. To simplify that code, we add this to the XAML with the following block:

<ContentPage.BindingContext>
	<vm:MainViewModel />
</ContentPage.BindingContext>

Now our XAML’s binding context is our MainViewModel as desired.

The Button

For this example, the heart of the binding centers on the Button:

<Button
	Text="{Binding Path=ButtonText}"
	SemanticProperties.Hint="Counts the number of times you click"
	Command="{Binding Path=ButtonCommand}"
	HorizontalOptions="Center" />
  • ButtonText gives us the current button text.
  • ButtonCommand maps to our button click action.

Step 5 – Cleanup Mainpage.xaml.cs

Let’s update our codebehind page to be simply:

namespace MauiMvvmDemo;

public partial class MainPage : ContentPage
{
	public MainPage()
	{
		InitializeComponent();
	}
}

Remember, the work done originally is now abstracted to the ViewModel. So, this codebehind doesn’t need to do anything but Initialize the UI.

Step 6 – Build

Note, this might take a little time the first time through.

Step 6 – Output Window

Step 7 – Run

Step 8 - Testing
Step 8 – Testing

Keep clicking the button to see the label update.

Step 8 – All Done!

That’s all there is to it. Happy coding….

I think the title says it pretty well: we want the DatePicker control to not be stupid and allow tabbing to the calendar button so we can meet mouse-less compliance standards.

Q: Why didn’t Microsoft do this to begin with?
A: *shrugs* But, we’re going to fix it.

The code:


using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

/// <summary>
/// Extended DatePicker.
/// </summary>
public class ExtendedDatePicker : DatePicker
{
    #region Members

    private bool _tabInvoked;

    #endregion

    #region Constructors

    /// <summary>
    /// Initializes a new instance of the <see cref="ExtendedDatePicker"/> class.
    /// </summary>
    public ExtendedDatePicker()
    {
        _tabInvoked = false;
        this.PreviewKeyDown += DatePickerPreviewKeyDown;
        this.CalendarClosed += DatePickerCalendarClosed;

        // Allow to be a tab target
        IsTabStop = true;
    }

    #endregion

    #region Events

    /// <summary>
    /// Dates the picker preview key down.
    /// </summary>
    /// <param name="sender">The sender.</param>
    /// <param name="e">The <see cref="KeyEventArgs"/> instance containing the event data.
    /// </param>
    private void DatePickerPreviewKeyDown(object sender, KeyEventArgs e)
    {
        var datePicker = sender as DatePicker;
        if (datePicker == null || e.Key != Key.Tab) return;

        // Mark that "tab" was pressed
        _tabInvoked = true;

        // Reverse drop down opened state
        datePicker.IsDropDownOpen = !datePicker.IsDropDownOpen;
    }

    /// <summary>
    /// Dates the picker calendar closed.
    /// </summary>
    /// <param name="sender">The sender.</param>
    /// <param name="e">The <see cref="RoutedEventArgs"/> instance containing the event data.
    /// </param>
    private void DatePickerCalendarClosed(object sender, RoutedEventArgs e)
    {
        if (!_tabInvoked) return;

        // Reset marker
        _tabInvoked = false;

        // Go to next control in sequence
        var element = Keyboard.FocusedElement as UIElement;
        if (element == null) return;
        element.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
    }

    #endregion
}

Here is what it does in a nutshell:

  • Makes this control a tab stop (it’s not by default)
  • Binds to the PreviewKeyDown event, which catches when Tab is pressed. It then sets a marker, telling the control that the calendar was opened via keyboard and sets IsDropDownOpen to true.
  • Binds to the CalendarClosed event, which (if Tab was invoked) resets the marker and sets the focus to the next control in the view.

It’s not overly complicated, but it solves an annoyance.

Until next time…

As you may know, there is no double-click event for an Image in WPF. However, there are definitely cases where we might want one. But the question is, how do we pull this off while upholding a pure MVVM implementation?

Some considerations:

  • Consideration 1: We should use Commands instead of Events.
  • Consideration 2: In order to get 2 clicks, we will have to look at the MouseDown event, and look at it’s ClickCount since that is something an Image can handle.
  • Consideration 3: Since this is an Image, there is no inherent Command property. In this case, we will need to use a library that provides one.

To start, let’s create a separate class that implements ICommand. In this example all we are doing is firing a MessageBox on successful completion of the event. In a pure implementation a MessageBox would not be considered kosher, but we are just going to use it for our example so we know if it worked:

using System;
using System.Windows;
using System.Windows.Input;

namespace DoubleClickImageTest
{
    /// <summary>
    /// A test command.
    /// </summary>
    public class TestCommand : ICommand
    {
        public virtual bool CanExecute(object parameter)
        {
            return true;
        }

        public virtual void Execute(object parameter)
        {
            // We shouldn't put message boxes in commands normally, but we will for this test.
            MessageBox.Show("Hey, we just double-clicked our Image!",
                "Double-Click Test", MessageBoxButton.OK);
        }

        public event EventHandler CanExecuteChanged
        {
            add { CommandManager.RequerySuggested += value; }
            remove { CommandManager.RequerySuggested -= value; }
        }
    }
}

Next, because this is MVVM we should create a View Model that will hold our command as a property:

using System;

namespace DoubleClickImageTest
{
    public class TestViewModel
    {
        #region Properties

        /// <summary>
        /// Gets or sets my command.
        /// </summary>
        /// <value>
        /// My command.
        /// </value>
        public TestCommand MyCommand { get; set; }

        #endregion

        #region Constructors
        
        /// <summary>
        /// Initializes a new instance of the <see cref="TestViewModel"/> class.
        /// </summary>
        public TestViewModel()
        {
            MyCommand = new TestCommand();
        }

        #endregion
    }
}

To finish setting up the View Model, we will add it to the code-behind of our View and set the Data Context of the View to the View Model instance.

using System;
using System.Windows;

namespace DoubleClickImageTest
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        #region Members
        
        private TestViewModel _vm;

        #endregion

        #region Constructors

        /// <summary>
        /// Initializes a new instance of the <see cref="MainWindow"/> class.
        /// </summary>
        public MainWindow()
        {
            // Get and set VM
            _vm = new TestViewModel();
            this.DataContext = _vm;

            // Initialize UI
            InitializeComponent();
        }

        #endregion
    }
}

We have now satisfied the conditions of Consideration 1.

In order to satisfy Consideration 2, we will need to do the following:

  1. Write a class called CustomImage that extends System.Windows.Control.Image.
  2. In CustomImage create a RoutedEvent and RoutedEventHandler to facilitate the double-click event.
  3. In CustomImage override OnMouseLeftButtonDown to evaluate the click count.

First, we create the RoutedEvent:

/// <summary>
/// The mouse left button double click event
/// </summary>
public static readonly RoutedEvent MouseLeftButtonDoubleClick =
    EventManager.RegisterRoutedEvent(
        "MouseLeftButtonDoubleClick",
        RoutingStrategy.Bubble,
        typeof(RoutedEventHandler),
        typeof(CustomImage));

Next we will create the RoutedEventHandler to execute MouseLeftButtonDoubleClick :

/// <summary>
/// Occurs when [mouse left button double click event handler].
/// </summary>
public event RoutedEventHandler MouseLeftButtonDoubleClickEvent
{
    add
    {
        AddHandler(MouseLeftButtonDoubleClick, value);
    }
    remove
    {
        RemoveHandler(MouseLeftButtonDoubleClick, value);
    }
}

Lastly, we need to override the OnMouseLeftButtonDown event.

So, why are we doing this? We know images have OnMouseLeftButtonDown, so we can use that to observe the click-count and call our event. In this case, we observe the ClickCount of our MouseButtonEventArgs. If we observe 2 clicks, we raise the MouseLeftButtonDoubleClick event:

/// <summary>
/// Invoked when an unhandled <see cref="E:System.Windows.UIElement.MouseLeftButtonDown" /> 
/// routed event is raised on this element. Implement this method to add class handling for 
/// this event.
/// </summary>
/// <param name="e">The <see cref="T:System.Windows.Input.MouseButtonEventArgs" /> that 
/// contains the event data. The event data reports that the left mouse button was pressed.
/// </param>
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
    // Double-click
    if (e.ClickCount == 2)
    {
        RaiseEvent(new MouseLeftButtonDoubleClickEventArgs(
            MouseLeftButtonDoubleClick, this));
    }
    base.OnMouseLeftButtonDown(e);
}

/// <summary>
/// MouseLeftButtonDoubleClick EventArgs.
/// </summary>
public class MouseLeftButtonDoubleClickEventArgs : RoutedEventArgs
{
    /// <summary>
    /// Initializes a new instance of the <see cref="MouseLeftButtonDoubleClickEventArgs"/> 
    /// class.
    /// </summary>
    /// <param name="routedEvent">The routed event identifier for this instance of the 
    /// <see cref="T:System.Windows.RoutedEventArgs" /> class.</param>
    /// <param name="source">An alternate source that will be reported when the event is 
    /// handled. This pre-populates the
    /// <see cref="P:System.Windows.RoutedEventArgs.Source" /> property.</param>
    public MouseLeftButtonDoubleClickEventArgs(RoutedEvent routedEvent, object source)
        : base(routedEvent, source)
    {
    }
}

So, in it’s entirety, the CustomImage class looks like this:

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace DoubleClickImageTest
{
    /// <summary>
    /// Custom image.
    /// </summary>
    public class CustomImage : Image
    {
        /// <summary>
        /// The mouse left button double click event
        /// </summary>
        public static readonly RoutedEvent MouseLeftButtonDoubleClick =
            EventManager.RegisterRoutedEvent(
                "MouseLeftButtonDoubleClick",
                RoutingStrategy.Bubble,
                typeof(RoutedEventHandler),
                typeof(CustomImage));

        /// <summary>
        /// Occurs when [mouse left button double click event handler].
        /// </summary>
        public event RoutedEventHandler MouseLeftButtonDoubleClickEvent
        {
            add
            {
                AddHandler(MouseLeftButtonDoubleClick, value);
            }
            remove
            {
                RemoveHandler(MouseLeftButtonDoubleClick, value);
            }
        }

        /// <summary>
        /// Invoked when an unhandled <see cref="E:System.Windows.UIElement.MouseLeftButtonDown" /> 
        /// routed event is raised on this element. Implement this method to add class handling for 
        /// this event.
        /// </summary>
        /// <param name="e">The <see cref="T:System.Windows.Input.MouseButtonEventArgs" /> that 
        /// contains the event data. The event data reports that the left mouse button was pressed.
        /// </param>
        protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
        {
            // Double-click
            if (e.ClickCount == 2)
            {
                RaiseEvent(new MouseLeftButtonDoubleClickEventArgs(
                    MouseLeftButtonDoubleClick, this));
            }
            base.OnMouseLeftButtonDown(e);
        }

        /// <summary>
        /// MouseLeftButtonDoubleClick EventArgs.
        /// </summary>
        public class MouseLeftButtonDoubleClickEventArgs : RoutedEventArgs
        {
            /// <summary>
            /// Initializes a new instance of the <see cref="MouseLeftButtonDoubleClickEventArgs"/> 
            /// class.
            /// </summary>
            /// <param name="routedEvent">The routed event identifier for this instance of the 
            /// <see cref="T:System.Windows.RoutedEventArgs" /> class.</param>
            /// <param name="source">An alternate source that will be reported when the event is 
            /// handled. This pre-populates the
            /// <see cref="P:System.Windows.RoutedEventArgs.Source" /> property.</param>
            public MouseLeftButtonDoubleClickEventArgs(RoutedEvent routedEvent, object source)
	            : base(routedEvent, source)
            {
            }
        }
    }
}

For Consideration 3 we need to extend our image to allow Commanding, and to do that we will use the Interactivity Library from Expression Blend.

Now, we will build our XAML. Let’s start by adding our CustomImage to a window:

<Window x:Class="DoubleClickImageTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:DoubleClickImageTest"
        Title="MainWindow" Height="350" Width="525">

    <Grid>
        <local:CustomImage 
            Width="200"
            Height="200"
            Source="/DoubleClickImageTest;component/cat_popcorn.jpg" 
            HorizontalAlignment="Center"
            VerticalAlignment="Center">
        </local:CustomImage>
    </Grid>
</Window>

It looks like this at runtime:

Popcorn Cat

So far, there is nothing overly significant about this implementation. Our CustomImage is behaving just like a normal image and it’s center-mass in our window.

Now, let’s add our new MouseLeftButtonDoubleClick event.

<Window x:Class="DoubleClickImageTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:DoubleClickImageTest"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <local:CustomImage 
            Width="200"
            Height="200"
            Source="/DoubleClickImageTest;component/cat_popcorn.jpg" 
            HorizontalAlignment="Center"
            VerticalAlignment="Center">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="MouseLeftButtonDoubleClickEvent">
                    <i:InvokeCommandAction Command="{Binding Path=MyCommand}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </local:CustomImage>
    </Grid>
</Window>

You will notice we included the Interactivity Library in the header:

xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"

Also, this new block inside CustomImage:

<i:Interaction.Triggers>
    <i:EventTrigger EventName="MouseLeftButtonDoubleClickEvent">
        <i:InvokeCommandAction Command="{Binding Path=MyCommand}" />
    </i:EventTrigger>
</i:Interaction.Triggers>

This says: “whenever MouseLeftButtonDoubleClick is fired, invoke MyCommand“.

Note: you will notice this works exactly the same as setting a command on a Button.

So, now when we double-click our image, we see this:

Popcorn Cat with a double-click event

And just like that, we created our double-clickable image.

Until next time…

Note: This is a continuation of the very popular article Customizing the WPF MenuItem in XAML.

The source code for this solution (Visual Studio 2012) can be downloaded HERE

Q: I have a WPF submenu but I want to limit it’s height. How do I do that?
A: As mentioned in the earlier post you need to override the default Aero MenuItem template and components for WPF.

Q: So, where do we need to modify the style to fix this?
A: There are actually 2 areas in the style that require modification.

These are lines 180 – 199 of the Style (as seen in the original post linked above):


<ContentControl Name="SubMenuBorder"
			Template="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=SubmenuContent}}"
			IsTabStop="false">
	<ScrollViewer Name="SubMenuScrollViewer" CanContentScroll="true" MaxHeight="400"
				  Style="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=MenuScrollViewer}}">
		<Grid RenderOptions.ClearTypeHint="Enabled">
			<Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
				<Rectangle
				Height="{Binding ElementName=SubMenuBorder,Path=ActualHeight}" 
				Width="{Binding ElementName=SubMenuBorder,Path=ActualWidth}" 
				Fill="{StaticResource SubMenuBackgroundBrush}" />
			</Canvas>
			<ItemsPresenter Name="ItemsPresenter" Margin="2"
						KeyboardNavigation.TabNavigation="Cycle"
						KeyboardNavigation.DirectionalNavigation="Cycle"
						SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
						Grid.IsSharedSizeScope="true"/>
		</Grid>
	</ScrollViewer>
</ContentControl>

Notice on line 183 (line 4), we added the MaxHeight=”400″

We repeat the again in the code between lines 457 and 476 of the Style (as seen in the original post linked above):


<ContentControl Name="SubMenuBorder"
				Template="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=SubmenuContent}}"
				IsTabStop="false">
	<ScrollViewer Name="SubMenuScrollViewer" CanContentScroll="true" MaxHeight="400"
				  Style="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=MenuScrollViewer}}">
		<Grid RenderOptions.ClearTypeHint="Enabled">
			<Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
				<Rectangle
				Height="{Binding ElementName=SubMenuBorder,Path=ActualHeight}" 
				Width="{Binding ElementName=SubMenuBorder,Path=ActualWidth}" 
				Fill="{StaticResource SubMenuBackgroundBrush}" />
			</Canvas>
			<ItemsPresenter Name="ItemsPresenter" Margin="2"
						KeyboardNavigation.TabNavigation="Cycle"
						KeyboardNavigation.DirectionalNavigation="Cycle"
						SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
						Grid.IsSharedSizeScope="true"/>
		</Grid>
	</ScrollViewer>
</ContentControl>

Notice on line 460 (line 4), we added the MaxHeight=”400″

The blocks are virtually identical except the first one affects the initial Submenu and the second handles the child Submenu.

The intent here is to control the MaxHeight with a property so, we are going to do just that.

My Project file layout:

Project file layout in VS2012

Important files:

  • MenuItemEnhanced.xaml: Our new and improved MenuItem control (now with submenu height features!)
  • MenuItemEnhanced.xaml.cs: Code-behind for our new and improved MenuItem control.
  • Styles.xaml: A ResourceDictionary containing the MenuItem style mentioned above in it’s entirety.

All right. We saw the initial style sheet in it’s “glory”. Lets look at the code for the new control.

The MenuItemEnhanced XAML:


<MenuItem x:Class="Xcalibur.UI.Menu.Controls.MenuItemEnhanced"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    
    <MenuItem.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/Xcalibur.UI.Menu;component/Styles/Styles.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </MenuItem.Resources>
</MenuItem>

Nothing overly remarkable here. We created a new control that is based on MenuItem.

Now the code-behind:


using System.Windows;
using System.Windows.Controls;

namespace Xcalibur.UI.Menu.Controls
{
    /// <summary>
    /// An extended MenuItem
    /// </summary>
    public partial class MenuItemEnhanced : MenuItem
    {
        #region Properties
        
        /// <summary>
        /// Gets or sets the height of the max submenu.
        /// </summary>
        /// <value>
        /// The height of the max submenu.
        /// </value>
        public double MaxSubmenuHeight
        {
            get { return (double)GetValue(MaxSubmenuHeightProperty); }
            set { SetValue(MaxSubmenuHeightProperty, value); }
        }

        /// <summary>
        /// Gets or sets the height of the max submenu (Dependency Property)
        /// </summary>
        public static readonly DependencyProperty MaxSubmenuHeightProperty =
            DependencyProperty.Register("MaxSubmenuHeight", typeof(double), 
            typeof(MenuItemEnhanced), new PropertyMetadata(400.0));
        
        #endregion

        #region Constructors
        
        /// <summary>
        /// Initializes a new instance of the <see cref="MenuItemEnhanced"/> class.
        /// </summary>
        public MenuItemEnhanced()
        {
            this.InitializeComponent();
        }

        #endregion
    }
}

Here, we created a dependency property called MaxSubmenuHeight of type double. The default height in this case is 400.0 since we used that height in the last post. Nothing more needed to be done here.

The kicker is updating our ResourceDictionary to observe our new, awesome DependencyProperty. To do that, we will go back to those 2 sections of XAML in the style sheet and change this:

MaxHeight=”400″

to this:

MaxHeight=”{Binding Path=MaxSubmenuHeight,RelativeSource={RelativeSource TemplatedParent}}”

That’s all there is to it.

And now for your viewing pleasure (as included in the project linked above), here is an example. The initial menu has been capped at 600px and it’s submenu at 200px:


<Window x:Class="TestBed.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:menu="clr-namespace:Xcalibur.UI.Menu.Controls;assembly=Xcalibur.UI.Menu"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Menu>
            <menu:MenuItemEnhanced Header="Test Menu" MaxSubmenuHeight="600">
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Test Item 1" />
                <menu:MenuItemEnhanced Header="Submenu" MaxSubmenuHeight="200">
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                    <menu:MenuItemEnhanced Header="Test Item 1" />
                </menu:MenuItemEnhanced>
            </menu:MenuItemEnhanced>
        </Menu>
    </Grid>
</Window>

And that looks like this:

Screenshot of our MenuItemEnhanced control in action

That’s all for now. Hope I helped you solve this annoying issue.

One of the issues I have seen around the web is in regard to TabControls and missing content. The “missing” content in this case is the content of tab items within a TabControl that are not in focus. By design, a TabControl will only load the content of a TabItem once it has been brought into focus. This is not bad design if you think about it for a moment.

Q: Shouldn’t we just load into memory what we need at the time we need it to minimize our footprint?
A: Yes, definitely. This is good application design.

But, what about the case where I need information from other tabs that have not yet been brought into focus? For example, what if I have a GridView in another tab and I want to show the row count right when the control is loaded? Well, the answer to that is pre-loading your tab items. And since the TabControl does not have this behavior built-in by default, we will have to do it ourselves.

Here is one way I saw on the web that I wanted to bring to your attention:


for (var i = 0; i < myTabControl.Items.Count; i++)
{
	myTabControl.SelectedIndex = i;
	myTabControl.UpdateLayout();
}

Well, this looks pretty good and it has very little code. This should work, right? No, not in all cases. The reason is that this will cycle through the tabs as fast as the UpdateLayout() call completes on each tab. Because this implementation will not necessarily wait until each tab is done loading, we cannot guarantee that the content of each tab has finished loading into the Visual Tree. What we need is a solution that waits until each tab item has completely loaded its content before we move to the next tab.

So, let’s state our list of objectives:

  • Objective 1: Each tab item should wait to complete loading before the next tab item is selected.
  • Objective 2: We should hide the Tab Control from view until it is done pre-loading.
  • Objective 3: We should return to the first tab in the sequence at completion.

The approach:

In order to effectively meet the first objective, we should chain the loading of each tab through recursion.

So, here are our 2 methods:


/// <summary>
/// Preloads tab items of a tab control in sequence.
/// </summary>
/// <param name="tabControl">The tab control.</param>
public static void PreloadTabs(TabControl tabControl)
{
    // Evaluate
    if (tabControl.Items != null)
    {
        // The first tab is already loaded
        // so, we will start from the second tab.
        if (tabControl.Items.Count > 1)
        {
            // Hide tabs
            tabControl.Opacity = 0.0;

            // Last action
            Action onComplete = () =>
            {
                // Set index to first tab
                tabControl.SelectedIndex = 0;

                // Show tabs
                tabControl.Opacity = 1.0;
            };

            // Second tab
            var firstTab = (tabControl.Items[1] as TabItem);
            if (firstTab != null)
            {
                PreloadTab(tabControl, firstTab, onComplete);
            }
        }
    }
}

/// <summary>
/// Preloads an individual tab item.
/// </summary>
/// <param name="tabControl">The tab control.</param>
/// <param name="tabItem">The tab item.</param>
/// <param name="onComplete">The onComplete action.</param>
private static void PreloadTab(TabControl tabControl, TabItem tabItem, Action onComplete = null)
{
    // On update complete
    tabItem.Loaded += delegate
    {
    	// Update if not the last tab
    	if (tabItem != tabControl.Items.Last())
    	{
    	    // Get next tab
            var nextIndex = tabControl.Items.IndexOf(tabItem) + 1;
            var nextTabItem = tabControl.Items[nextIndex] as TabItem;

            // Preload
            if (nextTabItem != null)
            {
                PreloadTab(tabControl, nextTabItem, onComplete);
            }
        }

        else
        {
            if (onComplete != null)
            {
                onComplete();
            }
        }
    };

    // Set current tab context
    tabControl.SelectedItem = tabItem;
}

So what’s going on here?

  1. PreloadTabs acts as our public method where we pass the TabControl.
  2. The opacity of the TabControl is set to 0 (Objective 2). We do not change the visibility in this case because the UI will not properly update if Visibility is Collapsed.
  3. PreloadTabs will make sure it has tab items and then call our private method PreloadTab for the second tab in the collection (since the first tab would already be loaded by the instantiation of the TabControl).
  4. PreloadTab evaluates the second tab item in the collection by setting the TabControl.SelectedItem property to the current tab.
  5. Once the tab item has completed loading, it will be evaluated. (Objective 1)
  6. If it is not the last tab item in the collection, the next tab item is retrieved and passed to the PreloadTab method.
  7. Note: This behavior will repeat until the last item in the tab item collection has been reached.
  8. Once the last item has been reached, the onComplete action is called.
  9. When the onComplete action is called, we set the SelectedIndex of the TabControl back to 0 (Objective 2), and then restore the opacity of the TabControl back to 1 (Objective 3).

And just like that we have a tab pre-loader for our TabControl.

That’s all for now. Happy coding.

Customizing the WPF MenuItem in XAML

Categories: .Net, C#, WPF, XAML
Comments: 4

Note: This topic has been updated with a complete solution in Part 2.

So, I was spending some time trying to figure out how to make a menu in WPF scrollable before it maxed out on my screen height. Well, unfortunately, the only way to do it is to override the default Aero template for the MenuItem in WPF. You can get that here.

Here’s the section to change. Notice the addition of “MaxHeight” on the “SubMenuScrollViewer” object. It’s really that simple.


<Popup x:Name="PART_Popup"
    AllowsTransparency="true"
    Placement="Right"
    VerticalOffset="-3"
    HorizontalOffset="-2"
    IsOpen="{Binding Path=IsSubmenuOpen,RelativeSource={RelativeSource TemplatedParent}}"
    Focusable="false"
    PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}">
    <theme:SystemDropShadowChrome Name="Shdw" Color="Transparent">
        <ContentControl Name="SubMenuBorder"
            Template="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=SubmenuContent}}"
            IsTabStop="false">
            <ScrollViewer Name="SubMenuScrollViewer" CanContentScroll="true" MaxHeight="400"
                Style="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=MenuScrollViewer}}">
                <Grid RenderOptions.ClearTypeHint="Enabled">
                    <Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
                        <Rectangle
                            Height="{Binding ElementName=SubMenuBorder,Path=ActualHeight}" 
                            Width="{Binding ElementName=SubMenuBorder,Path=ActualWidth}" 
                            Fill="{StaticResource SubMenuBackgroundBrush}" />
                    </Canvas>
                    <ItemsPresenter Name="ItemsPresenter" Margin="2"
                        KeyboardNavigation.TabNavigation="Cycle"
                        KeyboardNavigation.DirectionalNavigation="Cycle"
                        SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                        Grid.IsSharedSizeScope="true"/>
                </Grid>
            </ScrollViewer>
        </ContentControl>
    </theme:SystemDropShadowChrome>
</Popup>

 

Here is a screenshot of the desired effect:

Example of Height-Controlled Menu

Example of Height-Controlled Menu

Here is the full Resource Dictionary xaml:


<ResourceDictionary
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:theme="clr-namespace:Microsoft.Windows.Themes;assembly=PresentationFramework.Aero">

    <MenuScrollingVisibilityConverter x:Key="MenuScrollingVisibilityConverter"/>
    <Geometry x:Key="DownArrow">M 0,0 L 3.5,4 L 7,0 Z</Geometry>
    <Geometry x:Key="UpArrow">M 0,4 L 3.5,0 L 7,4 Z</Geometry>
    <Geometry x:Key="RightArrow">M 0,0 L 4,3.5 L 0,7 Z</Geometry>
    <Geometry x:Key="Checkmark">M 0,5.1 L 1.7,5.2 L 3.4,7.1 L 8,0.4 L 9.2,0 L 3.3,10.8 Z</Geometry>
    <SolidColorBrush x:Key="SubMenuBackgroundBrush" Color="#FFF5F5F5" />

    <LinearGradientBrush x:Key="MenuItemSelectionFill"
                         StartPoint="0,0"
                         EndPoint="0,1">
        <LinearGradientBrush.GradientStops>
            <GradientStop Color="#34C5EBFF"
                          Offset="0"/>
            <GradientStop Color="#3481D8FF"
                          Offset="1"/>
        </LinearGradientBrush.GradientStops>
    </LinearGradientBrush>
    <LinearGradientBrush x:Key="MenuItemPressedFill"
                         StartPoint="0,0"
                         EndPoint="0,1">
        <LinearGradientBrush.GradientStops>
            <GradientStop Color="#28717070"
                          Offset="0"/>
            <GradientStop Color="#50717070"
                          Offset="0.75"/>
            <GradientStop Color="#90717070"
                          Offset="1"/>
        </LinearGradientBrush.GradientStops>
    </LinearGradientBrush>
    <Style x:Key="{x:Static MenuItem.SeparatorStyleKey}"
           TargetType="{x:Type Separator}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type Separator}">
                    <Grid SnapsToDevicePixels="true" Margin="0,6,0,4">
                        <Rectangle Height="1"
                                   Margin="30,0,1,1"
                                   Fill="#E0E0E0"/>
                        <Rectangle Height="1"
                                   Margin="30,1,1,0"
                                   Fill="White"/>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
    <ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelItemTemplateKey}"
                     TargetType="{x:Type MenuItem}">
        <Grid SnapsToDevicePixels="true">
            <Rectangle x:Name="OuterBorder"
                       RadiusX="2"
                       RadiusY="2"/>
            <Rectangle Name="Bg"
                       Margin="1"
                       Fill="{TemplateBinding MenuItem.Background}"
                       Stroke="{TemplateBinding MenuItem.BorderBrush}"
                       StrokeThickness="1"
                       RadiusX="1"
                       RadiusY="1"/>
            <Rectangle x:Name="InnerBorder"
                       Margin="2"/>
            <DockPanel>
                <ContentPresenter x:Name="Icon"
                                  Margin="4,0,6,0"
                                  VerticalAlignment="Center"
                                  ContentSource="Icon"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                <Path x:Name="GlyphPanel"
                      Margin="7,0,0,0"
                      Visibility="Collapsed"
                      VerticalAlignment="Center"
                      Fill="{TemplateBinding MenuItem.Foreground}"
                      FlowDirection="LeftToRight"
                      Data="{StaticResource Checkmark}"/>
                <ContentPresenter ContentSource="Header"
                                  Margin="{TemplateBinding MenuItem.Padding}"
                                  RecognizesAccessKey="True"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
            </DockPanel>
        </Grid>
        <ControlTemplate.Triggers>
            <Trigger Property="Icon"
                     Value="{x:Null}">
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger Property="IsChecked"
                     Value="true">
                <Setter TargetName="GlyphPanel"
                        Property="Visibility"
                        Value="Visible"/>
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger Property="IsHighlighted"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#90717070"/>
                <Setter TargetName="OuterBorder"
                        Property="Stroke"
                        Value="#50FFFFFF"/>
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#50FFFFFF"/>

            </Trigger>
            <Trigger Property="IsKeyboardFocused"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#E0717070"/>
                <Setter TargetName="Bg"
                        Property="Fill"
                        Value="{StaticResource MenuItemPressedFill}"/>
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#50747272"/>
            </Trigger>
            <Trigger Property="IsEnabled"
                     Value="false">
                <Setter Property="Foreground"
                        Value="#FF9A9A9A"/>
                <Setter TargetName="GlyphPanel"
                        Property="Fill"
                        Value="#848589"/>
            </Trigger>
        </ControlTemplate.Triggers>
    </ControlTemplate>
    <ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelHeaderTemplateKey}"
                     TargetType="{x:Type MenuItem}">
        <Grid SnapsToDevicePixels="true">
            <Rectangle x:Name="OuterBorder"
                       RadiusX="2"
                       RadiusY="2"/>
            <Rectangle Name="Bg"
                       Margin="1"
                       Fill="{TemplateBinding MenuItem.Background}"
                       Stroke="{TemplateBinding MenuItem.BorderBrush}"
                       StrokeThickness="1"
                       RadiusX="1"
                       RadiusY="1"/>
            <Rectangle x:Name="InnerBorder"
                       Margin="2"/>
            <DockPanel>
                <ContentPresenter x:Name="Icon"
                                  Margin="4,0,6,0"
                                  VerticalAlignment="Center"
                                  ContentSource="Icon"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                <Path x:Name="GlyphPanel"
                      Margin="7,0,0,0"
                      Visibility="Collapsed"
                      VerticalAlignment="Center"
                      Fill="{TemplateBinding MenuItem.Foreground}"
                      FlowDirection="LeftToRight"
                      Data="{StaticResource Checkmark}"/>
                <ContentPresenter ContentSource="Header"
                                  Margin="{TemplateBinding MenuItem.Padding}"
                                  RecognizesAccessKey="True"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
            </DockPanel>
            <Popup x:Name="PART_Popup"
                   HorizontalOffset="1"
                   VerticalOffset="-1"
                   AllowsTransparency="true"
                   Placement="Bottom"
                   IsOpen="{Binding Path=IsSubmenuOpen,RelativeSource={RelativeSource TemplatedParent}}"
                   Focusable="false"
                   PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}">
                <theme:SystemDropShadowChrome Name="Shdw"
                                              Color="Transparent">
                    <ContentControl Name="SubMenuBorder"
                                    Template="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=SubmenuContent}}"
                                    IsTabStop="false">
                        <ScrollViewer Name="SubMenuScrollViewer" CanContentScroll="true" MaxHeight="400"
                                      Style="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=MenuScrollViewer}}">
                            <Grid RenderOptions.ClearTypeHint="Enabled">
                                <Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
                                    <Rectangle
                                    Height="{Binding ElementName=SubMenuBorder,Path=ActualHeight}" 
                                    Width="{Binding ElementName=SubMenuBorder,Path=ActualWidth}" 
                                    Fill="{StaticResource SubMenuBackgroundBrush}" />
                                </Canvas>
                                <ItemsPresenter Name="ItemsPresenter" Margin="2"
                                            KeyboardNavigation.TabNavigation="Cycle"
                                            KeyboardNavigation.DirectionalNavigation="Cycle"
                                            SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                            Grid.IsSharedSizeScope="true"/>
                            </Grid>
                        </ScrollViewer>
                    </ContentControl>
                </theme:SystemDropShadowChrome>
            </Popup>
        </Grid>
        <ControlTemplate.Triggers>
            <Trigger Property="IsSuspendingPopupAnimation"
                     Value="true">
                <Setter TargetName="PART_Popup"
                        Property="PopupAnimation"
                        Value="None"/>
            </Trigger>
            <Trigger Property="Icon"
                     Value="{x:Null}">
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger Property="IsChecked"
                     Value="true">
                <Setter TargetName="GlyphPanel"
                        Property="Visibility"
                        Value="Visible"/>
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger SourceName="PART_Popup"
                     Property="Popup.HasDropShadow"
                     Value="true">
                <Setter TargetName="Shdw"
                        Property="Margin"
                        Value="0,0,5,5"/>
                <Setter TargetName="Shdw"
                        Property="Color"
                        Value="#71000000"/>
            </Trigger>
            <Trigger Property="IsHighlighted"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#90717070"/>
                <Setter TargetName="OuterBorder"
                        Property="Stroke"
                        Value="#50FFFFFF"/>
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#50FFFFFF"/>

            </Trigger>
            <Trigger Property="IsKeyboardFocused"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#E0717070"/>
                <Setter TargetName="Bg"
                        Property="Fill"
                        Value="{StaticResource MenuItemPressedFill}"/>
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#50747272"/>
            </Trigger>
            <Trigger Property="IsSubmenuOpen"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#E0717070"/>
                <Setter TargetName="Bg"
                        Property="Fill"
                        Value="{StaticResource MenuItemPressedFill}"/>
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#50747272"/>
            </Trigger>
            <Trigger Property="IsEnabled"
                     Value="false">
                <Setter Property="Foreground"
                        Value="#FF9A9A9A"/>
                <Setter TargetName="GlyphPanel"
                        Property="Fill"
                        Value="#848589"/>
            </Trigger>
        </ControlTemplate.Triggers>
    </ControlTemplate>

    <!-- Submenu -->
    <ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuItemTemplateKey}"
                     TargetType="{x:Type MenuItem}">
        <Grid SnapsToDevicePixels="true">
            <Rectangle Name="Bg"
                       Fill="{TemplateBinding MenuItem.Background}"
                       Stroke="{TemplateBinding MenuItem.BorderBrush}"
                       StrokeThickness="1"
                       RadiusX="2"
                       RadiusY="2"/>
            <Rectangle x:Name="InnerBorder"
                       Margin="1"
                       RadiusX="2"
                       RadiusY="2"/>
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition MinWidth="24"
                                      Width="Auto"
                                      SharedSizeGroup="MenuItemIconColumnGroup"/>
                    <ColumnDefinition Width="4"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="37"/>
                    <ColumnDefinition Width="Auto"
                                      SharedSizeGroup="MenuItemIGTColumnGroup"/>
                    <ColumnDefinition Width="17"/>
                </Grid.ColumnDefinitions>
                <ContentPresenter x:Name="Icon"
                                  Margin="1"
                                  VerticalAlignment="Center"
                                  ContentSource="Icon"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                <Border x:Name="GlyphPanel"
                        Background="#E6EFF4"
                        BorderBrush="#CDD3E6"
                        BorderThickness="1"
                        CornerRadius="3"
                        Margin="1"
                        Visibility="Hidden"
                        Width="22" 
                        Height="22">
                    <Path Name="Glyph"
                          Width="9"
                          Height="11"
                          Fill="#0C12A1"
                          FlowDirection="LeftToRight"
                          Data="{StaticResource Checkmark}"/>
                </Border>
                <ContentPresenter Grid.Column="2"
                                  ContentSource="Header"
                                  Margin="{TemplateBinding MenuItem.Padding}"
                                  RecognizesAccessKey="True"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                <TextBlock Grid.Column="4"
                           Text="{TemplateBinding MenuItem.InputGestureText}"
                           Margin="{TemplateBinding MenuItem.Padding}"/>
            </Grid>
        </Grid>
        <ControlTemplate.Triggers>
            <Trigger Property="Icon"
                     Value="{x:Null}">
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger Property="IsChecked"
                     Value="true">
                <Setter TargetName="GlyphPanel"
                        Property="Visibility"
                        Value="Visible"/>
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger Property="IsHighlighted"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Fill"
                        Value="{StaticResource MenuItemSelectionFill}"/>
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#8071CBF1"/>
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#40FFFFFF"/>
            </Trigger>
            <Trigger Property="IsEnabled"
                     Value="false">
                <Setter Property="Foreground"
                        Value="#FF9A9A9A"/>
                <Setter TargetName="GlyphPanel"
                        Property="Background"
                        Value="#EEE9E9"/>
                <Setter TargetName="GlyphPanel"
                        Property="BorderBrush"
                        Value="#DBD6D6"/>
                <Setter TargetName="Glyph"
                        Property="Fill"
                        Value="#848589"/>
            </Trigger>
        </ControlTemplate.Triggers>
    </ControlTemplate>
    <ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuHeaderTemplateKey}"
                     TargetType="{x:Type MenuItem}">
        <Grid SnapsToDevicePixels="true">
            <Rectangle Name="Bg"
                       Fill="{TemplateBinding MenuItem.Background}"
                       Stroke="{TemplateBinding MenuItem.BorderBrush}"
                       StrokeThickness="1"
                       RadiusX="2"
                       RadiusY="2"/>
            <Rectangle x:Name="InnerBorder"
                       Margin="1"
                       Stroke="Transparent"
                       StrokeThickness="1"
                       RadiusX="2"
                       RadiusY="2"/>
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition MinWidth="24"
                                      Width="Auto"
                                      SharedSizeGroup="MenuItemIconColumnGroup"/>
                    <ColumnDefinition Width="4"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="37"/>
                    <ColumnDefinition Width="Auto"
                                      SharedSizeGroup="MenuItemIGTColumnGroup"/>
                    <ColumnDefinition Width="17"/>
                </Grid.ColumnDefinitions>
                <ContentPresenter x:Name="Icon"
                                  Margin="1"
                                  VerticalAlignment="Center"
                                  ContentSource="Icon"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                <Border x:Name="GlyphPanel"
                        Background="#E6EFF4"
                        BorderBrush="#CDD3E6"
                        BorderThickness="1"
                        CornerRadius="3"
                        Margin="1"
                        Visibility="Hidden"
                        Width="22" 
                        Height="22">
                    <Path Name="Glyph"
                          Width="9"
                          Height="11"
                          Fill="#0C12A1"
                          FlowDirection="LeftToRight"
                          Data="{StaticResource Checkmark}"/>
                </Border>
                <ContentPresenter Grid.Column="2"
                                  ContentSource="Header"
                                  Margin="{TemplateBinding MenuItem.Padding}"
                                  RecognizesAccessKey="True"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                <TextBlock Grid.Column="4"
                           Text="{TemplateBinding MenuItem.InputGestureText}"
                           Margin="{TemplateBinding MenuItem.Padding}"
                           Visibility="Collapsed"/>
                <Path Grid.Column="5"
                      VerticalAlignment="Center"
                      Margin="4,0,0,0"
                      Fill="{TemplateBinding MenuItem.Foreground}"
                      Data="{StaticResource RightArrow}"/>
            </Grid>
            <Popup x:Name="PART_Popup"
                   AllowsTransparency="true"
                   Placement="Right"
                   VerticalOffset="-3"
                   HorizontalOffset="-2"
                   IsOpen="{Binding Path=IsSubmenuOpen,RelativeSource={RelativeSource TemplatedParent}}"
                   Focusable="false"
                   PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}">
                <theme:SystemDropShadowChrome Name="Shdw"
                                              Color="Transparent">
                    <ContentControl Name="SubMenuBorder"
                                    Template="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=SubmenuContent}}"
                                    IsTabStop="false">
                        <ScrollViewer Name="SubMenuScrollViewer" CanContentScroll="true" MaxHeight="400"
                                      Style="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type FrameworkElement}, ResourceId=MenuScrollViewer}}">
                            <Grid RenderOptions.ClearTypeHint="Enabled">
                                <Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
                                    <Rectangle
                                    Height="{Binding ElementName=SubMenuBorder,Path=ActualHeight}" 
                                    Width="{Binding ElementName=SubMenuBorder,Path=ActualWidth}" 
                                    Fill="{StaticResource SubMenuBackgroundBrush}" />
                                </Canvas>
                                <ItemsPresenter Name="ItemsPresenter" Margin="2"
                                            KeyboardNavigation.TabNavigation="Cycle"
                                            KeyboardNavigation.DirectionalNavigation="Cycle"
                                            SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                            Grid.IsSharedSizeScope="true"/>
                            </Grid>
                        </ScrollViewer>
                    </ContentControl>
                </theme:SystemDropShadowChrome>
            </Popup>
        </Grid>
        <ControlTemplate.Triggers>
            <Trigger Property="IsSuspendingPopupAnimation"
                     Value="true">
                <Setter TargetName="PART_Popup"
                        Property="PopupAnimation"
                        Value="None"/>
            </Trigger>
            <Trigger Property="IsHighlighted"
                     Value="true">
                <Setter TargetName="InnerBorder"
                        Property="Stroke"
                        Value="#D1DBF4FF"/>
            </Trigger>
            <Trigger Property="Icon"
                     Value="{x:Null}">
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger Property="IsChecked"
                     Value="true">
                <Setter TargetName="GlyphPanel"
                        Property="Visibility"
                        Value="Visible"/>
                <Setter TargetName="Icon"
                        Property="Visibility"
                        Value="Collapsed"/>
            </Trigger>
            <Trigger SourceName="PART_Popup"
                     Property="Popup.HasDropShadow"
                     Value="true">
                <Setter TargetName="Shdw"
                        Property="Margin"
                        Value="0,0,5,5"/>
                <Setter TargetName="Shdw"
                        Property="Color"
                        Value="#71000000"/>
            </Trigger>
            <Trigger Property="IsHighlighted"
                     Value="true">
                <Setter TargetName="Bg"
                        Property="Fill"
                        Value="{StaticResource MenuItemSelectionFill}"/>
                <Setter TargetName="Bg"
                        Property="Stroke"
                        Value="#8571CBF1"/>
            </Trigger>
            <Trigger Property="IsEnabled"
                     Value="false">
                <Setter Property="Foreground"
                        Value="#FF9A9A9A"/>
                <Setter TargetName="GlyphPanel"
                        Property="Background"
                        Value="#EEE9E9"/>
                <Setter TargetName="GlyphPanel"
                        Property="BorderBrush"
                        Value="#DBD6D6"/>
                <Setter TargetName="Glyph"
                        Property="Fill"
                        Value="#848589"/>
            </Trigger>
        </ControlTemplate.Triggers>
    </ControlTemplate>
    <Style x:Key="{x:Type MenuItem}"
           TargetType="{x:Type MenuItem}">
        <Setter Property="HorizontalContentAlignment"
                Value="{Binding Path=HorizontalContentAlignment,RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"/>
        <Setter Property="VerticalContentAlignment"
                Value="{Binding Path=VerticalContentAlignment,RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"/>
        <Setter Property="Background"
                Value="Transparent"/>
        <Setter Property="ScrollViewer.PanningMode"
                Value="Both"/>
        <Setter Property="Stylus.IsFlicksEnabled"
                Value="False"/>
        <Setter Property="Template"
                Value="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuItemTemplateKey}}"/>
        <Style.Triggers>
            <Trigger Property="Role"
                     Value="TopLevelHeader">
                <Setter Property="Padding"
                        Value="7,2,8,3"/>
                <Setter Property="Template"
                        Value="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelHeaderTemplateKey}}"/>
            </Trigger>
            <Trigger Property="Role"
                     Value="TopLevelItem">
                <Setter Property="Padding"
                        Value="7,2,8,3"/>
                <Setter Property="Template"
                        Value="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelItemTemplateKey}}"/>
            </Trigger>
            <Trigger Property="Role"
                     Value="SubmenuHeader">
                <Setter Property="Padding"
                        Value="2,3,2,3"/>
                <Setter Property="Template"
                        Value="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuHeaderTemplateKey}}"/>
            </Trigger>
            <Trigger Property="Role"
                     Value="SubmenuItem">
                <Setter Property="Padding"
                        Value="2,3,2,3"/>
            </Trigger>
        </Style.Triggers>
    </Style>
</ResourceDictionary>

It’s not pretty. I hope MS adds the ability to change the height in future versions as I would rather not write a separate extended control just for this.

Until next time…

There are a lot of places on the web that talk about this concept and it’s clear there are a few ways to create a drag and drop TextBox in WPF.

Here is what I recommend to accomplish this task:

Given:
Your TextBox name is “TextBox1”

C#

/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
	public MainWindow()
	{
		// Initialize UI
		InitializeComponent();

		// Loaded event
		this.Loaded += delegate
			{
				TextBox1.AllowDrop = true;
				TextBox1.PreviewDragEnter += TextBox1PreviewDragEnter;
				TextBox1.PreviewDragOver += TextBox1PreviewDragOver;
				TextBox1.Drop += TextBox1DragDrop;
			};
	}

	/// <summary>
	/// We have to override this to allow drop functionality.
	/// </summary>
	/// <param name="sender"></param>
	/// <param name="e"></param>
	void TextBox1PreviewDragOver(object sender, DragEventArgs e)
	{
		e.Handled = true;
	}

	/// <summary>
	/// Evaluates the Data and performs the DragDropEffect
	/// </summary>
	/// <param name="sender"></param>
	/// <param name="e"></param>
	private void TextBox1PreviewDragEnter(object sender, DragEventArgs e)
	{
		if (e.Data.GetDataPresent(DataFormats.FileDrop))
		{
			e.Effects = DragDropEffects.Copy;
		}
		else
		{
			e.Effects = DragDropEffects.None;
		}
	}

	/// <summary>
	/// The drop activity on the textbox.
	/// </summary>
	/// <param name="sender"></param>
	/// <param name="e"></param>
	private void TextBox1DragDrop(object sender, DragEventArgs e)
	{
		// Get data object
		var dataObject = e.Data as DataObject;

		// Check for file list
		if (dataObject.ContainsFileDropList())
		{
			// Clear values
			TextBox1.Text = string.Empty;

			// Process file names
			StringCollection fileNames = dataObject.GetFileDropList();
			StringBuilder bd = new StringBuilder();
			foreach (var fileName in fileNames)
			{
				bd.Append(fileName + "\n");
			}

			// Set text
			TextBox1.Text = bd.ToString();
		}
	}
}

Now, let’s look at each event and cover what is going on:

/// <summary>
/// We have to override this to allow drop functionality.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
void TextBox1PreviewDragOver(object sender, DragEventArgs e)
{
	e.Handled = true;
}

We need to add this to override the default PreviewDrag behavior. If we don’t, then the Drop Event will not fire.

/// <summary>
/// Evaluates the Data and performs the DragDropEffect
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void TextBox1PreviewDragEnter(object sender, DragEventArgs e)
{
	if (e.Data.GetDataPresent(DataFormats.FileDrop))
	{
		e.Effects = DragDropEffects.Copy;
	}
	else
	{
		e.Effects = DragDropEffects.None;
	}
}

This piece deals with the source being dragged over the textbox. The data is evaluated at this point, and the DragDropEffects are executed accordingly. Notice that the data is checked for the correct type.

MSDN breaks down the DragDropEffects here.

/// <summary>
/// The drop activity on the textbox.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void TextBox1DragDrop(object sender, DragEventArgs e)
{
	// Get data object
	var dataObject = e.Data as DataObject;

	// Check for file list
	if (dataObject.ContainsFileDropList())
	{
		// Clear values
		TextBox1.Text = string.Empty;

		// Process file names
		StringCollection fileNames = dataObject.GetFileDropList();
		StringBuilder bd = new StringBuilder();
		foreach (var fileName in fileNames)
		{
			bd.Append(fileName + "\n");
		}

		// Set text
		TextBox1.Text = bd.ToString();
	}
}

In the drop event, a few things occur:

  • We grab the DataObject from “e”.
  • Then evaluate the DataObject to ensure it has a StringCollection using GetFileDropList.
  • We grab the StringCollection.
  • We enumerate through the collection and append each filename to a StringBuilder object.
  • Finally, we add the StringBuilder content to the TextBox Text field.