TextBlock с подсветкой текста (WPF)

от автора

Привет Хабр! Я создал контрол на основе 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));     } 

Заключение

Ну вот пожалуй и все. Хотелось бы услышать ваше мнение.
ссылка на оригинал статьи https://habrahabr.ru/post/314060/