Наверное, любому программисту, который разрабатывает пользовательский интерфейс на C#/XAML, приходилось писать нестандартные элементы управления. В нашей веселой команде 2GIS для Windows Phone мы довольно часто делаем это, и такие задачи стали почти рутиной. Но об одном случае мне хочется рассказать подробнее. Все началось с того, что однажды нам понадобилось написать весьма своеобразный прогресс-бар.


Проблема


Как-то раз к нам на внутрикомандную рассылку пришло письмо от нашего коллеги Woodroof. Данил достаточно известен внутри компании: он является талантливым разработчиком, носит странную прическу и прокачал придирчивость внимание к деталям до максимума. Содержание его письма вкратце можно передать примерно так.

  1. У Данила есть жена, ее зовут Саша.
  2. Саша пользуется смартфоном с Windows Phone, и у нее, конечно же, установлен 2GIS.
  3. В 2GIS’е есть стартовый экран загрузки города, на котором есть прогресс-бар.
  4. Саша заметила, что анимация этого прогресс-бара выглядит не круто.

Собственно, анимация тогда была вот такой:

Такой прогресс-бар уже достаточно необычен, но сделать его можно довольно просто с помощью обычного сдвига маски. Вот разметка на XAML, которая передает основную идею реализации.

<Grid>
    <Path Data="..."
          Stroke="Gray" />
    <Path Data="..."
          Stroke="Black">
        <Path.Clip>
            <RectangleGeometry Rect="0,0,100,50" />
        </Path.Clip>
    </Path>
</Grid>

Я намеренно не привожу значения свойств Data у элемента Path, чтобы не загромождать код. Но суть в том, что есть два абсолютно одинаковых векторных пути, расположенных один под другим. При этом часть верхней картинки отсекается маской (элемент Path.Clip). Программно изменяя Rect у маски, мы тем самым регулируем «прогресс» загрузки. В дизайнере VS для этой разметки вы увидите примерно такую картинку.

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

Мы всей командой тогда согласились, что гораздо лучше сделать вот так.

Но как же это сделать?

Решение


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

Для начала добавим к нашему векторному пути штриховку.


<Grid>
    <Path Data="..."
            Stroke="Gray"
            StrokeThickness="1"
            StrokeDashCap="Flat"
            StrokeStartLineCap="Flat"
            StrokeEndLineCap="Flat">
    </Path>
    <Path Data="..."
            StrokeThickness="2"
            StrokeDashCap="Flat"
            StrokeStartLineCap="Flat"
            StrokeEndLineCap="Flat"
            Stroke="Black"
            StrokeDashArray="9 3">
    </Path>
</Grid>

За штриховку отвечает свойство StrokeDashArray. Оно принимает значения типа DoubleCollection, и в нашем случае первое число — это длина штриха, а второе — длина промежутка между штрихами.

Если очень сильно увеличить длину промежутка между штрихами, например, так StrokeDashArray="9 99999?, то получим вот такую картинку.

Теперь становится понятно, что для достижения нужного эффекта нам необходимо изменять длину штриха. Например, для StrokeDashArray="128 99999? получим вот такую картинку.

Давайте для ясности я дам нашему нестандартному прогресс-бару имя — ProgressPathControl и определю его основную характеристику — свойство Progress.

public static readonly DependencyProperty ProgressProperty = DependencyProperty.Register(
    "Progress", typeof(double), typeof(ProgressPathControl),
    new PropertyMetadata(0.0, OnProgressValueChanged));

public double Progress
{
    get { return (double)GetValue(ProgressProperty); }
    set { SetValue(ProgressProperty, Math.Min(1.0, Math.Max(0.0, value))); }
}

private static void OnProgressValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (e.NewValue != e.OldValue)
    {
        ((ProgressPathControl)d).OnProgressValueChanged((double)e.OldValue, (double)e.NewValue);
    }
}

Пусть Progress меняется от 0 до 1. Очевидно, что для Progress = 0 длина штриха тоже будет равна нулю, а для Progress = 1 длина штриха будет равна длине кривой. Этому числу должен равняться и промежуток между штрихами.

Длину кривой можно элементарно посчитать, приглядевшись к элементу Path. У него есть свойство Data типа Geometry, а в нашем случае это PathGeometry, у которого есть коллекция Figures. Каждый её элемент имеет коллекцию Segments, содержащую элементы различных типов, в том числе BezierSegment и LineSegment. Нужно пробежаться по всем этим штукам и просуммировать длины сегментов. Я ленивый, поэтому ограничусь только BezierSegment и LineSegment, так как в моем случае их достаточно, а длину кривой Безье посчитаю уж совсем простым способом.

Например, так
private double GetGeometryLength(Geometry geometry)
{
    double result = 0;
    var pathGeometry = geometry as PathGeometry;
    if (pathGeometry != null)
    {
        foreach (var figure in pathGeometry.Figures)
        {
            var currentPoint = figure.StartPoint;
            foreach (var segment in figure.Segments)
            {
                var bezier = segment as BezierSegment;
                var line = segment as LineSegment;

                if (bezier != null)
                {
                    result += GetBezierLength(currentPoint, bezier.Point1, bezier.Point2, bezier.Point3);
                    currentPoint = bezier.Point3;
                }
                else if (line != null)
                {
                    result += GetLineLength(currentPoint, line.Point);
                    currentPoint = line.Point;
                }
            }
        }
    }

    return result;
}

private double GetBezierLength(Point p0, Point p1, Point p2, Point p3)
{
    double result = 0;
    Point lastPoint = p0;

    for (double t = 0.001; t <= 1; t += 0.001)
    {
        Point currentPoint;

        // Формула кубической кривой Безье
        // https://ru.wikipedia.org/wiki/Кривая_Безье
        currentPoint.X = Math.Pow(1 - t, 3) * p0.X +
            3 * t * Math.Pow(1 - t, 2) * p1.X +
            3 * t * t * (1 - t) * p2.X +
            Math.Pow(t, 3) * p3.X;

        currentPoint.Y = Math.Pow(1 - t, 3) * p0.Y +
            3 * t * Math.Pow(1 - t, 2) * p1.Y +
            3 * t * t * (1 - t) * p2.Y +
            Math.Pow(t, 3) * p3.Y;

        double dx = currentPoint.X - lastPoint.X;
        double dy = currentPoint.Y - lastPoint.Y;
        result += Math.Sqrt(dx * dx + dy * dy);
        lastPoint = currentPoint;
    }

    return result;
}

private double GetLineLength(Point p0, Point p1)
{
    double dx = p0.X - p1.X;
    double dy = p0.Y - p1.Y;
    return Math.Sqrt(dx * dx + dy * dy);
}


Теперь, когда мы знаем длину кривой, мы можем определить значение свойства StrokeDashArray для любых значений Progress.

double strokeDashLength = progressPathLength / progressPath.StrokeThickness * Progress;
double strokeDashOffset = progressPathLength / progressPath.StrokeThickness;
progressPath.StrokeDashArray = new DoubleCollection { strokeDashLength, strokeDashOffset };

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

Теперь у нас есть всё необходимое для написания нашего нестандартного прогресс-бара. Для полноты картины весь код ProgressPathControl, изрядно упрощенный, но неплохо иллюстрирующий основные принципы, я привожу ниже.

ProgressPathControl.cs
public class ProgressPathControl : Control
{
    #region ProgressProperty

    public static readonly DependencyProperty ProgressProperty = DependencyProperty.Register(
        "Progress", typeof(double), typeof(ProgressPathControl),
        new PropertyMetadata(0.0, OnProgressValueChanged));

    public double Progress
    {
        get { return (double)GetValue(ProgressProperty); }
        set { SetValue(ProgressProperty, Math.Min(1.0, Math.Max(0.0, value))); }
    }

    private static void OnProgressValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (e.NewValue != e.OldValue)
        {
            ((ProgressPathControl)d).OnProgressValueChanged();
        }
    }

    #endregion

    public ProgressPathControl()
    {
        DefaultStyleKey = typeof(ProgressPathControl);
    }

    protected override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        progressPath = (Path)GetTemplateChild("ProgressPath");
        progressPathLength = GetGeometryLength(progressPath.Data);
        OnProgressValueChanged();
    }

    private double GetGeometryLength(Geometry geometry)
    {
        double result = 0;
        var pathGeometry = geometry as PathGeometry;
        if (pathGeometry != null)
        {
            foreach (var figure in pathGeometry.Figures)
            {
                var currentPoint = figure.StartPoint;
                foreach (var segment in figure.Segments)
                {
                    var bezier = segment as BezierSegment;
                    var line = segment as LineSegment;

                    if (bezier != null)
                    {
                        result += GetBezierLength(currentPoint, bezier.Point1, bezier.Point2, bezier.Point3);
                        currentPoint = bezier.Point3;
                    }
                    else if (line != null)
                    {
                        result += GetLineLength(currentPoint, line.Point);
                        currentPoint = line.Point;
                    }
                }
            }
        }

        return result;
    }

    private double GetBezierLength(Point p0, Point p1, Point p2, Point p3)
    {
        double result = 0;
        Point lastPoint = p0;

        for (double t = 0.001; t <= 1; t += 0.001)
        {
            Point currentPoint;

            // Формула кубической кривой Безье
            // https://ru.wikipedia.org/wiki/Кривая_Безье
            currentPoint.X = Math.Pow(1 - t, 3) * p0.X +
                3 * t * Math.Pow(1 - t, 2) * p1.X +
                3 * t * t * (1 - t) * p2.X +
                Math.Pow(t, 3) * p3.X;

            currentPoint.Y = Math.Pow(1 - t, 3) * p0.Y +
                3 * t * Math.Pow(1 - t, 2) * p1.Y +
                3 * t * t * (1 - t) * p2.Y +
                Math.Pow(t, 3) * p3.Y;

            double dx = currentPoint.X - lastPoint.X;
            double dy = currentPoint.Y - lastPoint.Y;
            result += Math.Sqrt(dx * dx + dy * dy);
            lastPoint = currentPoint;
        }

        return result;
    }

    private double GetLineLength(Point p0, Point p1)
    {
        double dx = p0.X - p1.X;
        double dy = p0.Y - p1.Y;
        return Math.Sqrt(dx * dx + dy * dy);
    }

    private void OnProgressValueChanged()
    {
        if (progressPath != null)
        {
            double strokeDashLength = progressPathLength / progressPath.StrokeThickness * Progress;
            double strokeDashOffset = progressPathLength / progressPath.StrokeThickness;
            progressPath.StrokeDashArray = new DoubleCollection { strokeDashLength, strokeDashOffset };
        }
    }

    private double progressPathLength;
    private Path progressPath;
}


Generic.xaml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:controls="using:DoubleGis.Controls">
    
    <Style TargetType="controls:ProgressPathControl">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="controls:ProgressPathControl">
                    <Grid>
                        <Canvas Height="50"
                                Width="323">
                            <Path Data="M10.7,45.6C10,47.5,8.1,48.9,6,48.9c-2.8,0-5-2.2-5-5s2.2-5,5-5s5,2.2,5,5l0,0h30.3c0.1,0,0.2-0.1,0.2-0.2v-4c0-1.4,0.2-2.3,0.7-2.8c0.3-0.3,0.7-0.5,1.3-0.5H49c0.1,0,0.2-0.1,0.2-0.2v-3.6c0-1.4,0.2-2.3,0.7-2.8c0.3-0.3,0.8-0.5,1.3-0.5h3.5c0.1,0,0.2-0.1,0.2-0.2V14.7c0-2.1,0.4-3.1,1.4-3.3c0.1,0,0.3,0,0.5,0l0.2,0c3.4-0.5,3.6-4,3.6-9.6V0.6H63l0,1.1c0,5.4,0.2,9,3.8,9.6c0,0,0.1,0,0.1,0c0.1,0,0.2,0,0.3,0l0.1,0c1.5,0.4,1.5,2.7,1.5,3.4V29c0,0.1,0.1,0.2,0.3,0.2h3.2c0.6,0,1.1,0.2,1.5,0.6c0.5,0.6,0.8,1.5,0.8,2.7l0,10.6l0,0.4c0,0.1,0.1,0.2,0.2,0.2h16.6c2.4,0,2.8-2.1,3-4.3l0-3.6c0-0.1,0-0.1-0.1-0.2c0,0-0.1-0.1-0.2-0.1l-0.8,0c-3.6,0-6.1-2.3-6.1-5.6c0-1.8,0.9-3.7,2.3-4.7c0.1-0.1,0.1-0.1,0.1-0.2c-0.1-0.5-0.1-0.9-0.1-1.4c0-1.8,0.9-3.5,2.5-4.6c0.1,0,0.1-0.1,0.1-0.2c0-0.1,0-0.3,0-0.4c0-2,1.7-3.5,3.9-3.5c2.3,0,4.2,1.6,4.2,3.5c0,0.2,0,0.3,0,0.4c0,0.1,0,0.2,0.1,0.2c1.6,1.1,2.5,2.8,2.5,4.6c0,0.5,0,0.9-0.1,1.4c0,0.1,0,0.2,0.1,0.2c1.5,1.1,2.4,2.8,2.4,4.5c0,3.4-2.6,5.9-6.1,5.9l-1.2,0c-0.1,0-0.2,0.1-0.2,0.2l0,3.6c0.1,2.2,0.6,4.3,2.9,4.3h9.3c0.1,0,0.2-0.1,0.2-0.2v-2.1c0-1.3,0.2-2.2,0.7-2.7c0.3-0.3,0.7-0.4,1.2-0.4h2.5c1.9,0,1.9,2.3,1.9,3l0,2.2c0,0.1,0,0.1,0.1,0.2c0,0,0.1,0.1,0.2,0.1h2.5c0.1,0,0.1,0,0.2-0.1c0,0,0.1-0.1,0.1-0.2l0-2.2c0-0.7,0-3,1.9-3h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7v2.1c0,0.1,0.1,0.2,0.2,0.2h2.5c0.1,0,0.2-0.2,0.2-0.3l0-2c0-1.3,0.2-2.2,0.7-2.7c0.3-0.3,0.7-0.4,1.2-0.4h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7l0,0.4c0,0.1,0.1,0.2,0.2,0.2H148c0.1,0,0.2-0.1,0.2-0.3l0-4.6c0-1.5,0.2-2.5,0.7-3.1c0.3-0.3,0.8-0.5,1.3-0.5h2.2c0.1,0,0.2-0.1,0.2-0.2v-4.6c0-2.4,1.3-3.4,2.5-3.6c3.9-0.6,4.1-4.9,4-11.3l0-0.3l-0.5-2.4c0-0.1,0-0.1-0.1-0.1l-2-1.8l2.8-0.4c0.1,0,0.2-0.1,0.2-0.1l1.1-2.7l1.1,2.7c0,0.1,0.1,0.1,0.2,0.2l2.8,0.3l-2.2,1.9c0,0-0.1,0.1-0.1,0.1l-0.5,2.4c0,0,0,0,0,0.1l0,0.7c0,6.2,0.2,10.2,4.2,10.8c0.6,0.1,2.4,0.5,2.4,3.6v4.6c0,0.1,0.1,0.2,0.2,0.2h2.2c0.4,0,0.7,0.1,1,0.4c0.9,0.9,1,3.1,1,4.3v0.2c0,0.1,0.1,0.2,0.2,0.2h16.2c0.1,0,0.2-0.1,0.2-0.2c0-0.1,0-0.2-0.1-0.3c-1.1-0.8-2.5-1.9-3.6-3c-2.3-2.4-2.8-4.3-2.8-5.5c0-2.8,0.7-6,6-6c1.6,0,2.8,0.7,3.5,2c0.1,0.2,0.4,0.4,0.6,0.4c0.3,0,0.5-0.1,0.6-0.4c0.7-1.3,1.9-2,3.5-2c5.3,0,6,3.2,6,6c0,1.2-0.5,3-2.8,5.5c-1,1.1-2.5,2.2-3.6,3c-0.1,0.1-0.1,0.2-0.1,0.3c0,0.1,0.1,0.2,0.2,0.2l12.2,0c0.1,0,0.2-0.1,0.2-0.2l0-2.3c0.1-2.2,0.6-3.2,1.7-3.5l0.1,0l0.8-0.1c2.5-0.4,3.7-2.3,4-6.4l0-0.7h1.5l0.1,0.7c0.4,3.9,1.8,6,4.4,6.4l0.6,0.1l0.1,0c1.1,0.3,1.8,1.6,1.7,3.5v2.1c0,0.1,0.1,0.2,0.2,0.2h2.2c0.7,0,1.1,0.1,1.4,0.4c0.3,0.3,0.4,0.9,0.4,2.4c0,0,0,0,0,0.1l0,0.1l0,0.1c0,0.1,0.1,0.2,0.3,0.2h11c0.1,0,0.2-0.1,0.2-0.2l0-0.8c0-0.9,0.3-1.7,0.8-2.2c0.3-0.3,0.7-0.4,1.2-0.4c0,0,2.5,0,2.5,0c1.9,0,1.9,2.3,1.9,3l0,2.2c0,0.1,0,0.1,0.1,0.2c0,0,0.1,0.1,0.2,0.1h2.5c0.1,0,0.1,0,0.2-0.1c0,0,0.1-0.1,0.1-0.2l0-2.2c0-0.7,0-3,1.9-3h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7v2.1c0,0.1,0.1,0.2,0.2,0.2h2.5c0.1,0,0.2-0.2,0.3-0.3l0-2c0-1.3,0.2-2.2,0.7-2.7c0.3-0.3,0.7-0.4,1.2-0.4h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7v2.1c0,0.1,0.1,0.2,0.2,0.2h11.2l0.1,0c2.3-0.1,2.7-2.2,2.8-4.3l0-3.6c0-0.1-0.1-0.2-0.2-0.2c0,0-0.8,0-0.8,0c-3.6,0-6.1-2.3-6.1-5.6c0-1.8,0.9-3.7,2.3-4.7c0.1-0.1,0.1-0.1,0.1-0.2c-0.1-0.5-0.1-0.9-0.1-1.4c0-1.8,0.9-3.5,2.5-4.6c0.1,0,0.1-0.1,0.1-0.2c0-0.1,0-0.3,0-0.4c0-2,1.7-3.5,3.9-3.5c2.3,0,4.2,1.6,4.2,3.5c0,0.2,0,0.3,0,0.4c0,0.1,0,0.2,0.1,0.2c1.6,1.1,2.5,2.8,2.5,4.6c0,0.5,0,0.9-0.1,1.4c0,0.1,0,0.2,0.1,0.2c1.5,1.1,2.4,2.8,2.4,4.5c0,3.4-2.6,5.9-6.1,5.9l-1.2,0c-0.1,0-0.2,0.1-0.2,0.2l0,3.6c0.1,2.2,0.6,4.3,2.9,4.3H312l0,0.1c0,2.8,2.2,5,5,5c2.8,0,5-2.2,5-5s-2.2-5-5-5c-2.1,0-4,1.4-4.7,3.2"
                                  Height="50"
                                  StrokeThickness="1"
                                  StrokeDashCap="Flat"
                                  StrokeStartLineCap="Flat"
                                  StrokeEndLineCap="Flat"
                                  Stroke="{TemplateBinding Background}"
                                  Canvas.Left="0"
                                  Stretch="None"
                                  Canvas.Top="0"
                                  Width="323" />
                            <Path Data="M10.7,45.6C10,47.5,8.1,48.9,6,48.9c-2.8,0-5-2.2-5-5s2.2-5,5-5s5,2.2,5,5l0,0h30.3c0.1,0,0.2-0.1,0.2-0.2v-4c0-1.4,0.2-2.3,0.7-2.8c0.3-0.3,0.7-0.5,1.3-0.5H49c0.1,0,0.2-0.1,0.2-0.2v-3.6c0-1.4,0.2-2.3,0.7-2.8c0.3-0.3,0.8-0.5,1.3-0.5h3.5c0.1,0,0.2-0.1,0.2-0.2V14.7c0-2.1,0.4-3.1,1.4-3.3c0.1,0,0.3,0,0.5,0l0.2,0c3.4-0.5,3.6-4,3.6-9.6V0.6H63l0,1.1c0,5.4,0.2,9,3.8,9.6c0,0,0.1,0,0.1,0c0.1,0,0.2,0,0.3,0l0.1,0c1.5,0.4,1.5,2.7,1.5,3.4V29c0,0.1,0.1,0.2,0.3,0.2h3.2c0.6,0,1.1,0.2,1.5,0.6c0.5,0.6,0.8,1.5,0.8,2.7l0,10.6l0,0.4c0,0.1,0.1,0.2,0.2,0.2h16.6c2.4,0,2.8-2.1,3-4.3l0-3.6c0-0.1,0-0.1-0.1-0.2c0,0-0.1-0.1-0.2-0.1l-0.8,0c-3.6,0-6.1-2.3-6.1-5.6c0-1.8,0.9-3.7,2.3-4.7c0.1-0.1,0.1-0.1,0.1-0.2c-0.1-0.5-0.1-0.9-0.1-1.4c0-1.8,0.9-3.5,2.5-4.6c0.1,0,0.1-0.1,0.1-0.2c0-0.1,0-0.3,0-0.4c0-2,1.7-3.5,3.9-3.5c2.3,0,4.2,1.6,4.2,3.5c0,0.2,0,0.3,0,0.4c0,0.1,0,0.2,0.1,0.2c1.6,1.1,2.5,2.8,2.5,4.6c0,0.5,0,0.9-0.1,1.4c0,0.1,0,0.2,0.1,0.2c1.5,1.1,2.4,2.8,2.4,4.5c0,3.4-2.6,5.9-6.1,5.9l-1.2,0c-0.1,0-0.2,0.1-0.2,0.2l0,3.6c0.1,2.2,0.6,4.3,2.9,4.3h9.3c0.1,0,0.2-0.1,0.2-0.2v-2.1c0-1.3,0.2-2.2,0.7-2.7c0.3-0.3,0.7-0.4,1.2-0.4h2.5c1.9,0,1.9,2.3,1.9,3l0,2.2c0,0.1,0,0.1,0.1,0.2c0,0,0.1,0.1,0.2,0.1h2.5c0.1,0,0.1,0,0.2-0.1c0,0,0.1-0.1,0.1-0.2l0-2.2c0-0.7,0-3,1.9-3h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7v2.1c0,0.1,0.1,0.2,0.2,0.2h2.5c0.1,0,0.2-0.2,0.2-0.3l0-2c0-1.3,0.2-2.2,0.7-2.7c0.3-0.3,0.7-0.4,1.2-0.4h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7l0,0.4c0,0.1,0.1,0.2,0.2,0.2H148c0.1,0,0.2-0.1,0.2-0.3l0-4.6c0-1.5,0.2-2.5,0.7-3.1c0.3-0.3,0.8-0.5,1.3-0.5h2.2c0.1,0,0.2-0.1,0.2-0.2v-4.6c0-2.4,1.3-3.4,2.5-3.6c3.9-0.6,4.1-4.9,4-11.3l0-0.3l-0.5-2.4c0-0.1,0-0.1-0.1-0.1l-2-1.8l2.8-0.4c0.1,0,0.2-0.1,0.2-0.1l1.1-2.7l1.1,2.7c0,0.1,0.1,0.1,0.2,0.2l2.8,0.3l-2.2,1.9c0,0-0.1,0.1-0.1,0.1l-0.5,2.4c0,0,0,0,0,0.1l0,0.7c0,6.2,0.2,10.2,4.2,10.8c0.6,0.1,2.4,0.5,2.4,3.6v4.6c0,0.1,0.1,0.2,0.2,0.2h2.2c0.4,0,0.7,0.1,1,0.4c0.9,0.9,1,3.1,1,4.3v0.2c0,0.1,0.1,0.2,0.2,0.2h16.2c0.1,0,0.2-0.1,0.2-0.2c0-0.1,0-0.2-0.1-0.3c-1.1-0.8-2.5-1.9-3.6-3c-2.3-2.4-2.8-4.3-2.8-5.5c0-2.8,0.7-6,6-6c1.6,0,2.8,0.7,3.5,2c0.1,0.2,0.4,0.4,0.6,0.4c0.3,0,0.5-0.1,0.6-0.4c0.7-1.3,1.9-2,3.5-2c5.3,0,6,3.2,6,6c0,1.2-0.5,3-2.8,5.5c-1,1.1-2.5,2.2-3.6,3c-0.1,0.1-0.1,0.2-0.1,0.3c0,0.1,0.1,0.2,0.2,0.2l12.2,0c0.1,0,0.2-0.1,0.2-0.2l0-2.3c0.1-2.2,0.6-3.2,1.7-3.5l0.1,0l0.8-0.1c2.5-0.4,3.7-2.3,4-6.4l0-0.7h1.5l0.1,0.7c0.4,3.9,1.8,6,4.4,6.4l0.6,0.1l0.1,0c1.1,0.3,1.8,1.6,1.7,3.5v2.1c0,0.1,0.1,0.2,0.2,0.2h2.2c0.7,0,1.1,0.1,1.4,0.4c0.3,0.3,0.4,0.9,0.4,2.4c0,0,0,0,0,0.1l0,0.1l0,0.1c0,0.1,0.1,0.2,0.3,0.2h11c0.1,0,0.2-0.1,0.2-0.2l0-0.8c0-0.9,0.3-1.7,0.8-2.2c0.3-0.3,0.7-0.4,1.2-0.4c0,0,2.5,0,2.5,0c1.9,0,1.9,2.3,1.9,3l0,2.2c0,0.1,0,0.1,0.1,0.2c0,0,0.1,0.1,0.2,0.1h2.5c0.1,0,0.1,0,0.2-0.1c0,0,0.1-0.1,0.1-0.2l0-2.2c0-0.7,0-3,1.9-3h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7v2.1c0,0.1,0.1,0.2,0.2,0.2h2.5c0.1,0,0.2-0.2,0.3-0.3l0-2c0-1.3,0.2-2.2,0.7-2.7c0.3-0.3,0.7-0.4,1.2-0.4h2.5c0.5,0,0.9,0.1,1.2,0.4c0.5,0.5,0.7,1.3,0.7,2.7v2.1c0,0.1,0.1,0.2,0.2,0.2h11.2l0.1,0c2.3-0.1,2.7-2.2,2.8-4.3l0-3.6c0-0.1-0.1-0.2-0.2-0.2c0,0-0.8,0-0.8,0c-3.6,0-6.1-2.3-6.1-5.6c0-1.8,0.9-3.7,2.3-4.7c0.1-0.1,0.1-0.1,0.1-0.2c-0.1-0.5-0.1-0.9-0.1-1.4c0-1.8,0.9-3.5,2.5-4.6c0.1,0,0.1-0.1,0.1-0.2c0-0.1,0-0.3,0-0.4c0-2,1.7-3.5,3.9-3.5c2.3,0,4.2,1.6,4.2,3.5c0,0.2,0,0.3,0,0.4c0,0.1,0,0.2,0.1,0.2c1.6,1.1,2.5,2.8,2.5,4.6c0,0.5,0,0.9-0.1,1.4c0,0.1,0,0.2,0.1,0.2c1.5,1.1,2.4,2.8,2.4,4.5c0,3.4-2.6,5.9-6.1,5.9l-1.2,0c-0.1,0-0.2,0.1-0.2,0.2l0,3.6c0.1,2.2,0.6,4.3,2.9,4.3H312l0,0.1c0,2.8,2.2,5,5,5c2.8,0,5-2.2,5-5s-2.2-5-5-5c-2.1,0-4,1.4-4.7,3.2"
                                  x:Name="ProgressPath"
                                  StrokeThickness="2"
                                  StrokeDashCap="Flat"
                                  StrokeStartLineCap="Flat"
                                  StrokeEndLineCap="Flat"
                                  Stroke="{TemplateBinding Foreground}"
                                  Height="50"
                                  Canvas.Left="0"
                                  Stretch="None"
                                  Canvas.Top="0"
                                  Width="323" />
                        </Canvas>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>


Profit


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

При желании код ProgressPathControl можно доработать, чтобы он научился анимировать произвольные кривые. Например, можно сделать так.

Это довольно распространенный в мобильных интерфейсах прогресс-бар, которого «из коробки» в Windows Phone SDK нет. Похожий элемент управления в 2GIS для WP используется, например, при добавлении пользовательских фотографий к геообъектам, и теперь вы тоже знаете, как написать такой самостоятельно.

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


  1. rafuck
    21.01.2016 11:45
    +7

    Эта фраза прекрасна:

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


  1. Viacheslav01
    24.01.2016 19:16
    +1

    Единственное добавлю, вы проверяли, что анимация длины штриха выполняется не в UI потоке и не жрет ресурсы?


    1. volokhin
      24.01.2016 20:31

      Это хороший вопрос, я ждал его. Изменение длины штриха конечно же идет через UI поток — никакой магии не происходит. Но сама операция выходит довольно легковесная. Там где у нас используется этот прогресс-бар (загрузка городов, загрузка фотографий) мы не заметили, что его наличие как-то влияет на производительность.


      1. Viacheslav01
        24.01.2016 21:07

        Жаль, что без UI не обошлось, я честно надеялся на магию, такой трюк с заштриховкой мне понравился :)
        Беда в том, что даже самая легковесная анимация в UI не прогнозируема по затратам ресурсов, в WP 8.1 Xaml MS из за этого заставил явно указывать при создании таких анимаций, что вы знаете, что делаете.

        У меня в проекте тоже не обошлось без таких анимаций, а еще раньше пришлось отказаться от компонентов телерика именно из за того, что их движок анимации почти полностью крутился в UI.


      1. Viacheslav01
        24.01.2016 21:12

        Хотя это все же больше касается именно анимации XAML, а просто установка через UI свойства изредка, а не 60 р/с пожалуй действительно не повредит.