Введение


Признаем все, что «DotNetFramework» — гениальное изобретение Microsoft, которое предоставляет внушительный набор готовых компонентов и позволяет строить ПО по принципу «LEGO». Но не всегда их достаточно, особенно для специфических задач, требующих либо «особенного быстродействия», либо «особенного способа взаимодействия»… И Microsoft даёт возможность создавать свои компоненты. Итак, хочу поделиться опытом создания собственного ListView-компонента (будем называть так вид компонентов, которые выводят для просмотра список каких-либо объектов) — «по-быстрому» (в условиях, когда надо было ещё вчера).

Постановка задачи


Необходим компонент для просмотра списка однородных и неоднородных (т.е. в списке будут содержаться экземпляры объектов разных типов (в том числе и пользовательских)) элементов (более 10000 элементов).
Должны быть возможности «раскрашивания» вида отображения этих элементов.
Ну и обязательно по клику по элементу должно происходить событие, которое будет давать нам ссылку на экземпляр и его индекс в списке объектов.

Предварительный результат


Забегая вперёд, посмотрим, что получилось:


Рисунок 1 – Шаблон с разными размерами отображения


Рисунок 2 – Шаблон с одинаковыми размерами отображения


Рисунок 3 – Клик по элементу

Итак, рассмотрим подход к проектированию структуры компонента и углубимся в код.

«Мат.часть» компонента


В компоненте содержится список из object-ов, который является набором исходных данных.
Делегат на функцию конвертации из пользовательского типа в шаблон для отображения (класс BlockTamplateBase).
Шаблон отображения (класс BlockTamplateBase) содержит информацию об области отображения и функцию, описывающую алгоритм рисования.

Разберём поля ListBlockView – Пример 1.

Пример 1
        /// <summary>
        /// Список объектов, которые являются исходными данными (реализуем требование "неоднородности элементов")
        /// </summary>
        public List<object> SourceData { get; set; }
 
        protected Brush colorChoosenBlockShadow;
        /// <summary>
        /// Цвет затенения выбранного элемента (по которому "кликнули")
        /// </summary>
        public Brush ColorChoosenBlockShadow { get { return colorChoosenBlockShadow; } set { colorChoosenBlockShadow = value; this.InvalidateVisual(); } }
 
        public delegate BlockTemplateBase ConvertObjectToBlockTemplateDelegate(object item, int index);
        /// <summary>
        /// Указатель на функцию, в которой будет описан пользовательский алгоритм для отображения (рисвоания) данных,
        /// т.е. управление "раскраской" вида элемента на основе их значений
        /// (требование о "раскраске" элементов)
        /// (Далее будет подробнее о BlockTemplateBase)
        /// </summary>
        public ConvertObjectToBlockTemplateDelegate ConvertToBlockTemplate { get; set; }
        
        /// <summary>
        /// Список областей (прямоугольников), в которых отрисованы видымые элементы списка SourceData,
        /// начиная с индекса IndexCurrentFirstVisibleBlock
        /// (необходимо для реализации клика по элементу)
        /// </summary>
        protected List<Rect> CurrentVisibleListBlockRect { get; set; }
        /// <summary>
        /// Индекс первого видимого элемента (необходимо для реализации вертикального скроллнига)
        /// </summary>
        protected int IndexCurrentFirstVisibleBlock { get; set; }
        /// <summary>
        /// Индекс текущего выбранного элемента (который будет "затеняться")
        /// </summary>
        public int IndexCurrentChoosenBlock { get; protected set; }


Реализация события «Клика по элементу» — Пример 2.
Можно сделать обычное событие, а можно – маршрутизируемое. Для моей задачи и обычного хватает.

Пример 2
        /* Маршрутизируемое событие «Клик по элементу»
        public class ClickItemRoutedEventArgs : RoutedEventArgs
        {
            public int Index { get; protected set; }
            public object Item { get; protected set; }
            public ClickItemRoutedEventArgs(RoutedEvent routedEvent, object item, int index)
                : base(routedEvent)
            {
                Item = item;
                Index = index;
            }
            public ClickItemRoutedEventArgs()
                : base()
            {
                Item = null;
                Index = -1;
            }
 
        }
        public static readonly RoutedEvent ClickItemEvent = EventManager.RegisterRoutedEvent(“ClickItem”, RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ListBlockView));
        public event RoutedEventHandler ClickItem
        {
            add { base.AddHandler(ClickItemEvent, value); }
            remove { base.RemoveHandler(ClickItemEvent, value); }
        }
        void RaiseClickItem(object item, int index)
        {
            ClickItemRoutedEventArgs args = new ClickItemRoutedEventArgs(ClickItemEvent, item, index);
            RaiseEvent(args);
        }
         * */
 
        public class ClickDataItemEventArgs : EventArgs
        {
            public object Item { get; protected set; }
            public int Index { get; protected set; }
            public ClickDataItemEventArgs() : base() { Item = null; Index = -1; }
            public ClickDataItemEventArgs(object item, int index) : base() { Item = item; Index = index; }
        }
        /// <summary>
        /// Событие клика по элементу
        /// </summary>
        public event EventHandler<ClickDataItemEventArgs> ClickItem;
        protected void ClickItemRaiseEvent(object item, int index)
        {
            if (ClickItem != null)
                ClickItem(this, new ClickDataItemEventArgs(item, index));
        }


Реализация «Клика по элементу» как мышкой, так и программная – Пример 3.

Пример 3
        protected override void OnMouseUp(MouseButtonEventArgs e)
        {
            base.OnMouseUp(e);
            //обходим список с областями рендеринга компонента
            for (int i = this.CurrentVisibleListBlockRect.Count - 1; i >= 0; i--)
            {
                //если курсор в области элемента, то выбираем его
                if (this.CurrentVisibleListBlockRect[i].Contains(e.GetPosition(this)))
                {
                    IndexCurrentChoosenBlock = i + IndexCurrentFirstVisibleBlock;
                    this.InvalidateVisual();
                    this.ClickItemRaiseEvent(this.SourceData[IndexCurrentChoosenBlock], IndexCurrentChoosenBlock);
                }
            }
        }
        /// <summary>
        /// Программный выбор элемента по индексу и генерация события клика по нему
        /// </summary>
        /// <param name="index"> индекс элемента </param>
        public void Select(int index)
        {
            int tempIndex = index;
            if (this.SourceData.Count.Equals(0)) return;
            if ((index < 0) && (index >= this.SourceData.Count))
                tempIndex = 0;
            this.IndexCurrentChoosenBlock = tempIndex;
            this.IndexCurrentFirstVisibleBlock = tempIndex;
            this.InvalidateVisual();
            ClickItemRaiseEvent(this.SourceData[this.IndexCurrentChoosenBlock], this.IndexCurrentChoosenBlock);
        }


А теперь разберём самое интересное – рендеринг компонента. В примере 4.

Пример 4
        /// <summary>
        /// Алгоритм отрисовки компонента
        /// </summary>
        /// <param name="drawingContext">контекст рисования</param>
        protected override void OnRender(DrawingContext drawingContext)
        {
            base.OnRender(drawingContext);
            //Текущая фактическая высота области рисования
            double currentHeight = this.RenderSize.Height - this.hScrollBar.RenderSize.Height;
            //Текущая фактическая ширина области рисования
            double currentWidth = this.RenderSize.Width - this.vScrollBar.RenderSize.Width;
            //Область рисования (вне этой области рисовать нельзя)
            Size clipSize = new Size(currentWidth, currentHeight);
            //ограничиваем
            drawingContext.PushClip(new RectangleGeometry(new Rect(new Point(0, 0), clipSize)));
            if (this.SourceData.Count <= 0) return;
            //текущий индекс рисуемого элемента (блока)
            int tempIndex = this.IndexCurrentFirstVisibleBlock;
            //текущая точка рисования на канвасе компонента
            Point currentBlockRenderLocation = new Point(0,0);
            //учёт горизонтального скроллинга (если ползунок передвинут) (в случае когда не помещается полностью элемент)
            currentBlockRenderLocation.X = - this.hScrollBar.Value;
            this.hScrollBar.Maximum = 0;
            //очистка Списка областей (прямоугольников), в которых отрисованы видымые элементы списка SourceData
            this.CurrentVisibleListBlockRect.Clear();
            //рисуем блоки (элементы) пока они видны на экране (канвасе компонента)
            while (currentBlockRenderLocation.Y < currentHeight)
            {
                if (this.ConvertToBlockTemplate == null) return;
                if (tempIndex >= this.SourceData.Count) return;
                
                //преобразоваем элемент пользовательского типа в универсальный шаблон отображения
                //данную функцию описывает пользователь компонента
                BlockTemplateBase currentRenderedBlock = this.ConvertToBlockTemplate(this.SourceData[tempIndex], tempIndex);
                
                //рендерим шаблон
                DrawingVisual currentBlockBuffer =  currentRenderedBlock.Render(currentBlockRenderLocation);
                //рисуем его на канвасе компонента
                drawingContext.DrawDrawing(currentBlockBuffer.Drawing);
 
                //область рисования текущего шаблона
                Rect currentBlockRect = new Rect(currentBlockRenderLocation, currentRenderedBlock.RenderSize);
                //добавляем его в список (пригодится для реализации клика по элементу)
                this.CurrentVisibleListBlockRect.Add(currentBlockRect);
 
                //подкрашиваем выбранный элемент
                if (this.IndexCurrentChoosenBlock.Equals(tempIndex))
                {
                    drawingContext.PushOpacity(0.5);
                    drawingContext.DrawRectangle(this.ColorChoosenBlockShadow, null, currentBlockRect);
                    drawingContext.Pop();
                }
 
                //выбираем самую длинную ширину (самы длинный элемент) (для реализации горизонтального скроллинга)
                double deltaWidth = currentRenderedBlock.RenderSize.Width - currentWidth;
                if (deltaWidth > 0)
                    if (this.hScrollBar.Maximum <= deltaWidth) { this.hScrollBar.Maximum = deltaWidth; }
 
                //переходим вниз, на свободное место для рисования
                currentBlockRenderLocation.Y += currentRenderedBlock.RenderSize.Height;
                tempIndex++;
            }
        }


Далее рассмотрим возможности «раскраски элементов» на базе шаблонов. Сразу забегу вперёд, сказав, что такой подход обеспечивается возможность описания пользовательского шаблона, так, как необходимо.
Базовый шаблон (BlockTemplateBase) представлен в примере 5.

Пример 5
    /// <summary>
    /// Базовый шаблон отображения
    /// </summary>
    public class BlockTemplateBase
    {
        /// <summary>
        /// Область рисования
        /// </summary>
        protected Rect RenderRect;
        /// <summary>
        /// Буфер рисования
        /// </summary>
        protected DrawingVisual RenderBuffer;
        /// <summary>
        /// Размеры области рисования
        /// </summary>
        public Size RenderSize { get { return this.RenderRect.Size; } set { this.RenderRect.Size = value; } }
 
        public BlockTemplateBase() { RenderRect = new Rect(); RenderSize = new Size(); RenderBuffer = new DrawingVisual(); }
 
        /// <summary>
        /// Базовый алгоритм рендеринга
        /// </summary>
        /// <param name="renderLocation"> точка отрисовки </param>
        /// <returns> буфер рисования </returns>
        public DrawingVisual Render(Point renderLocation)
        {
            using (DrawingContext dc = RenderBuffer.RenderOpen())
            {
                RenderRect.Location = renderLocation;
                dc.PushClip(new RectangleGeometry(RenderRect));
                //вызываем функцию в которой описан алгоритм риования
                if (DataRender != null) DataRender(dc);
                dc.Close();
            }
            return RenderBuffer;
        }
 
        protected delegate void DataRenderDelegate(DrawingContext dc);
        //указатель на функцию с алгоритмом рисования
        protected DataRenderDelegate DataRender { get; set; }
    }


Простой шаблон (BlockTemplateSimple) для отображения, наследуемый от базового шаблона представлен в примере 6.

Пример 6
    /// <summary>
    /// простенький Шаблон рендеринга
    /// </summary>
    public class BlockTemplateSimple : BlockTemplateBase
    {
        /// <summary>
        /// Текстовая строка
        /// </summary>
        public string Data { get; set; }
        /// <summary>
        /// Цвет фона
        /// </summary>
        public Brush ColorBackground { get; set; }
        /// <summary>
        /// Цвет границ
        /// </summary>
        public Brush ColorBorder { get; set; }
        /// <summary>
        /// Цвет шрифта
        /// </summary>
        public Brush ColorFont { get; set; }
        /// <summary>
        /// размер шрифта
        /// </summary>
        public double FontSize { get; set; }
        /// <summary>
        /// Название шрифта
        /// </summary>
        public string FontName { get; set; }
 
        public BlockTemplateSimple()
        {
            base.DataRender = this.DataRender;
            ColorBackground = Brushes.WhiteSmoke;
            ColorBorder = Brushes.Gray;
            ColorFont = Brushes.Black;
            FontSize = 10;
            FontName = “Calibri”;
        }
 
        /// <summary>
        /// Алгоритм рендеринга
        /// </summary>
        /// <param name=”dc”> контекст рисования </param>
        new void DataRender(DrawingContext dc)
        {
            //рисуем границу
            dc.DrawRectangle(ColorBackground, new Pen(ColorBorder, 1.0), this.RenderRect);
            //форматируем текст для рисования
            FormattedText txt = new FormattedText(Data, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(FontName), FontSize, ColorFont);
            txt.MaxTextWidth = this.RenderRect.Width;;
            txt.MaxTextHeight = this.RenderRect.Height;
            txt.TextAlignment = TextAlignment.Justify;
            //рисуем текст
            dc.DrawText(txt, this.RenderRect.Location);
        }
    }


Теперь рассмотрим примерный вариант использования данного компонента.
Для этого определим 2 тестовых пользовательских класса, как показано в примере 7.

Пример 7
    public class UserClassA
    {
        public int Value { get; set; }
        public string Str1 { get; set; }
        public string Str2 { get; set; }
        public UserClassA()
        { Value = 0; Str1 = "__"; Str2 = "__"; }
        public UserClassA(int val, string str1, string str2):this()
        { Value = val; Str1 = str1; Str2 = str2; }
    }
    public class UserClassB
    {
        public string Str { get; set; }
        public UserClassB()
        { Str = "__"; }
        public UserClassB(string str) : this()
        { Str = str; }
    }


В примере 8 инициализируем компонент. В примере 9 – алгоритм преобразования из пользовательского типа в шаблон.

Пример 8
        List<object> DataList { get; set; }
        Random r { get; set; }
 
        public MainWindow()
        {
            InitializeComponent();
            DataList = new List<object>();
            r = new Random();
            this.listBlockView_Test.ConvertToBlockTemplate = this.ConvertToBlockTemplate;
            this.listBlockView_Test.ClickItem += new EventHandler<ListBlockView.ListBlockView.ClickDataItemEventArgs>(listBlockView_Test_ClickItem);
        }


Пример 9
        /// <summary>
        /// Пользовательский Алгоритм преобразования из совего класса в шаблон рисования
        /// </summary>
        /// <param name="item">элемент</param>
        /// <param name="index">индекс</param>
        /// <returns>шаблон</returns>
        BlockTemplateBase ConvertToBlockTemplate(object item, int index)
        {
            BlockTemplateSimple block = new BlockTemplateSimple();
            if (item is UserClassA)
            {
                UserClassA itemA = (UserClassA)item;
                //Формируем данные для отображения
                block.Data = String.Format("Value= {0}, Str1= {1}, Str2= {2}", itemA.Value.ToString(), itemA.Str1, itemA.Str2); 
                //Раскрашиваем (задаём параметры рисования)
                block.FontSize = 14;
                block.FontName = "Calibri";
                block.ColorBackground = Brushes.Yellow;
                block.ColorBorder = Brushes.Red;
                block.ColorFont = Brushes.Blue;
            }
            else if (item is UserClassB)
            {
                UserClassB itemB = (UserClassB)item;
                //Формируем данные для отображения
                block.Data = String.Format("Str= {0}", itemB.Str);
                //Раскрашиваем (задаём параметры рисования)
                block.FontSize = 12;
                block.FontName = "Courier New";
                block.ColorBackground = Brushes.LightGray;
                block.ColorBorder = Brushes.Black;
                block.ColorFont = Brushes.Green;
            }
            block.RenderSize = new Size(500, 30);
            return block;
        }


Реализация обработки клика по элементу показана в примере 10.

Пример 10
        void listBlockView_Test_ClickItem(object sender, ListBlockView.ClickDataItemEventArgs e)
        {
            string textBoxStr = "";
            if (e.Item is UserClassA)
            {
                textBoxStr = String.Format("Индекс элемента = {0}{1}Тип элемента: {2}{3}Value= {4}, Str1= {5}, Str2= {6}",
                    e.Index.ToString(), '\n'.ToString(), 
                    "UserClassA", '\n'.ToString(),
                    ((UserClassA)e.Item).Value.ToString(), ((UserClassA)e.Item).Str1, ((UserClassA)e.Item).Str2);
            }
            else if (e.Item is UserClassB)
            {
                textBoxStr = String.Format("Индекс элемента = {0}{1}Тип элемента: {2}{3}Str= {4}", 
                    e.Index.ToString(), '\n'.ToString(),
                    "UserClassB", '\n'.ToString(),
                    ((UserClassB)e.Item).Str);
            }
            MessageBox.Show(textBoxStr);
        }


Выводы


Можно было ещё добавить задание общего вида отображения компонента в xaml-коде и много других плюшек – их в WPF много, но не будем забывать, что компонент нужен был «ещё вчера» и «по-быстрому».

Итак, на выходе имеем:
  • Компонент типа ListView с неоднородным содержимым;
  • Возможностью задания алгоритма рендеринга элемента (преобразования пользовательского типа в шаблон отображения);
  • Возможность создания шаблона отображения «по потребностям» самим пользователем компонента по несложным принципам (т.е. не особо сложно и самому создать – никаких ограничений);
  • Виртуальный режим работы компонента (т.е. вывод (рендеринг) идёт «на лету») – это ускоряет работу компонента (нам не надо ничего с исходными данными делать, только отрисовать несколько элементов исходного списка);
  • Возможность быстрой работы на любом количестве исходных данных (столько, сколько вмещается в List).

P.S.


Прошу подкинуть умных мыслей по улучшению в комментариях.
Ссылка на проект: Скачать проект
Примечание: разработка велась в MS Visual Studio 2010 под «.Net Framework 4»

Комментарии (21)


  1. PetrovSerega
    07.12.2015 21:48
    +9

    А чем это лучше нескольких DataTemplate с DataTemplateSelector?


    1. BlackEngineer
      07.12.2015 22:26
      -2

      Не понял вашу мысль… можно поподробнее что Вы имеете в виду?


      1. kekekeks
        07.12.2015 23:02
        +7

        ListView (да и любой ItemsControl) из коробки умеет показывать разнородный контент. Ему можно задать несколько DataTemplate с разными DataType. При этом набор темплейтов можно вообще в глобальных ресурсах хранить.


      1. PetrovSerega
        08.12.2015 10:47
        +1

        Пример можно посмотреть здесь.


      1. b_oberon
        08.12.2015 13:04
        +2

        И еще одна хорошая (хотя и старая) статья здесь. Финальный результат — правильно стилизованный ListBox.
        image


      1. CrazyViper
        08.12.2015 17:48
        +1

        Это ваша третья статья про то как не надо в WPF использовать подходы WinForms. Вам в комментариях говорят — изучите технологию, а вы упорно используете WinForms-подход и называете это WPF.

        не будем забывать, что компонент нужен был «ещё вчера» и «по-быстрому».
        Вот именно поэтому в WPF есть DataTemplate и DataTemplateSelector, которые позволяют в 10 строчек на xaml + 10 строчек на C# сделать то, на что у вас ушло 10 спойлеров.

        Ну а собственный рендеринг в WPF — это отдельная тема и его надо использовать только тогда, когда это очень и очень сильно обосновано.


  1. dymanoid
    08.12.2015 01:09
    +7

    У вас неверный в корне подход к созданию компонентов WPF. Представленный вами подход имел место быть в Windows Forms, где было необходимо вручную рендерить контрол и за всем следить вручную. WPF же предоставляет мощнейшие средства для разделения данных и их представления — вы в декларативной форме описываете, как должно выглядеть представление ваших данных, а остальное WPF делает за вас. Рекомендую почитать что-то типа этого.


    1. groaner
      08.12.2015 01:29

      Насколько я понимаю, смысл делать свой ListView есть только в том случае если не устраивает именно скорость стандартных механизмов WPF. Конечно в данном случае даже стандартный ListView легко сможет отобразить хоть миллион строк. Но вот, например, если столбец будет не один, а сотня-другая — тут уже WPF загнется на раз-два.

      А, ну и ещё в стандартном ListView может не устраивать убогая поддержка Drag&Drop. Но это наверняка исправляется простым наследованием.


      1. dymanoid
        08.12.2015 01:37

        Посмотрите, например, Xceed DataGrid из пакета WPF Extended Toolkit. Умеет отображать миллионы записей при сотнях столбцов (виртуализация данных и отображения), в общем и целом довольно шустрый. Всё построено на стилях и байндингах, т.е. именно так, как и предполагалось делать компоненты.


        1. groaner
          08.12.2015 01:54

          Возможно. Просто я как раз с такой задачей (миллион строк/сотня столбцов) сталкивался на практике. И заставить более-менее сносно работать стандартный ListView мне не удалось. Потому что виртуализация строк есть, а вот виртуализации столбцов — нет. Пришлось, насколько помню, подсовывать собственную реализацию GridViewRowPresenter отрисовывающую строку вручную. Биндинги и темплейты — это все конечно круто, но не на таких объёмах. Тем более если к ним прибавить тормозной WPF-овский рендеринг. А представьте что вам нужна ещё и плавная прокрутка (попиксельная). А у вас даже нет возможности сделать контролу подсказку что все строки одинаковой высоты. Без написанного с нуля ListView точно нельзя будет обойтись.


          1. Dywar
            08.12.2015 09:50
            +2

            Что за необходимость иметь миллионы строк с множеством столбцов, кто это сможет смотреть и зачем?


            1. groaner
              08.12.2015 11:01

              Конкретно в нашем случае это была телеметрия. По строкам — отсчёты времени, по столбцам — телеметрические параметры, в ячейках — их значения. Плюс возможность фильтрации по времени. Вы не поверите, но иногда удобно вывести сразу весь миллион строк, а дальше уже использовать поиск/фильтрацию.


              1. redmanmale
                08.12.2015 11:07

                Вы пробовали сделать шуструю пагинацию?


                1. groaner
                  08.12.2015 11:20

                  Не пробовали. В принципе, с включённой виртуализацией и с ручной отрисовкой столбцов ListView и так достаточно быстро стал работать. А без ручной отрисовки все равно вряд ли можно было бы обойтись — с сотней столбцов ListView уже на сотне строк начинает тормозить. Там какое-то дикое количество всяких Measure и Arrange на каждый чих вызываются. По крайней мере в 3.5


  1. Scrooge2
    08.12.2015 12:59
    +6

    Это просто какая-то жесть. Какая причина написания этого контрола?
    Прочитайте хоть несколько страниц любой книги про технологию, чтоб не творить этот беспредел.

    То что вы сделали, делается в несколько строчек XAML.


    1. b_oberon
      08.12.2015 13:05
      +4

      Инженер-программист 3 категории…


      1. BlackEngineer
        09.12.2015 21:09

        не надо намёков) про квалификацию — не Вам решать, а аттестационной комиссии


        1. b_oberon
          10.12.2015 14:33

          Во-первых, аттестационная комиссия уже, видимо, все решила, а я лишь констатирую факты из профиля.

          Ну а во-вторых, в части WPF я могу сказать и без намеков: квалификация низкая, не охватывающая даже основных принципов платформы. Если размещаете статью на Хабре, готовьтесь выслушивать критику. Прошлый опыт показал: не в коня корм, с сентября ничего не изменилось.


  1. BlackEngineer
    09.12.2015 21:03

    1. это нужно было для того, чтобы все программисты (а именно на заводе все) которые работали только WinForms могли пользоваться
    2. по интерфейсу USB от «коробочки» приходят пакеты (транзакции МКИО и ДПК) за 1 мс как минимум 10 и стандартный листвью не справляется — начинает тормозить очередь принимающая пакеты по USB


    1. b_oberon
      10.12.2015 14:23
      +1

      чтобы все программисты (а именно на заводе все) которые работали только WinForms могли пользоваться
      Да пусть пользуются, кто же им запрещает? Только не надо пытаться даунгрейдить WPF до состояния WinForms.

      начинает тормозить очередь принимающая пакеты по USB
      А работа с USB у вас в UI Thread крутится?.. И, конечно же, после каждого пакета жизненно важно перерисовывать UI — человек отлично воспринимает события с интервалом 100 мкс.

      Я бы на вашем месте все-таки обратил внимание на конструктивную критику и почитал немного про WPF.


  1. HomoLuden
    15.12.2015 17:47

    Посоветовали люди
    Мне использовать очки.
    Их прикладывал я всюду,
    Но они не помогли.
    После школы пусть не медик
    И стекло не шлифовал,
    Только кажется очёчки
    Прохиндей мне те продал.