Search code examples
wpfmvvmtrim

How to create a path trimming textbox with ellipsis in wpf using mvvm?


I want to create a texbox that will have a directory/file path inside it. If directory path is too long the text should appear to be trimmed with ellipsis, I would like ellipsis to appear in the middle of the path string, for example, D:\Directory1\Directory2\Directory3 can be trimmed as D:\...\Directory3. The path itself should be bound to a ViewModel so it can be used in MVVM model.


Solution

  • I've encountered this issue recently, so I decided to share my solution to it here. First of all inspired by this thread How to create a file path Trimming TextBlock with Ellipsis I decided to create my custom TextBlock that will trim its text with ellipsis, this the implementation, I wrote comments so that the code is clear:

    using System.ComponentModel;
    using System.Globalization;
    using System.Linq;
    using System.Threading.Tasks;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Media;
    
    namespace PathTrimming.Controls
    {
        public class PathTrimmingTextBlock : TextBlock, INotifyPropertyChanged
        {
            #region Dependency properties
            //This property represents the Text of this textblock that can be bound to another viewmodel property, 
            //whenever this property is updated the Text property will be updated too.
            //We cannot bind to Text property directly because once we update Text, e.g., Text = "NewValue", the binding will be broken
            public string BoundedText
            {
                get { return GetValue(BoundedTextProperty).ToString(); }
                set { SetValue(BoundedTextProperty, value); }
            }
    
            public static readonly DependencyProperty BoundedTextProperty = DependencyProperty.Register(
                nameof(BoundedText), typeof(string), typeof(PathTrimmingTextBlock),
                new PropertyMetadata(string.Empty, new PropertyChangedCallback(BoundedTextProperty_Changed)));
    
            //Every time the property BoundedText is updated two things should be done:
            //1) Text should be updated to be equal to new BoundedText
            //2) New path should be trimmed again
            private static void BoundedTextProperty_Changed(object sender, DependencyPropertyChangedEventArgs e)
            {
                var pathTrimmingTextBlock = (PathTrimmingTextBlock)sender;
                pathTrimmingTextBlock.OnPropertyChanged(nameof(BoundedText));
                pathTrimmingTextBlock.Text = pathTrimmingTextBlock.BoundedText;
                pathTrimmingTextBlock.TrimPathAsync();
            }
            #endregion
    
            private const string Ellipsis = "...";
    
    
            public PathTrimmingTextBlock()
            {
                // This will make sure if the directory name is too long it will be trimmed with ellipsis on the right side
                TextTrimming = TextTrimming.CharacterEllipsis;
    
                //setting the event handler for every time this PathTrimmingTextBlock is rendered
                Loaded += new RoutedEventHandler(PathTrimmingTextBox_Loaded);
            }
    
            private void PathTrimmingTextBox_Loaded(object sender, RoutedEventArgs e)
            {
                //asynchronously update Text, so that the window won't be frozen
                TrimPathAsync();
            }
    
            private void TrimPathAsync()
            {
                Task.Run(() => Dispatcher.Invoke(() => TrimPath()));
            }
    
            private void TrimPath()
            {
                var isWidthOk = false; //represents if the width of the Text is short enough and should not be trimmed 
                var widthChanged = false; //represents if the width of Text was changed, if the text is short enough at the begging it should not be trimmed
                var wasTrimmed = false; //represents if Text was trimmed at least one time
    
                //in this loop we will be checking the current width of textblock using FormattedText at every iteration,
                //if the width is not short enough to fit textblock it will be shrinked by one character, and so on untill it fits
                do
                {
                    //widthChanged? Text + Ellipsis : Text - at first iteration we have to check if Text is not already short enough to fit textblock,
                    //after widthChanged = true, we will have to measure the width of Text + Ellipsis, because ellipsis will be added to Text
                    var formattedText = new FormattedText(widthChanged ? Text + Ellipsis : Text,
                        CultureInfo.CurrentCulture,
                        FlowDirection.LeftToRight,
                        new Typeface(FontFamily, FontStyle, FontWeight, FontStretch),
                        FontSize,
                        Foreground);
    
                    //check if width fits textblock RenderSize.Width, (cannot use Width here because it's not set during rendering,
                    //and cannot use ActualWidth either because it is the initial width of Text not textblock itself)
                    isWidthOk = formattedText.Width < RenderSize.Width;
    
                    //if it doesn't fit trim it by one character
                    if (!isWidthOk)
                    {
                        wasTrimmed = TrimPathByOneChar();
                        widthChanged = true;
                    }
                    //continue loop
                } while (!isWidthOk && wasTrimmed);
    
                //Format Text with ellipsis, if width was changed (after previous loop we may have gotten a path like this "D:\Dire\Directory" 
                //it should be formatted to "D:\...\Directory")
                if (widthChanged)
                {
                    FormatWithEllipsis();
                }
            }
    
            //Trim Text by one character before last slash, if Text doesn't have slashes it won't be trimmed with ellipsis in the middle,
            //instead it will be trimmed with ellipsis at the end due to having TextTrimming = TextTrimming.CharacterEllipsis; in the constructor
            private bool TrimPathByOneChar()
            {
                var lastSlashIndex = Text.LastIndexOf('\\');
                if (lastSlashIndex > 0)
                {
                    Text = Text.Substring(0, lastSlashIndex - 1) + Text.Substring(lastSlashIndex);
                    return true;
                }
                return false;
            }
    
            //"\Directory will become "...\Directory"
            //"Dire\Directory will become "...\Directory"\
            //"D:\Dire\Directory" will become "D:\...\Directory"
            private void FormatWithEllipsis()
            {
                var lastSlashIndex = Text.LastIndexOf('\\');
                if (lastSlashIndex == 0)
                {
                    Text = Ellipsis + Text;
                }
                else if (lastSlashIndex > 0)
                {
                    var secondastSlashIndex = Text.LastIndexOf('\\', lastSlashIndex - 1);
                    if (secondastSlashIndex < 0)
                    {
                        Text = Ellipsis + Text.Substring(lastSlashIndex);
                    }
                    else
                    {
                        Text = Text.Substring(0, secondastSlashIndex + 1) + Ellipsis + Text.Substring(lastSlashIndex);
                    }
                }
            }
    
            //starndard implementation of INotifyPropertyChanged to be able to notify BoundedText property change 
            #region INotifyPropertyChanged
            public event PropertyChangedEventHandler PropertyChanged;
    
            public void OnPropertyChanged(string propertyName)
            {
                if (PropertyChanged != null)
                {
                    PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
                }
            }
            #endregion
        }
    }
    

    Now after having the texblock we created we have to somehow "wire" it to TextBox in XAML, it can be done using ControlTemplate. This is the full XAML code, again I wrote comments so it should be easy to follow along:

    <Window x:Class="PathTrimming.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
            xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
            xmlns:viewmodel = "clr-namespace:PathTrimming.ViewModel"
            xmlns:controls="clr-namespace:PathTrimming.Controls"
            mc:Ignorable="d"
            Title="MainWindow" Height="350" Width="525">
        <!-- Assigning datacontext to the window -->
        <Window.DataContext>
            <viewmodel:MainViewModel/>
        </Window.DataContext>
    
    
        <Window.Resources>
            <ResourceDictionary>
                <!--This is the most important part, if TextBox is not in focused,
                it will be rendered as PathTrimmingTextBlock,
                if it is focused it shouldn't be trimmed and will be rendered as default textbox.
                To achieve this I'm using DataTrigger and ControlTemplate-->
                <Style x:Key="TextBoxDefaultStyle" TargetType="{x:Type TextBox}">
                    <Style.Triggers>
                        <DataTrigger Binding="{Binding IsKeyboardFocused, RelativeSource={RelativeSource Self}}" Value="False">
                            <Setter Property="Template">
                                <Setter.Value>
                                    <ControlTemplate TargetType="TextBox">
                                        <Border
                                            BorderThickness="1"
                                            BorderBrush="#000">
                                            <controls:PathTrimmingTextBlock BoundedText="{TemplateBinding Text}"/>
                                        </Border>
                                    </ControlTemplate>
                                </Setter.Value>
                            </Setter>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </ResourceDictionary>
        </Window.Resources>
    
        <!--Grid with two textboxes and button that updates the textboxes with new pathes from a random path pool-->
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <TextBox Grid.Row="0" Grid.Column="0" Width="100" Text="{Binding Path1}" Style="{StaticResource TextBoxDefaultStyle}"/>
            <TextBox Grid.Row="1" Grid.Column="0" Width="100" Text="{Binding Path2}" Style="{StaticResource TextBoxDefaultStyle}"/>
            <Button Grid.Row="2" Content="Update pathes" Command="{Binding UpdatePathesCmd}"/>
        </Grid>
    </Window>
    

    Now finally what is left, is to write our ViewModel that is responsible to feed data to the View. Here I utilized MVVM Light library to simplify the code, but this is not important, using any other approach should work fine. This is the code with comments, should be pretty self explanatory anyway:

    using GalaSoft.MvvmLight;
    using GalaSoft.MvvmLight.Command;
    using System;
    using System.Windows.Input;
    
    namespace PathTrimming.ViewModel
    {
        public class MainViewModel : ViewModelBase
        {
            public string Path1
            {
                get { return _path1; }
                set
                {
                    _path1 = value;
                    RaisePropertyChanged();
                }
            }
    
            public string Path2
            {
                get { return _path2; }
                set
                {
                    _path2 = value;
                    RaisePropertyChanged();
                }
            }
    
            private string _path1;
            private string _path2;
    
            public MainViewModel()
            {
                UpdatePathes();
            }
    
            //The command that will update Path1 and Path2 with some random path values
            public ICommand UpdatePathesCmd
            {
                get { return new RelayCommand(UpdatePathes); }
            }
    
            private void UpdatePathes()
            {
                Path1 = PathProvider.GetPath();
                Path2 = PathProvider.GetPath();
            }
        }
    
        //A simple static class to provide a pool of different pathes
        public static class PathProvider
        {
            private static Random randIndexGenerator = new Random();
            private static readonly string[] pathes =
            {
                "D:\\Directory1\\Directory2\\Directory3",
                "D:\\Directory1\\Directory2",
                "Directory1\\Directory2\\Directory3",
                "D:\\Directory1\\Directory12345678901234567890",
                "Directory1234567890123456789012345678901234567890",
                "D:\\Directory1"
            };
    
            public static string GetPath()
            {
                var randIndex = randIndexGenerator.Next(pathes.Length);
                return pathes[randIndex];
            }
        }
    }