如何使用 mvvm 在 wpf 中创建带省略号的路径修剪文本框?
How to create a path trimming textbox with ellipsis in wpf using mvvm?
我想创建一个包含 directory/file 路径的文本框。如果目录路径太长,文本应该显示为用省略号修剪,我希望省略号出现在路径字符串的中间,例如,D:\Directory1\Directory2\Directory3
可以被修剪为 D:\...\Directory3
。
路径本身应该绑定到 ViewModel
以便它可以在 MVVM
模型中使用。
我最近遇到了这个问题,所以我决定在这里分享我的解决方案。
首先受到这个线程 How to create a file path Trimming TextBlock with Ellipsis 的启发,我决定创建我的自定义 TextBlock
,它将 trim 其带有省略号的文本,这个实现,我写了注释以便代码清晰:
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
}
}
现在,在我们创建了 texblock 之后,我们必须以某种方式 "wire" 它到 XAML
中的 TextBox
,这可以使用 ControlTemplate
来完成。这是完整的 XAML
代码,我又写了评论,所以应该很容易理解:
<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>
现在最后剩下的就是编写我们的 ViewModel
来负责向 View
提供数据。这里我使用 MVVM Light
库来简化代码,但这并不重要,使用任何其他方法都应该可以正常工作。
这是带有注释的代码,无论如何应该很容易解释:
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];
}
}
}
我想创建一个包含 directory/file 路径的文本框。如果目录路径太长,文本应该显示为用省略号修剪,我希望省略号出现在路径字符串的中间,例如,D:\Directory1\Directory2\Directory3
可以被修剪为 D:\...\Directory3
。
路径本身应该绑定到 ViewModel
以便它可以在 MVVM
模型中使用。
我最近遇到了这个问题,所以我决定在这里分享我的解决方案。
首先受到这个线程 How to create a file path Trimming TextBlock with Ellipsis 的启发,我决定创建我的自定义 TextBlock
,它将 trim 其带有省略号的文本,这个实现,我写了注释以便代码清晰:
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
}
}
现在,在我们创建了 texblock 之后,我们必须以某种方式 "wire" 它到 XAML
中的 TextBox
,这可以使用 ControlTemplate
来完成。这是完整的 XAML
代码,我又写了评论,所以应该很容易理解:
<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>
现在最后剩下的就是编写我们的 ViewModel
来负责向 View
提供数据。这里我使用 MVVM Light
库来简化代码,但这并不重要,使用任何其他方法都应该可以正常工作。
这是带有注释的代码,无论如何应该很容易解释:
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];
}
}
}