twixed.ru huh… nothing interesting here

16Дек/110

MetroUI-like scrolling

Два месяца этот примерчик ждал своей очереди, и «пылился» на винче без дела. И, наконец-то, я собрался духом, и выкладываю его на всеобщее обозрение и порицание.
В этой статье я поделюсь своим методом реализации некоего подобия интерфейса Metro UI, написанного, как говорится, «на коленке» на C# под WPF. В примере используется .Net framework 4, но все описанное можно без изменений использовать в версиях 3 и 3.5. Framework 2.0 не умеет WPF, но реализованный здесь алгоритм можно запросто перенести на WinForms (правда, на VCL отрисовка «плывущих» контролов безбожно тормозит и уродует форму, даже если DoubleBuffered:=true, а на каждый тик таймера вызываются Application.ProcessMessages и/или Refresh). WPF был выбран основным для демонстрации этого алгоритма потому что это «модно, стильно, молодежно»… шучу. Конечно, он был выбран потому, что это удобно, быстро и красиво.
Итак, заинтересованных прошу под кат…

Переменные

Заранее определим все переменные и классы, которые будут использоваться для обработки скроллинга как в Metro UI. В первую очередь, нам нужен таймер, который будет обрабатывать и изменять положение тайлов, в зависимости от ускорения, которое им придал пользователь, потаскав мышкой. Следовательно, нам нужна будет переменная для значения скорости, переменная для смещения (скроллинга), переменные для положения мыши и переменные для времени начала и конца перетаскивания с точностью до миллисекунд. Помимо этого будет еще и булева переменная, работающая как флаг для остановки скроллинга. Позже мы увидим, что она окажется важной, хотя, на первый взгляд, можно было бы обойтись и без нее.

private DispatcherTimer timer = new DispatcherTimer();          // main timer
 
private double tileOffset = 0;              // actually, it is a scroll offset
private double tilesWidth = 0;              // total width of all tiles
private double lastMouseXPos = 0;           // remembers last mouse position. needed to calculate scroll direction and speed
private double mouseXPos = 0;               // no comments on these two
private double mouseYPos = 0;               // 
private double mouseInertia = 0;            // determines scroll speed after the mouse button is released. decreases avery timer tick
private UInt64 inertiaStart = 0;            // mouse drag start time in milliseconds
private UInt64 inertiaEnd = 0;              // mouse drag end time in milliseconds
private Boolean isInertiaStopped = true;    // useful flag needed to stop scroll by zeroing `mouseInertia` on next timer tick

Тайлы

Теперь создадим и разместим нашу плитку. В примере она будет простой, однако элементарность всего происходящего позволяет использовать практические любые контролы. Я же использовал Grid как простой и удобный элемент, который, к тому же, может служить контейнером для других контролов, а это уже открывает хорошие возможности для кастомизации.
Размещать вновь создаваемые Grid'ы мы будем на основной сетке чистого приложения, любезно созданного для нас средой разработки. Для этого надо присвоить ей имя, например, 'tilesGrid'.
Накрапаем простенькую функцию, в которой будем создавать новый Grid, задавать его параметры, и размещать его на основной сетке:

private void AddTile(int i)
{
  // grid is one of the simplest WPF controls, but also it's a convinient one,
  // because it can be customized, manipulated and can be a container for other controls
  Grid g = new Grid();
  g.Width = 128;
  g.Height = 128;
  g.Margin = new Thickness(5 + tilesWidth, 0, 0, 0);
  g.Background = new SolidColorBrush(Color.FromRgb((byte)(i*120), (byte)(i*110), (byte)(i*130)));     // some "random" background color
  g.VerticalAlignment = VerticalAlignment.Top;
  g.HorizontalAlignment = HorizontalAlignment.Left;
  tilesGrid.Children.Add(g);                                  // placing new element into main grid
  g.Effect = new DropShadowEffect{Color = new Color {A=1,R=0,G=0,B=0},ShadowDepth = 2,BlurRadius = 5,Opacity = 1};    // simple effect to make our tiles look sweet
 
  Label l = new Label();                                      // actually, this label is not necessary
  l.Content=i.ToString();                                     // i'm placing it to make visual counting
  g.Children.Add(l);                                          // of tiles a little bit easier
 
  tilesWidth = tilesWidth + g.Width+5;                        // we're updating total tiles's width to arrange them correctly directly after creation
}

Поместим на сетку двадцать пять тайлов:

public MainWindow()
{
  InitializeComponent();
  for (int i = 0; i < 25; i++) AddTile(i + 1);          // we'd like to create 25 tiles
}

 

Размещение тайлов

Естественно, плитку нужно красиво разместить. Для этого создадим отдельную функцию, которая будет вызываться при каждом срабатывании таймера, и размещать тайлы в две строки слева направо, а также сдвигать их в соответствии со смещением скролла. Приведенная функция проста, и, вероятно, неказиста. Я знаю, что было бы великолепно использовать здесь алгоритм из задачи «о рюкзаке», но на столь изощренное велосипедостроительство у меня, к сожалению, нет времени. Поэтому мы действуем «в лоб»: перебираем все дочерние элементы основной сетки, и размещаем наши гриды один за другим, периодически наращивая счетчик столбцов, от которого зависит верхний отступ тайла. Сложностей здесь нет никаких, потому что мы строго задали размеры тайлов при инициализации, и в вычислениях просто используем эти цифры.

private void LayoutTiles()
{
  // simplest layout algorithm ever! (:
  // tiles are arranged in two rows with margin of 5 pixels
  if (tilesWidth <= 0) return;                                        // if total tiles's width less then the main grid's with, do nothing
  tilesWidth = 5;                                                     // set the initial offset of default margin (5 px)
  int count = 0;                                                      // columns count
  int totalCount = 0;                                                 // total tiles count
  for (int i = 0; i < tilesGrid.Children.Count; i++)                  // loop through main grid's children
  {
    Object obj = tilesGrid.Children[i];
    if (obj.GetType() == typeof(System.Windows.Controls.Grid))      // checking children type. we need only Grids. any other objects remains untouched
    {                                                               // this condition must be modified to your needs
      totalCount++;
      Grid rect = (Grid)obj;
      if (i % 2 == 0)
        rect.Margin = new Thickness(tileOffset + 5 + 133 * count, 5, 0, 0);     // 
      else                                                                        // setting current tile margin according to scroll offset 
      {                                                                           // and column number our tile is placed in
        rect.Margin = new Thickness(tileOffset + 5 + 133 * count, 138, 0, 0);   //
        count++;                                                                // increase column count
      }
    }
  }
  tilesWidth = 133 * count - tilesGrid.ActualWidth + 5;               // set total width to number of columns minus main grid's width
  if (totalCount % 2 != 0) tilesWidth += 133;                         // increase it by one column if we have odd number of tiles
}

 

Скроллинг перетаскиванием

Следующим шагом мы будем скроллить все тайлы влево или в право, «хватая» мышкой любой из них, и перетаскивая его в нужную сторону. Для этого нам потребуется добавить три обработчика — «нажали кнопку мыши», «отпустили кнопку мыши» и «подвинули мышь» — и назначить их каждому тайлу после его создания.
Начнем с обработчика нажатия на клавишу мыши: он должен запомнить время нажатия и координаты мыши, чтобы другие обработчики могли использовать их для построения своего поведения и вычисления смещения тайлов.

private void TileMouseDown(object sender, MouseButtonEventArgs e)
{
  if (e.LeftButton == MouseButtonState.Pressed)                       // do anything only if left mouse button is pressed
  {
    Grid rect = (Grid)sender;
    mouseXPos = rect.PointToScreen(e.GetPosition(rect)).X;          // save current mouse position
    mouseYPos = rect.PointToScreen(e.GetPosition(rect)).Y;          // relatively to screen
    lastMouseXPos = mouseXPos;                                      // update last mouse position on x-pos (we'll need it later)
    mouseInertia = 0;                                               // reset the scrolling speed
    TimeSpan span = DateTime.UtcNow - new DateTime(1970, 1, 1);     // and save scrolling start time in milliseconds
    inertiaStart = (UInt64)span.TotalMilliseconds;                  // (we really need it to be so precise)
  }
}

 

Следующим будет обработчик движения мыши над нашими тайлами. Он должен выполнять какие-либо действия только если не все тайлы уместились в экран, и мышь была сдвинута по оси X более чем на пять пикселей в любом направлении. В этом случае мы считаем, что пользователь действительно желает перетащить тайлы, и делаем это, вычисляя дистанцию, которую прошла мышь с последней итерации, и размещаем тайлы в соответствии с изменившимся смещением.

private void TileMouseMove(object sender, MouseEventArgs e)
{
  if (tilesWidth + Width > Width)                                             // check if tiles don't fit into window
    if (e.LeftButton == MouseButtonState.Pressed)                           // and do somenthing only if left mouse button is pressed
    {
      Grid rect = (Grid)sender;
      if ((mouseXPos < rect.PointToScreen(e.GetPosition(rect)).X - 5) ||  // have mouse moved more than 5 pixels on x-axis?
          (mouseXPos > rect.PointToScreen(e.GetPosition(rect)).X + 5))    // strart scrolling if she did
      {
        double diff = (lastMouseXPos - rect.PointToScreen(e.GetPosition(rect)).X);  // compute the distance she traveled since last iteration
        lastMouseXPos = rect.PointToScreen(e.GetPosition(rect)).X;      // and save current position to use it next time
        tileOffset -= diff;                                             // change tile scroll offset
        if (tileOffset > 0) tileOffset = 0;                             // restrict excess scrolling to the left
        if (tileOffset < -tilesWidth) tileOffset = -tilesWidth;         // and right
        LayoutTiles();
      }
    }
}

 

Последний «мышиный» обработчик вызывается, когда пользователь отпустил кнопку мыши, и нам надо решить — клик это был или завершение перетаскивания. Для этого используются координаты мыши, сохраненные в последний раз, и, если они отличаются не больше, чем на 5 пикселей, засчитываем клик, если нет — завершаем перетаскивание и вычисляем переданное ускорение.
Как я говорил в начале этой статьи, рассмотрим необходимость существования булевой переменной `isInertiaStopped`. Она может показаться не нужной, потому что (как вы поймете из кода ниже) можно было бы просто обнулять скорость скроллинга. Однако парой лишних строчек мы добавили очень важный функционал — пользователь будет иметь возможность остановить прокрутку без вызова обработчика «клик». Т.е. он сможет остановить скроллинг, понять, что же он видит, и уже потом кликнуть по нужному тайлу, обработчиком на клик по которому может быть запуск приложения, открытие дочерней формы или вообще самоуничтожение программы со смешным описанием исключения.

private void TileMouseUp(object sender, MouseButtonEventArgs e)
{
  if (e.LeftButton == MouseButtonState.Released)                      // do anything only if left mouse button was released
  {
    Grid rect = (Grid)sender;
    if ((mouseXPos > rect.PointToScreen(e.GetPosition(rect)).X - 5) &&  //
        (mouseXPos < rect.PointToScreen(e.GetPosition(rect)).X + 5) &&  // have mouse moved more than 5 pixels in any direction?
        (mouseYPos > rect.PointToScreen(e.GetPosition(rect)).Y - 5) &&  //
        (mouseYPos < rect.PointToScreen(e.GetPosition(rect)).Y + 5))    //
    {
      if (isInertiaStopped)                                       // are tiles still scrolling?
      {
        // tile click handler should be place here
      }
      else
      {
        isInertiaStopped = true;                                // stop scrolling tiles
      }
    }
    else
    {
      isInertiaStopped = false;                                   // pull down scrolling flag, so next time we will know that tiles are scrolling
      TimeSpan span = DateTime.UtcNow - new DateTime(1970, 1, 1); 
      inertiaEnd = (UInt64)span.TotalMilliseconds;                // save scrolling end time in milliseconds
      double distance = mouseXPos - rect.PointToScreen(e.GetPosition(rect)).X;    // compute the distance she traveled since last iteration
      UInt64 time = inertiaEnd - inertiaStart;                    // measure the time this distance was covered by
      if (tilesWidth + Width > Width) mouseInertia = distance * 10 / time * -1;   // and calculate scrolling speed based on these two values 
    }
  }
}

 

Эти три обработчика должны быть привязаны к каждому созданному нами тайлу, поэтому в функцию AddTile мы добавим еще три строчки:

g.MouseDown += new MouseButtonEventHandler(TileMouseDown);  //
g.MouseUp += new MouseButtonEventHandler(TileMouseUp);      // adding mouse event handlers to newly created grid
g.MouseMove += new MouseEventHandler(TileMouseMove);        //

 

Таймер

Сейчас наша программа позволяет скроллить тайлы только когда их тащат мыщью, а несколько переменных и большая часть последнего рассмотренного обработчика вообще оказываются ненужными. Поэтому мы добавим таймер, и каждые 10 миллисекунд будем вызывать его обработчик, единственная задача которого вычислять изменение скорости скроллинга, и ракладывать тайлы в соответствии с новым смещением.

private void TimerTick(object sender, EventArgs e)
{
  // simple calculations
  if (mouseInertia != 0)                                      // if scrolling is needed
  {
    tileOffset += mouseInertia;                             // change scroll offset
    if (tileOffset > 0) tileOffset = 0;                     // restring excess scrolling to the left
    if (tileOffset < -tilesWidth) tileOffset = -tilesWidth; // and right
    mouseInertia *= 0.99;                                   // decrease scrolling speed
    LayoutTiles();
  }
}

 

В конструкторе  объекта задаем интервал срабатывания таймера, привязываем его обработчик, и запускаем:

timer.Interval = TimeSpan.FromMilliseconds(10);             //
timer.Tick += new EventHandler(TimerTick);                  // setting up and starting main timer
timer.Start();                                              //

 

Заключение

Простейший пример готов. Я надеюсь, что он может кому-то пригодиться, и достаточно понятен для затачивания под свои нужды. Я знаю, что в коде есть неиспользуемые переменные (а именно, переменные для хранения положения мыши по оси Y), но убирать их не стал, просто чтобы по прошествии времени было проще реализовать вертикальный/свободный скроллинг, если таковой понадобится.
Приведу скриншот программы, которая использует приведенный здесь алгоритм для отображения подключаемых модулей, «иконки» которых обладают способностью скроллиться мышью влево и вправо, как если бы это было на сенсорном устройстве:

Ну, и, на последок, ссылка на архив с исходником.

PS: Буду очень благодарен тому, кто поможет с задачей «о рюкзаке» действенным и простым алгоритмом или ссылкой.

UPDATE: Чуть улучшенная версия — при достижении крайней плитки не останавливает ее как бетонная стена, а постепенно снижает скорость, и возвращает на исходную, как на резинке.

Post to Twitter Post to Digg Post to Facebook Post to Google Buzz Send Gmail Post to LinkedIn

Метки записи: , , Оставить комментарий
Комментарии (0) Пинги (0)

Пока нет комментариев.


Leave a comment

Нет обратных ссылок на эту запись.