Привет! Я создал контрол на основе TextBlock с возможностью подсветки текста. Для начала приведу пример его использования, затем опишу, как он создавался.
<local:HighlightTextBlock TextWrapping="Wrap">
<local:HighlightTextBlock.HighlightRules>
<local:HighlightRule HightlightedText="{Binding Filter, Source={x:Reference thisWindow}}">
<local:HighlightRule.Highlights>
<local:HighlightBackgroung Brush="Yellow"/>
<local:HighlightForeground Brush="Black"/>
</local:HighlightRule.Highlights>
</local:HighlightRule>
</local:HighlightTextBlock.HighlightRules>
<Run FontWeight="Bold">Property:</Run>
<Run Text="{Binding Property}"/>
</local:HighlightTextBlock>
Начало разработки
Потребовалось мне подсветить текст в TextBlock, введенный в строку поиска. На первый взгляд задача показалась простой. Пришло в голову разделить текст на 3 элемента Run, которые бы передавали в конвертер весь текст, строку поиска и свое положение (1/2/3). Средний Run имеет Backgroung.
Не успел я приступить к реализации, как пришла в голову мысль, что совпадений может быть несколько. А значит такой подход не подходит.
Была еще мысль формировать Xaml «на лету», парсить его при помощи XamlReader и кидать в TextBlock. Но эта мысль тоже сразу отвалилась, потому что попахивает.
Следующей (и окончательной) идеей стало создать систему правил подсветки и прикрутить ее к TextBlock. Тут 2 варианта: свой контрол с блэкджеком и девочками на основе TextBlock или AttachedProperty. После недолгих раздумий, я решил, что все таки лучше создать отдельный контрол, потому что функционал подсветки может наложить некоторые ограничения на функциональность самого TextBlock, а разруливать это проще, если от него унаследоваться.
Исходники готового контрола
Итак, приступим. Сразу предупрежу, что контрол я делал в том же проекте, где собирался тестировать первую идею, поэтому не обращайте внимание на неймспейсы. До ума такие вещи я доведу уже, когда буду включать контрол в основной проект (или буду выкладывать на гитхаб).
В Xaml разметке контрола все чисто, за исключением обработчика события Loaded
<TextBlock x:Class="WpfApplication18.HighlightTextBlock"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Loaded="TextBlock_Loaded">
</TextBlock>
Переходим к коду:
public partial class HighlightTextBlock : TextBlock
{
// Здесь сохраняется сериализованное оригинальное наполнение TextBlock
// (подсветка накладывается на оригинал и потом уже подставляется в TextBlock)
string _content;
// Это словарь для правил подсветки и соответствующих им очередей задач
Dictionary<HighlightRule, TaskQueue> _ruleTasks;
/// <summary>
/// Коллекция правил подсветки
/// </summary>
public HighlightRulesCollection HighlightRules
{
get
{
return (HighlightRulesCollection)GetValue(HighlightRulesProperty);
}
set
{
SetValue(HighlightRulesProperty, value);
}
}
public static readonly DependencyProperty HighlightRulesProperty =
DependencyProperty.Register("HighlightRules", typeof(HighlightRulesCollection), typeof(HighlightTextBlock), new FrameworkPropertyMetadata(null) { PropertyChangedCallback = HighlightRulesChanged });
static void HighlightRulesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
var col = e.NewValue as HighlightRulesCollection;
var tb = sender as HighlightTextBlock;
if (col != null && tb != null)
{
col.CollectionChanged += tb.HighlightRules_CollectionChanged;
foreach (var rule in col)
{
rule.HighlightTextChanged += tb.Rule_HighlightTextChanged;
}
}
}
public HighlightTextBlock()
{
_ruleTasks = new Dictionary<HighlightRule, TaskQueue>();
HighlightRules = new HighlightRulesCollection();
InitializeComponent();
}
// Обработчик события на изменение коллекции правил подсветки
void HighlightRules_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
foreach (HighlightRule rule in e.NewItems)
{
_ruleTasks.Add(rule, new TaskQueue(1));
SubscribeRuleNotifies(rule);
BeginHighlight(rule);
}
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
foreach (HighlightRule rule in e.OldItems)
{
rule.HightlightedText = string.Empty;
_ruleTasks.Remove(rule);
UnsubscribeRuleNotifies(rule);
}
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
foreach (HighlightRule rule in e.OldItems)
{
rule.HightlightedText = string.Empty;
_ruleTasks.Remove(rule);
UnsubscribeRuleNotifies(rule);
}
break;
}
}
// Подписка на события правила подсветки
void SubscribeRuleNotifies(HighlightRule rule)
{
rule.HighlightTextChanged += Rule_HighlightTextChanged;
}
// Отписка от событий правила подсветки
void UnsubscribeRuleNotifies(HighlightRule rule)
{
rule.HighlightTextChanged -= Rule_HighlightTextChanged;
}
// Обработчик события, которое срабатывает, когда текст для подсветки изменился
void Rule_HighlightTextChanged(object sender, HighlightTextChangedEventArgs e)
{
BeginHighlight((HighlightRule)sender);
}
// Здесь запускается механизм подсвечивания в созданном мною диспетчере задач.
// Смысл в том, что если текст вводится/стирается слишком быстро,
// предыдущая подсветка не успеет закончить работу, поэтому новая подсветка
// добавляется в очередь. Если в очереди уже что то есть, то это удаляется из очереди
// и вставляется новая задача. Для каждого правила очередь своя.
void BeginHighlight(HighlightRule rule)
{
_ruleTasks[rule].Add(new Action(() => Highlight(rule)));
}
// Механизм подсветки
void Highlight(HighlightRule rule)
{
// Если передали не существующее правило, покидаем процедуру
if (rule == null)
return;
// Так как правила у нас задаются в Xaml коде, они будут принадлежать основному потоку, в котором крутится форма,
// поэтому некоторые свойства можно достать/положить только таким образом
ObservableCollection<Highlight> highlights = null;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
highlights = rule.Highlights;
}));
// Даже если существует правило, но в нем не задано, чем подсвечивать, покидаем процедуру подсветки
if (highlights.Count == 0)
return;
// Еще ряд условий для выхода из процедуры подсветки
var exitFlag = false;
exitFlag = exitFlag || string.IsNullOrWhiteSpace(_content);
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
exitFlag = exitFlag || Inlines.IsReadOnly || Inlines.Count == 0 ||
HighlightRules == null || HighlightRules.Count == 0;
}));
if (exitFlag)
return;
// Создадим параграф. Все манипуляции будем проводить внутри него, потому что выделить что либо
// непосредственно в TextBlock нельзя, если это выделение затрагивает несколько элементов
var par = new Paragraph();
// Парсим _content, в котором у нас сериализованный Span с оригинальным содержимым TextBlock'a.
var parsedSp = (Span)XamlReader.Parse(_content);
// Сам Span нам не нужен, поэтому сливаем все его содержимое в параграф
par.Inlines.AddRange(parsedSp.Inlines.ToArray());
// Обозначаем стартовую позицию (просто для удобства) и выдергиваем из TextBlock'a голый текст.
// Искать вхождения искомой строки будем именно в нем
var firstPos = par.ContentStart;
var curText = string.Empty;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
curText = Text;
}));
// Выдергиваем из основного потока текст для подсветки
var hlText = string.Empty;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
hlText = rule.HightlightedText;
}));
// Если текст для подсветки не пустой и его длина не превышает длину текста, в котором ищем,
// то продолжим, иначе просто выведем в конце оригинал
if (!string.IsNullOrEmpty(hlText) && hlText.Length <= curText.Length)
{
// Выдергиваем в основном потоке из правила свойство IgnoreCase.
// Решил логику оставиьт в основном потоке, потому что нагрузка операции очень низкая
// и не стоит моего пота :)
var comparison = StringComparison.CurrentCulture;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
comparison = rule.IgnoreCase ? StringComparison.CurrentCultureIgnoreCase : StringComparison.CurrentCulture;
}));
// Формируем список индексов, откуда начинаются вхождения искомой строки в тексте
var indexes = new List<int>();
var ind = curText.IndexOf(hlText, comparison);
while (ind > -1)
{
indexes.Add(ind);
ind = curText.IndexOf(hlText, ind + hlText.Length, StringComparison.CurrentCultureIgnoreCase);
}
TextPointer lastEndPosition = null;
// Проходим по всем индексам начала вхождения строки поиска в текст
foreach (var index in indexes)
{
// Эта переменная нужна была в моих соисканиях наилучшего места для начала поиска,
// ведь индекс положения в string не соответствует реальному положению TextPointer'a.
// Поиск продолжается, поэтому переменную я оставил.
var curIndex = index;
// Начинаем поиск с последней найденной позиции либо перемещаем TextPointer вперед
// на значение, равное индексу вхождения подстроки в текст
var pstart = lastEndPosition ?? firstPos.GetInsertionPosition(LogicalDirection.Forward).GetPositionAtOffset(curIndex);
// startInd является длиной текста между начальным TextPointer и текущей точкой начала подсветки
var startInd = new TextRange(pstart, firstPos.GetInsertionPosition(LogicalDirection.Forward)).Text.Length;
// В результате нам нужно, чтобы startInd был равен curIndex
while (startInd != curIndex)
{
// Если честно, мне неще не встречались случаи, когда я обгонял startInd обгонял curIndex, однако
// решил оставить продвижение назад на случай более оптимизированного алгоритма поиска
if (startInd < curIndex)
{
// Смещаем точку начала подсветки на разницу curIndex - startInd
var newpstart = pstart.GetPositionAtOffset(curIndex - startInd);
// Иногда TextPointer оказывается между r и n, в этом случае начало подсветки
// сдвигается вперед. Чтобы этого избежать, двигаем его в следующую позицию для вставки
if (newpstart.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.ElementEnd)
newpstart = newpstart.GetInsertionPosition(LogicalDirection.Forward);
var len = new TextRange(pstart, newpstart).Text.Length;
startInd += len;
pstart = newpstart;
}
else
{
var newpstart = pstart.GetPositionAtOffset(curIndex - startInd);
var len = new TextRange(pstart, newpstart).Text.Length;
startInd -= len;
pstart = newpstart;
}
}
// Ищем конечную точку подсветки аналогичным способом, как для начальной
var pend = pstart.GetPositionAtOffset(hlText.Length);
var delta = new TextRange(pstart, pend).Text.Length;
while (delta != hlText.Length)
{
if (delta < hlText.Length)
{
var newpend = pend.GetPositionAtOffset(hlText.Length - delta);
var len = new TextRange(pend, newpend).Text.Length;
delta += len;
pend = newpend;
}
else
{
var newpend = pend.GetPositionAtOffset(hlText.Length - delta);
var len = new TextRange(pend, newpend).Text.Length;
delta -= len;
pend = newpend;
}
}
// К сожалению, предложенным способом не получается разделить Hyperlink.
// Скорее всего это придется делать вручную, но пока такой необходимости нет,
// поэтому, если начальной или конечной частью подсветки мы режем гиперссылку,
// то просто сдвигаем эти позиции. В общем ссылка либо полностью попадает в подсветку,
// либо не попадает совсем
var sHyp = (pstart?.Parent as Inline)?.Parent as Hyperlink;
var eHyp = (pend?.Parent as Inline)?.Parent as Hyperlink;
if (sHyp != null)
pstart = pstart.GetNextContextPosition(LogicalDirection.Forward);
if (eHyp != null)
pend = pend.GetNextContextPosition(LogicalDirection.Backward);
// Ну а тут применяем к выделению подсветки.
if (pstart.GetOffsetToPosition(pend) > 0)
{
var sp = new Span(pstart, pend);
foreach (var hl in highlights)
hl.SetHighlight(sp);
}
lastEndPosition = pend;
}
}
// Здесь сериализуем получившийся параграф и в основном потоке помещаем его содержимое в TextBlock
var parStr = XamlWriter.Save(par);
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
Inlines.Clear();
Inlines.AddRange(((Paragraph)XamlReader.Parse(parStr)).Inlines.ToArray());
})).Wait();
}
void TextBlock_Loaded(object sender, RoutedEventArgs e)
{
// Здесь дергаем наполнение TextBlock'a и сериализуем его в строку,
// чтобы накатывать подсветку всегда на оригинал.
// Это лучше вынести в отдельный поток, но пока и так сойдет.
var sp = new Span();
sp.Inlines.AddRange(Inlines.ToArray());
var tr = new TextRange(sp.ContentStart, sp.ContentEnd);
using (var stream = new MemoryStream())
{
tr.Save(stream, DataFormats.Xaml);
stream.Position = 0;
using(var reader = new StreamReader(stream))
{
_content = reader.ReadToEnd();
}
}
Inlines.AddRange(sp.Inlines.ToArray());
// Запускаем подсветку для всех правил
foreach (var rule in HighlightRules)
BeginHighlight(rule);
}
}
Я не буду здесь описывать код, потому что комментарии, на мой взгляд, избыточны.
Вот код очереди задач:
public class TaskQueue
{
Task _worker;
Queue<Action> _queue;
int _maxTasks;
bool _deleteOld;
object _lock = new object();
public TaskQueue(int maxTasks, bool deleteOld = true)
{
if (maxTasks < 1)
throw new ArgumentException("TaskQueue: максимальное число задач должно быть больше 0");
_maxTasks = maxTasks;
_deleteOld = deleteOld;
_queue = new Queue<Action>(maxTasks);
}
public bool Add(Action action)
{
if (_queue.Count() < _maxTasks)
{
_queue.Enqueue(action);
DoWorkAsync();
return true;
}
if (_deleteOld)
{
_queue.Dequeue();
return Add(action);
}
return false;
}
void DoWorkAsync()
{
if(_queue.Count>0)
_worker = Task.Factory.StartNew(DoWork);
}
void DoWork()
{
lock (_lock)
{
if (_queue.Count > 0)
{
var currentTask = Task.Factory.StartNew(_queue.Dequeue());
currentTask.Wait();
DoWorkAsync();
}
}
}
}
Здесь все довольно просто. Поступает новая задача. Если в очереди есть место, то она помещается в очередь. Иначе, если поле _deleteOld == true, то удаляем следующую задачу (наиболее позднюю) и помещаем новую, иначе возвращаем false (задача не добавлена).
Вот код коллекции правил. По идее, можно было обойтись ObservableCollection, но от этой коллекции в дальнейшем может потребоваться дополнительный функционал.
public class HighlightRulesCollection : DependencyObject, INotifyCollectionChanged, ICollectionViewFactory, IList, IList<HighlightRule>
{
ObservableCollection<HighlightRule> _items;
public HighlightRulesCollection()
{
_items = new ObservableCollection<HighlightRule>();
_items.CollectionChanged += _items_CollectionChanged;
}
public HighlightRule this[int index]
{
get
{
return ((IList<HighlightRule>)_items)[index];
}
set
{
((IList<HighlightRule>)_items)[index] = value;
}
}
object IList.this[int index]
{
get
{
return ((IList)_items)[index];
}
set
{
((IList)_items)[index] = value;
}
}
public int Count
{
get
{
return ((IList<HighlightRule>)_items).Count;
}
}
public bool IsFixedSize
{
get
{
return ((IList)_items).IsFixedSize;
}
}
public bool IsReadOnly
{
get
{
return ((IList<HighlightRule>)_items).IsReadOnly;
}
}
public bool IsSynchronized
{
get
{
return ((IList)_items).IsSynchronized;
}
}
public object SyncRoot
{
get
{
return ((IList)_items).SyncRoot;
}
}
public event NotifyCollectionChangedEventHandler CollectionChanged;
public int Add(object value)
{
return ((IList)_items).Add(value);
}
public void Add(HighlightRule item)
{
((IList<HighlightRule>)_items).Add(item);
}
public void Clear()
{
((IList<HighlightRule>)_items).Clear();
}
public bool Contains(object value)
{
return ((IList)_items).Contains(value);
}
public bool Contains(HighlightRule item)
{
return ((IList<HighlightRule>)_items).Contains(item);
}
public void CopyTo(Array array, int index)
{
((IList)_items).CopyTo(array, index);
}
public void CopyTo(HighlightRule[] array, int arrayIndex)
{
((IList<HighlightRule>)_items).CopyTo(array, arrayIndex);
}
public ICollectionView CreateView()
{
return new CollectionView(_items);
}
public IEnumerator<HighlightRule> GetEnumerator()
{
return ((IList<HighlightRule>)_items).GetEnumerator();
}
public int IndexOf(object value)
{
return ((IList)_items).IndexOf(value);
}
public int IndexOf(HighlightRule item)
{
return ((IList<HighlightRule>)_items).IndexOf(item);
}
public void Insert(int index, object value)
{
((IList)_items).Insert(index, value);
}
public void Insert(int index, HighlightRule item)
{
((IList<HighlightRule>)_items).Insert(index, item);
}
public void Remove(object value)
{
((IList)_items).Remove(value);
}
public bool Remove(HighlightRule item)
{
return ((IList<HighlightRule>)_items).Remove(item);
}
public void RemoveAt(int index)
{
((IList<HighlightRule>)_items).RemoveAt(index);
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IList<HighlightRule>)_items).GetEnumerator();
}
void _items_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
CollectionChanged?.Invoke(this, e);
}
}
Вот код правила подсветки:
public class HighlightRule : DependencyObject
{
public delegate void HighlightTextChangedEventHandler(object sender, HighlightTextChangedEventArgs e);
public event HighlightTextChangedEventHandler HighlightTextChanged;
public HighlightRule()
{
Highlights = new ObservableCollection<Highlight>();
}
/// <summary>
/// Текст, который нужно подсветить
/// </summary>
public string HightlightedText
{
get { return (string)GetValue(HightlightedTextProperty); }
set { SetValue(HightlightedTextProperty, value); }
}
public static readonly DependencyProperty HightlightedTextProperty =
DependencyProperty.Register("HightlightedText", typeof(string), typeof(HighlightRule), new FrameworkPropertyMetadata(string.Empty, HighlightPropertyChanged));
public static void HighlightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var me = d as HighlightRule;
if (me != null)
me.HighlightTextChanged?.Invoke(me, new HighlightTextChangedEventArgs((string)e.OldValue, (string)e.NewValue));
}
/// <summary>
/// Игнорировать регистр?
/// </summary>
public bool IgnoreCase
{
get { return (bool)GetValue(IgnoreCaseProperty); }
set { SetValue(IgnoreCaseProperty, value); }
}
public static readonly DependencyProperty IgnoreCaseProperty =
DependencyProperty.Register("IgnoreCase", typeof(bool), typeof(HighlightRule), new PropertyMetadata(true));
/// <summary>
/// Коллекция подсветок
/// </summary>
public ObservableCollection<Highlight> Highlights
{
get
{
return (ObservableCollection<Highlight>)GetValue(HighlightsProperty);
}
set { SetValue(HighlightsProperty, value); }
}
public static readonly DependencyProperty HighlightsProperty =
DependencyProperty.Register("Highlights", typeof(ObservableCollection<Highlight>), typeof(HighlightRule), new PropertyMetadata(null));
}
public class HighlightTextChangedEventArgs : EventArgs
{
public string OldText { get; }
public string NewText { get; }
public HighlightTextChangedEventArgs(string oldText,string newText)
{
OldText = oldText;
NewText = newText;
}
}
Никакой логики тут нет почти, поэтому без комментариев.
Вот абстрактный класс для подсветки:
public abstract class Highlight : DependencyObject
{
public abstract void SetHighlight(Span span);
public abstract void SetHighlight(TextRange range);
}
Мне на данный момент известно два способа подсветить фрагмент. Через Span и через TextRange. Пока что выбранный способ железно прописан в коде в процедуре подсветки, но в дальнейшем я планирую сделать это опционально.
public class HighlightBackgroung : Highlight
{
public override void SetHighlight(Span span)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
span.Background = brush;
}
public override void SetHighlight(TextRange range)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
range.ApplyPropertyValue(TextElement.BackgroundProperty, brush);
}
/// <summary>
/// Кисть для подсветки фона
/// </summary>
public Brush Brush
{
get
{
return (Brush)GetValue(BrushProperty);
}
set { SetValue(BrushProperty, value); }
}
public static readonly DependencyProperty BrushProperty =
DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightBackgroung), new PropertyMetadata(Brushes.Transparent));
}
Ну тут нечего комментировать, кроме безопасности потоков. Дело в том, что экземпляр должен крутиться в основном потоке, а метод может быть вызван откуда угодно.
public class HighlightForeground : Highlight
{
public override void SetHighlight(Span span)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
span.Foreground = brush;
}
public override void SetHighlight(TextRange range)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
range.ApplyPropertyValue(TextElement.ForegroundProperty, brush);
}
/// <summary>
/// Кисть для цвета текста
/// </summary>
public Brush Brush
{
get { return (Brush)GetValue(BrushProperty); }
set { SetValue(BrushProperty, value); }
}
public static readonly DependencyProperty BrushProperty =
DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightForeground), new PropertyMetadata(Brushes.Black));
}
Заключение
Ну вот пожалуй и все. Хотелось бы услышать ваше мнение.
Автор: MoreBeauty