Сохранение рисунка в WinRT

84

У всех программ FingerPaint, рассмотренных в предыдущих статьях, был один крупный недостаток: они не позволяли сохранить рисунок. Я упоминал о том, что один из способов сохранения изображения основан на переборе элементов Line, Polyline или Path на панели Grid и создании текстового файла (вероятно, в формате XML), позволяющего воссоздать все элементы при загрузке файла.

Также рисунок может наноситься на растровое изображение, которое будет сохраняться в файле. Но при этом возникает большая проблема: версия WritableBitmap для Windows Runtime не поддерживает вывод таких элементов, как Line и Path. Фактически вам придется реализовать собственные алгоритмы рисования линий и дуг. По набору координат и других параметров эти алгоритмы должны определить, какие пикселы растрового изображения должны быть задействованы в рисовании этих графических объектов.

Допустим, вы хотите нарисовать линию между двумя точками:

Линия между двумя точками

Геометрическая линия обладает нулевой толщиной, но толщина линии на экране отлична от нуля, а в программах рисования она может быть достаточно значительной (например, 24 пиксела). Для вывода такой линии необходимо нарисовать прямоугольник, который с каждого конца геометрической линии продолжается на половину толщины (12 пикселов в нашем примере):

Пример создания линии с толщиной в 24 пиксела

Четыре угла этого прямоугольника достаточно легко вычисляются поворотом нормализованного вектора между двумя геометрическими точками на 90° и -90° и умножением на половину толщины линии. Но если ваша программа будет прорисовывать один прямоугольник для каждого события PointerMoved, эти прямоугольники не будут соединяться в кривую - между ними появятся промежутки. Чтобы их не было, можно закруглить концы отрезков:

Линии с закругленными концами

Радиус дуг составляет половину толщины линии.

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

Вероятно, вы еще помните из школьного курса уравнение прямой с угловым коэффициентом:

где m — угол наклона, a b — координата пересечения прямой и оси Y.

В традиционных средствах компьютерной графики для заполнения областей используются горизонтальные линии развертки (scan lines) — термин позаимствован из телевизионных технологий. Уравнение прямой должно представлять x как функцию y:

Для линии, соединяющей точки pt1 и pt2, коэффициенты a и b вычисляются следующим образом:

Для любого значения y (то есть для любой линии развертки), если y лежит в промежутке между pt1.y и pt2.y, это значение y соответствует точке на линии. Координата x точки может быть вычислена по уравнению прямой.

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

Если линия развертки проходит через закругления, вычисления усложняются, но ненамного. Круг радиуса r с центром в начале координат состоит из всех точек (x, y), для которых выполняется условие:

Для круга с центром в точке (xc, yc) уравнение принимает вид:

Или при выражении x как функции y:

Для любого y, если подкоренное выражение отрицательно, то y находится за пределами круга — где-то выше или ниже. В противном случае для каждого y существует (обычно) два значения x. Единственное исключение встречается при нулевом значении корня; это происходит тогда, когда y отстоит ровно на r единиц от yc (верхняя и нижняя точки круга).

С дугами, состоящими только из половины окружности, дело обстоит сложнее. Любая точка дуги находится под определенным углом относительно центра круга. Этот угол может быть вычислен методом Math.Atan2. Если нам известна начальная и конечная точка дуги, метод Math.Atan2 может вычислить углы, соответствующие этим двум точкам. Также метод Math.Atan2 может использоваться для вычисления угла произвольной точки окружности. Если точка окружности лежит в промежутке между начальной и конечной точками, значит, она находится на дуге.

В общем случае для произвольного значения y можно проанализировать две линии и две дуги и определить все точки (x, y), принадлежащие этим фигурам. Чаще всего таких точек будет две: точка входа и выхода линии развертки из фигуры. Для этой линии развертки все пикселы между двумя точками следует заполнить.

Решение FingerPaint содержит проект библиотеки с именем VectorDrawing. Библиотека содержит несколько структур, используемых для рисования линий на растровых изображениях (я оформил их как структуры, а не как классы, потому что их экземпляры будут очень часто создаваться и уничтожаться).

Структуру Vector2 из этой библиотеки вы уже видели ранее. Все остальные структуры реализуют следующий интерфейс:

using System.Collections.Generic;

namespace WinRTTestApp
{
    public interface IGeometrySegment
    {
        void GetAllX(double y, IList<double> xCollection);
    }
}

Для любого y метод GetAllX() заполняет коллекцию значений x. На практике при использовании структур библиотеки, реализующих этот интерфейс, коллекция часто возвращается пустой. Иногда она содержит одно значение, а иногда два. Структура LineSegment выглядит так:

using Windows.Foundation;
using System.Collections.Generic;

namespace WinRTTestApp
{
    public struct LineSegment : IGeometrySegment
    {
        readonly double a, b;         // as in x = ay + b
        readonly Point point1, point2;

        public LineSegment(Point point1, Point point2) : this()
        {
            this.point1 = point1;
            this.point2 = point2;

            b = point1.X - a * point1.Y;
            a = (point2.X - point1.X) / (point2.Y - point1.Y);
        }

        public void GetAllX(double y, IList<double> xCollection)
        {
            if ((point2.Y > point1.Y && y >= point1.Y && y < point2.Y) ||
                (point2.Y < point1.Y && y <= point1.Y && y > point2.Y))
            {
                xCollection.Add(a * y + b);
            }
        }
    }
}

Обратите внимание: команда if в GetAllX() проверяет, что y находится между point1.Y и point2.Y. Допустимы значения y, равные point1.Y, но не те, которые равны point2.Y. Иначе говоря, определяется отрезок, состоящий из всех точек от point1 (включительно) до point2 (не включая). В этом отношении необходимо установить жесткие правила и действовать очень внимательно, иначе при обработке соединенных линий и дуг в коллекции могут появиться дубликаты x, усложняющие работу.

В коде нет специальной обработки горизонтальных линий (у которых значение point1.Y равно point2.Y, а значение a равно бесконечности). В этом случае условие if никогда не выполняется, а линия игнорируется. Линия развертки не может пересекать горизонтальную ограничивающую линию.

Структура ArcSegment представляет обобщенную дугу на окружности:

using System;
using Windows.Foundation;
using System.Collections.Generic;

namespace WinRTTestApp
{
    public struct ArcSegment : IGeometrySegment
    {
        readonly double radius;
        readonly Point center, point1, point2;
        readonly double angle1, angle2;

        public ArcSegment(Point center, double radius,
                          Point point1, Point point2) : this()
        {
            this.point1 = point1;
            this.point2 = point2;
            this.center = center;
            this.radius = radius;
            this.angle1 = Math.Atan2(point1.Y - center.Y, point1.X - center.X);
            this.angle2 = Math.Atan2(point2.Y - center.Y, point2.X - center.X);
        }

        public void GetAllX(double y, IList<double> xCollection)
        {
            double sqrtArg = radius * radius - Math.Pow(y - center.Y, 2);

            if (sqrtArg >= 0)
            {
                double sqrt = Math.Sqrt(sqrtArg);
                TryY(y, center.X + sqrt, xCollection);
                TryY(y, center.X - sqrt, xCollection);
            }
        }

        private void TryY(double y, double x, IList<double> xCollection)
        {
            double angle = Math.Atan2(y - center.Y, x - center.X);

            if ((angle1 < angle2 && (angle1 <= angle && angle < angle2)) ||
                (angle1 > angle2 && (angle1 <= angle || angle < angle2)))
            {
                xCollection.Add((float)x);
            }
        }
    }
}

Довольно сложная (но симметричная) команда if в методе TryY() обеспечивает свертку угловых значений в диапазоне от π до -π и обратно. Сравнение angle1 с angle и angle2 подразумевает, что линия развертки считается пересекающей дугу тогда, когда значение angle равно angle1, но не angle2.

Метод GetAllX в LineSegment помещает в коллекцию нуль или одно значение x. Метод GetAllX в ArcSegment может поместить в коллекцию нуль, одно или два значения x. Структура RoundCappedLine объединяет два экземпляра LineSegment и два экземпляра ArcSegment для линии с однородной толщиной:

using System;
using Windows.Foundation;
using System.Collections.Generic;

namespace WinRTTestApp
{
    public struct RoundCappedLine : IGeometrySegment
    {
        LineSegment lineSegment2;
        ArcSegment arcSegment2;
        LineSegment lineSegment1;
        ArcSegment arcSegment1;

        public RoundCappedLine(Point point1, Point point2, double radius) : this()
        {
            Vector2 vector = new Vector2(point2 - new Vector2(point1));
            Vector2 normVect = vector;
            normVect = normVect.Normalized;

            Point pt1a = point1 + radius * new Vector2(normVect.Y, -normVect.X);
            Point pt2a = pt1a + vector;
            Point pt1b = point1 + radius * new Vector2(-normVect.Y, normVect.X);
            Point pt2b = pt1b + vector;

            lineSegment1 = new LineSegment(pt1a, pt2a);
            arcSegment1 = new ArcSegment(point2, radius, pt2a, pt2b);
            lineSegment2 = new LineSegment(pt2b, pt1b);
            arcSegment2 = new ArcSegment(point1, radius, pt1b, pt1a);
        }

        public void GetAllX(double y, IList<double> xCollection)
        {
            arcSegment1.GetAllX(y, xCollection);
            lineSegment1.GetAllX(y, xCollection);
            arcSegment2.GetAllX(y, xCollection);
            lineSegment2.GetAllX(y, xCollection);
        }
    }
}

Структура реализует GetAllX вызовом методов GetAllX в двух экземплярах LineSegment и двух экземплярах ArcSegment. Код, вызывающий GetAllX в этой структуре, обязан проследить за тем, чтобы коллекция была предварительно очищена. Метод возвращает коллекцию с нулем, одним или двумя значениями x. Первые два случая при заполнении фигур можно игнорировать. В случае двух значений x пикселы между этими двумя значениями могут быть заполнены.

Структура RoundCappedPath устроена аналогично, кроме того, что она позволяет создавать линии с переменной толщиной на сенсорных экранах:

using System;
using Windows.Foundation;
using System.Collections.Generic;

namespace WinRTTestApp
{
    public struct RoundCappedPath : IGeometrySegment
    {
        LineSegment lineSegment2;
        ArcSegment arcSegment2;
        LineSegment lineSegment1;
        ArcSegment arcSegment1;

        public RoundCappedPath(Point point1, Point point2, double radius1, double radius2) : this()
        {
            Point c0 = point1;
            Point c1 = point2;
            double r0 = radius1;
            double r1 = radius2;

            // Получение вектора из c1 в c0
            Vector2 vCenters = new Vector2(c0) - new Vector2(c1);

            // Получение длины и нормализованной версии
            double d = vCenters.Length;
            vCenters = vCenters.Normalized;

            // Создание точки фокуса F относительно c0
            double e = d * r0 / (r1 - r0);
            Point F = c0 + e * vCenters;

            // Определение угла и длины катетов
            double alpha = 180 * Math.Asin(r0 / e) / Math.PI;
            double leg0 = Math.Sqrt(e * e - r0 * r0);
            double leg1 = Math.Sqrt((e + d) * (e + d) - r1 * r1);

            // Векторы касательных
            Vector2 vRight = -vCenters.Rotate(alpha);
            Vector2 vLeft = -vCenters.Rotate(-alpha);

            // Определение точек касания
            Point t0R = F + leg0 * vRight;
            Point t0L = F + leg0 * vLeft;
            Point t1R = F + leg1 * vRight;
            Point t1L = F + leg1 * vLeft;

            lineSegment1 = new LineSegment(t1R, t0R);
            arcSegment1 = new ArcSegment(c0, r0, t0R, t0L);
            lineSegment2 = new LineSegment(t0L, t1L);
            arcSegment2 = new ArcSegment(c1, r1, t1L, t1R);
        }

        public void GetAllX(double y, IList<double> xCollection)
        {
            arcSegment1.GetAllX(y, xCollection);
            lineSegment1.GetAllX(y, xCollection);
            arcSegment2.GetAllX(y, xCollection);
            lineSegment2.GetAllX(y, xCollection);
        }
    }
}

Конечно, работать с этими структурами в реальной программе не так просто, как с экземплярами Line, Polyline или Path! Ниже приведен метод RenderOnBitma для программы FingerPaint. В этом методе используется объект WriteableBitmap с массивом пикселов pixels и объектом Stream с именем pixelStream. Метод прежде всего определяет, должен ли он использовать RoundCappedLine:

using System;
using System.Linq;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Windows.Foundation;
using Windows.Graphics.Imaging;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.Storage.Streams;
using Windows.UI;
using Windows.UI.Input;
using Windows.UI.Popups;
using Windows.UI.ViewManagement;
using Windows.UI.WebUI;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Imaging;
using System.Runtime.InteropServices.WindowsRuntime;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        // ...

        private bool RenderOnBitmap(Point point1, double radius1, Point point2, double radius2, Color color)
        {
            bool bitmapNeedsUpdate = false;

            // Определение линии между двумя точками
            IGeometrySegment geoseg = null;

            // Поправка на масштабирование изображения
            Point center1 = ScaleToBitmap(point1);
            Point center2 = ScaleToBitmap(point2);

            // Вычисление расстояния между точками
            double distance = Math.Sqrt(Math.Pow(center2.X - center1.X, 2) +
                                        Math.Pow(center2.Y - center1.Y, 2));

            // Определение правильного способа представления сегмента
            if (radius1 == radius2)
                geoseg = new RoundCappedLine(center1, center2, radius1);

            else if (radius1 < radius2 && radius1 + distance < radius2)
                geoseg = new RoundCappedLine(center1, center2, radius2);

            else if (radius2 < radius1 && radius2 + distance < radius1)
                geoseg = new RoundCappedLine(center1, center2, radius1);

            else if (radius1 < radius2)
                geoseg = new RoundCappedPath(center1, center2, radius1, radius2);

            else
                geoseg = new RoundCappedPath(center2, center1, radius2, radius1);

            // Определение минимальной и максимальной вертикальных координат
            int yMin = (int)Math.Min(center1.Y - radius1, center2.Y - radius2);
            int yMax = (int)Math.Max(center1.Y + radius1, center2.Y + radius2);

            yMin = Math.Max(0, Math.Min(bitmap.PixelHeight, yMin));
            yMax = Math.Max(0, Math.Min(bitmap.PixelHeight, yMax));

            // Перебор всех координат y, содержащих часть сегмента
            for (int y = yMin; y < yMax; y++)
            {
                // Получение диапазона координат в сегменте
                xCollection.Clear();
                geoseg.GetAllX(y, xCollection);

                if (xCollection.Count == 2)
                {
                    // Определение минимальной и максимальной горизонтальных координат
                    int xMin = (int)(Math.Min(xCollection[0], xCollection[1]) + 0.5f);
                    int xMax = (int)(Math.Max(xCollection[0], xCollection[1]) + 0.5f);

                    xMin = Math.Max(0, Math.Min(bitmap.PixelWidth, xMin));
                    xMax = Math.Max(0, Math.Min(bitmap.PixelWidth, xMax));

                    // Перебор значений X
                    for (int x = xMin; x < xMax; x++)
                    {
                        {
                            // Задание пиксела
                            int index = 4 * (y * bitmap.PixelWidth + x);
                            pixels[index + 0] = color.B;
                            pixels[index + 1] = color.G;
                            pixels[index + 2] = color.R;
                            pixels[index + 3] = 255;

                            bitmapNeedsUpdate = true;
                        }
                    }
                }
            }
            // Обновление растрового изображения
            if (bitmapNeedsUpdate)
            {
                // Определение начального индекса и количества пикселов
                int start = 4 * yMin * bitmap.PixelWidth;
                int count = 4 * (yMax - yMin) * bitmap.PixelWidth;

                pixelStream.Seek(start, SeekOrigin.Begin);
                pixelStream.Write(pixels, start, count);
                bitmap.Invalidate();
            }

            return bitmapNeedsUpdate;
        }

        private Point ScaleToBitmap(Point pt)
        {
            return new Point((pt.X - imageOffset.X) / imageScale,
                             (pt.Y - imageOffset.Y) / imageScale);
        }
    }
}

Обратите внимание: метод RenderOnBitmap завершается ограничением обновления линиями развертки, задействованными в конкретной операции. Метод ScaleToBitmap вносит поправку в координаты точек для растровых изображений, меньших или больших текущего размера страницы. Чтобы организовать разбиение файлов с исходным кодом в проекте FingerPaint на функциональные блоки, я разделил логику фонового кода MainPage на три файла:

Остаток файла MainPage.Pointer.cs выглядит очень знакомо. Если не считать вызова RenderOnBitmap, он не отличается от аналогичного файла из проекта FingerPaint5:

// ...

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        struct PointerInfo
        {
            public Brush Brush;
            public Point PreviousPoint;
            public double PreviousRadius;
        }

        Dictionary<uint, PointerInfo> pointerDictionary = new Dictionary<uint, PointerInfo>();
        List<double> xCollection = new List<double>();

        protected override void OnPointerPressed(PointerRoutedEventArgs e)
        {
            // Получение информации об аргументах события
            uint id = e.Pointer.PointerId;
            PointerPoint pointerPoint = e.GetCurrentPoint(this);

            // Создать объект PointerInfo
            PointerInfo pointerInfo = new PointerInfo
            {
                PreviousPoint = pointerPoint.Position,
                PreviousRadius = appSettings.Thickness * pointerPoint.Properties.Pressure,
                Brush = new SolidColorBrush(appSettings.Color)
            };

            // Включить в словарь
            pointerDictionary.Add(id, pointerInfo);

            // Захват указателя
            CapturePointer(e.Pointer);

            base.OnPointerPressed(e);
        }

        protected override void OnPointerMoved(PointerRoutedEventArgs e)
        {
            // Получение идентификатора из аргументов события
            uint id = e.Pointer.PointerId;

            // Если идентификатор находится в словаре, начинается цикл
            if (pointerDictionary.ContainsKey(id))
            {
                PointerInfo pointerInfo = pointerDictionary[id];

                foreach (PointerPoint pointerPoint in e.GetIntermediatePoints(this).Reverse())
                {
                    // Для каждой точки определяется новая позиция и сила нажатия
                    Point point = pointerPoint.Position;
                    double radius = appSettings.Thickness * pointerPoint.Properties.Pressure;

                    // Вывод и установка флага модификации
                    appSettings.IsImageModified =
                        RenderOnBitmap(pointerInfo.PreviousPoint, pointerInfo.PreviousRadius,
                                       point, radius,
                                       appSettings.Color);

                    // Обновить объект PointerInfo
                    pointerInfo.PreviousPoint = point;
                    pointerInfo.PreviousRadius = radius;
                }

                // Сохранить PointerInfo в словаре
                pointerDictionary[id] = pointerInfo;
            }
            base.OnPointerMoved(e);
        }

        protected override void OnPointerReleased(PointerRoutedEventArgs e)
        {
            // Получение информации из аргументов события
            uint id = e.Pointer.PointerId;

            // Если идентификатор хранится в словаре, удалить его
            if (pointerDictionary.ContainsKey(id))
                pointerDictionary.Remove(id);

            base.OnPointerReleased(e);
        }

        protected override void OnPointerCaptureLost(PointerRoutedEventArgs e)
        {
            // Получение информации из аргументов события
            uint id = e.Pointer.PointerId;

            // Если идентификатор хранится в словаре, удалить его
            if (pointerDictionary.ContainsKey(id))
                pointerDictionary.Remove(id);

            base.OnPointerCaptureLost(e);
        }

        private bool RenderOnBitmap(Point point1, double radius1, Point point2, double radius2, Color color)
        {
            // ...
        }

        private Point ScaleToBitmap(Point pt)
        {
            // ...
        }
        
        // ...
    }
}

Оба метода, OnPointerPressed и OnPointerMoved, обращаются к полю appSettings типа AppSettings. Этот объект сохраняет конфигурацию приложения в локальном хранилище при приостановке программы и загружает ее при продолжении работы. Вероятно, общая структура класса к этому моменту вам покажется знакомой:

using Windows.UI;
using Windows.Storage;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WinRTTestApp
{
    public class AppSettings : INotifyPropertyChanged
    {
        // Исходные значения параметров конфигурации приложения
        string loadedFilePath = null;
        string loadedFilename = null;
        bool isImageModified = false;
        Color color = Colors.Blue;
        double thickness = 16;

        public event PropertyChangedEventHandler PropertyChanged;

        public AppSettings()
        {
            ApplicationDataContainer appData = ApplicationData.Current.LocalSettings;

            if (appData.Values.ContainsKey("LoadedFilePath"))
                this.LoadedFilePath = (string)appData.Values["LoadedFilePath"];

            if (appData.Values.ContainsKey("LoadedFilename"))
                this.LoadedFilename = (string)appData.Values["LoadedFilename"];

            if (appData.Values.ContainsKey("IsImageModified"))
                this.IsImageModified = (bool)appData.Values["IsImageModified"];

            if (appData.Values.ContainsKey("Color.Red") &&
                appData.Values.ContainsKey("Color.Green") &&
                appData.Values.ContainsKey("Color.Blue"))
            {
                this.Color = Color.FromArgb(255,
                                            (byte)appData.Values["Color.Red"],
                                            (byte)appData.Values["Color.Green"],
                                            (byte)appData.Values["Color.Blue"]);
            }

            if (appData.Values.ContainsKey("Thickness"))
                this.Thickness = (double)appData.Values["Thickness"];

        }

        public string LoadedFilePath
        {
            set { SetProperty<string>(ref loadedFilePath, value); }
            get { return loadedFilePath; }
        }

        public string LoadedFilename
        {
            set { SetProperty<string>(ref loadedFilename, value); }
            get { return loadedFilename; }
        }

        public bool IsImageModified
        {
            set { SetProperty<bool>(ref isImageModified, value); }
            get { return isImageModified; }
        }

        public Color Color
        {
            set { SetProperty<Color>(ref color, value); }
            get { return color; }
        }

        public double Thickness
        {
            set { SetProperty<double>(ref thickness, value); }
            get { return thickness; }
        }

        public void Save()
        {
            ApplicationDataContainer appData = ApplicationData.Current.LocalSettings;
            appData.Values.Clear();
            appData.Values.Add("LoadedFilePath", this.LoadedFilePath);
            appData.Values.Add("LoadedFilename", this.LoadedFilename);
            appData.Values.Add("IsImageModified", this.IsImageModified);
            appData.Values.Add("Color.Red", this.Color.R);
            appData.Values.Add("Color.Green", this.Color.G);
            appData.Values.Add("Color.Blue", this.Color.B);
            appData.Values.Add("Thickness", this.Thickness);
        }

        protected bool SetProperty<T>(ref T storage, T value,
                                      [CallerMemberName] string propertyName = null)
        {
            if (object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

В частности, программа FingerPaint позволяет загрузить существующий файл, что-нибудь нарисовать на нем и сохранить результат. Если вы ее используете именно таким образом, имя и полный путь к файлу являются частью пользовательской конфигурации. Если же рисование начинается с пустого «холста», свойства LoadedFilename и LoadedFilePath будут равны null.

В любом случае свойство IsImageModified содержит true, если изображение было изменено, но не сохранено в файле. Файл MainPage.xaml просто создает элемент Image и реализует строку приложения:

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Image Name="image" />

        <!-- Блокировка кнопок файлового ввода/вывода в состоянии Snapped -->
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="ApplicationViewStates">
                <VisualState x:Name="FullScreenLandscape" />
                <VisualState x:Name="Filled" />
                <VisualState x:Name="FullScreenPortrait" />

                <VisualState x:Name="Snapped">
                    <Storyboard>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="fileButtons"
                                                   Storyboard.TargetProperty="Visibility">
                            <DiscreteObjectKeyFrame KeyTime="0" Value="Collapsed" />
                        </ObjectAnimationUsingKeyFrames>
                    </Storyboard>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
    </Grid>

    <Page.BottomAppBar>
        <AppBar>
            <Grid>
                <StackPanel Orientation="Horizontal" 
                            HorizontalAlignment="Left">

                    <AppBarButton Label="Цвет" Icon="FontColor"
                            Name="colorbtn"
                            Click="colorbtn_Click" />

                    <AppBarButton Label="Толщина" Icon="Edit" Name="thicknessbtn"
                            Click="thicknessbtn_Click" />
                </StackPanel>

                <StackPanel Name="fileButtons"
                            Orientation="Horizontal" 
                            HorizontalAlignment="Right">

                    <AppBarButton Label="Открыть файл" Icon="OpenFile" Name="openbtn"
                            Click="openbtn_Click" />

                    <AppBarButton Label="Сохранить как" Icon="SaveLocal" Name="saveasbtn"
                            Click="saveasbtn_Click" />

                    <AppBarButton Label="Сохранить" Icon="Save" Name="savebtn"
                            Click="savebtn_Click" />

                    <AppBarButton Label="Добавить" Icon="Add" Name="addbtn"
                            Click="addbtn_Click" />
                </StackPanel>
            </Grid>
        </AppBar>
    </Page.BottomAppBar>
</Page>

Обратите внимание на разметку VisualStateManager, которая заставляет все кнопки файлового ввода/вывода в строке приложения исчезать при переходе приложения в состояние Snapped. Дело даже не в том, чтобы избежать перекрытия кнопок: средства выбора файлов не работают в состоянии Snapped.

Объединяя все компоненты программы, я столкнулся с небольшой проблемой, связанной с файловым вводом/выводом и перезапуском приложения после завершения. Предположим, пользователь активизирует FileOpenPicker для выбора существующего файла из библиотеки Pictures. Программа получает объект StorageFile от FileOpenPicker, использует его для открытия файла и сохраняет объект StorageFile в поле. Затем, когда пользователь нажимает кнопку "Сохранить" в строке приложения, программа просто использует этот объект StorageFile для сохранения файла.

Но допустим, программа была приостановлена или завершена с последующим перезапуском. Объект StorageFile не может быть сохранен в локальном хранилище приложения! Вместо этого программе приходится сохранять полный путь к файлу (как это сделано в классе AppSettings). Когда программа будет перезапущена, а пользователь нажмет кнопку "Сохранить", у приложения не будет объекта StorageFile для этого файла. Вместо этого оно должно создать объект с использованием статического метода StorageFile.GetFileFromPathAsync. Однако использование метода означает, что программа обращается к файловой системе без использования объекта StorageFile, полученного от средства выбора файлов.

По этой причине программе FingerPaint необходимо разрешение для обращения к библиотеке Pictures. В Visual Studio я вызвал свойства Package.appxmanifest, перешел на вкладку Capabilities и установил флажок Pictures Library. Я не хотел предоставлять программе это специальное разрешение, но единственной альтернативой была необходимость сохранения ранее загруженного файла с использованием FileSavePicker.

Ниже приведен файл MainPage.File.cs. Логика загрузки и сохранения растровых изображений уже знакома вам по предыдущим программам, а логика запроса на сохранение измененного рисунка — по приложению XamlCruncher:

// ...

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        // ...

        WriteableBitmap bitmap;
        Stream pixelStream;
        byte[] pixels;

        private async Task CreateNewBitmapAndPixelArray()
        {
            bitmap = new WriteableBitmap((int)this.ActualWidth, (int)this.ActualHeight);
            pixels = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 4];

            // Все изображение заполняется белым цветом
            for (int index = 0; index < pixels.Length; index++)
                pixels[index] = 0xFF;

            await InitializeBitmap();

            appSettings.LoadedFilePath = null;
            appSettings.LoadedFilename = null;
            appSettings.IsImageModified = false;
        }

        private async Task LoadBitmapFromFile(StorageFile storageFile)
        {
            using (IRandomAccessStreamWithContentType stream = await storageFile.OpenReadAsync())
            {
                BitmapDecoder decoder = await BitmapDecoder.CreateAsync(stream);
                BitmapFrame bitmapframe = await decoder.GetFrameAsync(0);
                PixelDataProvider dataProvider =
                    await bitmapframe.GetPixelDataAsync(BitmapPixelFormat.Bgra8,
                                                        BitmapAlphaMode.Premultiplied,
                                                        new BitmapTransform(),
                                                        ExifOrientationMode.RespectExifOrientation,
                                                        ColorManagementMode.ColorManageToSRgb);
                pixels = dataProvider.DetachPixelData();
                bitmap = new WriteableBitmap((int)bitmapframe.PixelWidth,
                                             (int)bitmapframe.PixelHeight);
                await InitializeBitmap();
            }
        }

        private async Task InitializeBitmap()
        {
            pixelStream = bitmap.PixelBuffer.AsStream();
            await pixelStream.WriteAsync(pixels, 0, pixels.Length);
            bitmap.Invalidate();
            image.Source = bitmap;
            CalculateImageScaleAndOffset();
        }

        private async void addbtn_Click(object sender, RoutedEventArgs e)
        {
            Button button = sender as Button;
            button.IsEnabled = false;

            await CheckIfOkToTrashFile(CreateNewBitmapAndPixelArray);

            button.IsEnabled = true;
            this.BottomAppBar.IsOpen = false;
        }

        private async void openbtn_Click(object sender, RoutedEventArgs e)
        {
            Button button = sender as Button;
            button.IsEnabled = false;

            await CheckIfOkToTrashFile(LoadFileFromOpenPicker);

            button.IsEnabled = true;
            this.BottomAppBar.IsOpen = false;
        }

        private async Task CheckIfOkToTrashFile(Func<Task> commandAction)
        {
            if (!appSettings.IsImageModified)
            {
                await commandAction();
                return;
            }

            string message =
                String.Format("Вы действительно хотите сохранить изменения в {0}?",
                              appSettings.LoadedFilePath ?? "(не задано)");

            MessageDialog msgdlg = new MessageDialog(message, "Рисуем пальцем");
            msgdlg.Commands.Add(new UICommand("Сохранить", null, "save"));
            msgdlg.Commands.Add(new UICommand("Не сохранять", null, "dont"));
            msgdlg.Commands.Add(new UICommand("Отмена", null, "cancel"));
            msgdlg.DefaultCommandIndex = 0;
            msgdlg.CancelCommandIndex = 2;
            IUICommand command = await msgdlg.ShowAsync();

            if ((string)command.Id == "cancel")
                return;

            if ((string)command.Id == "dont")
            {
                await commandAction();
                return;
            }

            if (appSettings.LoadedFilePath == null)

            {
                StorageFile storageFile = await GetFileFromSavePicker();

                if (storageFile == null)
                    return;

                appSettings.LoadedFilePath = storageFile.Path;
                appSettings.LoadedFilename = storageFile.Name;
            }

            string exception = null;

            try
            {
                await SaveBitmapToFile(appSettings.LoadedFilePath);
            }
            catch (Exception exc)
            {
                exception = exc.Message;
            }

            if (exception != null)
            {
                msgdlg = new MessageDialog("Изображение не может быть сохранено. " +
                                           "Системная ошибка: " + exception,
                                           "Рисуем пальцем");
                await msgdlg.ShowAsync();
                return;
            }

            await commandAction();
        }

        private async Task LoadFileFromOpenPicker()
        {
            // Создание объекта FileOpenPicker
            FileOpenPicker picker = new FileOpenPicker();
            picker.SuggestedStartLocation = PickerLocationId.PicturesLibrary;

            // Инициализация расширениями файлов
            IReadOnlyList<BitmapCodecInformation> codecInfos =
                                    BitmapDecoder.GetDecoderInformationEnumerator();

            foreach (BitmapCodecInformation codecInfo in codecInfos)
                foreach (string extension in codecInfo.FileExtensions)
                    picker.FileTypeFilter.Add(extension);

            // Получение выбранного файла
            StorageFile storageFile = await picker.PickSingleFileAsync();

            if (storageFile == null)
                return;

            string exception = null;

            try
            {
                await LoadBitmapFromFile(storageFile);
            }
            catch (Exception exc)
            {
                exception = exc.Message;
            }

            if (exception != null)
            {
                MessageDialog msgdlg =
                    new MessageDialog("Файл изображения не может быть загружен. " +
                                      "Системная ошибка: " + exception,
                                      "Рисуем пальцем");
                await msgdlg.ShowAsync();
                return;
            }

            appSettings.LoadedFilePath = storageFile.Path;
            appSettings.LoadedFilename = storageFile.Name;
            appSettings.IsImageModified = false;
        }

        private async void savebtn_Click(object sender, RoutedEventArgs e)
        {
            Button button = sender as Button;
            button.IsEnabled = false;

            if (appSettings.LoadedFilePath != null)
            {
                await SaveWithErrorNotification(appSettings.LoadedFilePath);
            }
            else
            {
                StorageFile storageFile = await GetFileFromSavePicker();

                if (storageFile == null)
                    return;

                await SaveWithErrorNotification(storageFile);
            }

            button.IsEnabled = true;
        }

        private async void saveasbtn_Click(object sender, RoutedEventArgs e)
        {
            StorageFile storageFile = await GetFileFromSavePicker();

            if (storageFile == null)
                return;

            await SaveWithErrorNotification(storageFile);
        }

        private async Task<StorageFile> GetFileFromSavePicker()
        {
            FileSavePicker picker = new FileSavePicker();
            picker.SuggestedStartLocation = PickerLocationId.PicturesLibrary;
            picker.SuggestedFileName = appSettings.LoadedFilename ?? "MyFingerPainting";

            // Получение информации о кодере
            Dictionary<string, Guid> imageTypes = new Dictionary<string, Guid>();
            IReadOnlyList<BitmapCodecInformation> codecInfos =
                                    BitmapEncoder.GetEncoderInformationEnumerator();

            foreach (BitmapCodecInformation codecInfo in codecInfos)
            {
                List<string> extensions = new List<string>();

                foreach (string extension in codecInfo.FileExtensions)
                    extensions.Add(extension);

                string filetype = codecInfo.FriendlyName.Split(' ')[0];
                picker.FileTypeChoices.Add(filetype, extensions);

                foreach (string mimeType in codecInfo.MimeTypes)
                    imageTypes.Add(mimeType, codecInfo.CodecId);
            }

            // Получение объекта StorageFile для выбранного файла
            return await picker.PickSaveFileAsync();
        }

        private async Task<bool> SaveWithErrorNotification(string filename)
        {
            StorageFile storageFile = await StorageFile.GetFileFromPathAsync(filename);
            return await SaveWithErrorNotification(storageFile);
        }

        private async Task<bool> SaveWithErrorNotification(StorageFile storageFile)
        {
            string exception = null;

            try
            {
                await SaveBitmapToFile(storageFile);
            }
            catch (Exception exc)
            {
                exception = exc.Message;
            }

            if (exception != null)
            {
                MessageDialog msgdlg =
                    new MessageDialog("Изображение не может быть сохранено. " +
                                      "Системная ошибка: " + exception,
                                      "Рисуем пальцем");
                await msgdlg.ShowAsync();
                return false;
            }

            appSettings.LoadedFilePath = storageFile.Path;
            appSettings.IsImageModified = false;
            return true;
        }

        private async Task SaveBitmapToFile(string filename)
        {
            StorageFile storageFile = await StorageFile.GetFileFromPathAsync(filename);
            await SaveBitmapToFile(storageFile);
        }

        private async Task SaveBitmapToFile(StorageFile storageFile)
        {
            using (IRandomAccessStream fileStream = await storageFile.OpenAsync(FileAccessMode.ReadWrite))
            {
                BitmapEncoder encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, fileStream);
                encoder.SetPixelData(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied,
                                     (uint)bitmap.PixelWidth, (uint)bitmap.PixelHeight,
                                     96, 96, pixels);
                await encoder.FlushAsync();
            }
        }
        
        // ...
    }
}

Файл MainPage.xaml.es, приведенный далее, имеет несколько обязанностей. Код файла сохраняет конфигурацию приложения при приостановке программы и восстанавливает ее при запуске. Также он сохраняет текущее изображение и перезагружает его. В его логике используются методы из файла MainPage.File.cs, но, конечно, он должен игнорировать все возникающие исключения.

Файл также отвечает за обработку события SizeChanged — как при задании визуального состояния в файле XAML, так и при задании полей imageScale и imageOffset. В зависимости от исходного размера растрового изображения, ориентации экрана и состояния Snap View, изображение, служащее «холстом» для рисования, может быть меньше или больше страницы. Оно всегда отображается с максимально возможным размером без нарушения пропорций, но при рисовании может возникнуть необходимость в преобразовании координат точки касания в координаты изображения:

// ...

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        // ...

        AppSettings appSettings = new AppSettings();
        double imageScale = 1;
        Point imageOffset = new Point();

        public MainPage()
        {
            this.InitializeComponent();

            SizeChanged += page_SizeChanged;
            Loaded += page_Loaded;
            Application.Current.Suspending += app_Suspending;
        }

        private void page_SizeChanged(object sender, SizeChangedEventArgs e)
        {
            VisualStateManager.GoToState(this, ApplicationView.Value.ToString(), true);

            if (bitmap != null)
            {
                CalculateImageScaleAndOffset();
            }
        }

        private void CalculateImageScaleAndOffset()
        {
            imageScale = Math.Min(this.ActualWidth / bitmap.PixelWidth,
                                  this.ActualHeight / bitmap.PixelHeight);
            imageOffset = new Point((this.ActualWidth - imageScale * bitmap.PixelWidth) / 2,
                                    (this.ActualHeight - imageScale * bitmap.PixelHeight) / 2);
        }

        private async void page_Loaded(object sender, RoutedEventArgs e)
        {
            try
            {
                /* StorageFolder localFolder = ApplicationData.Current.LocalFolder;
                StorageFile storageFile = await localFolder.GetFileAsync("FingerPaint.png");
                await LoadBitmapFromFile(storageFile); */
            }
            catch
            {
                // Ошибки игнорируются
            }

            if (bitmap == null)
                await CreateNewBitmapAndPixelArray();
        }

        private async void app_Suspending(object sender, Windows.ApplicationModel.SuspendingEventArgs e)
        {
            // Сохранение конфигурации приложения
            appSettings.Save();

            // Сохранение текущего изображения
            Windows.ApplicationModel.SuspendingDeferral deferral = 
                e.SuspendingOperation.GetDeferral();

            try
            {
                StorageFolder localFolder = ApplicationData.Current.LocalFolder;
                StorageFile storageFile = await localFolder.CreateFileAsync("FingerPaint.png",
                                                        CreationCollisionOption.ReplaceExisting);
                await SaveBitmapToFile(storageFile);
            }
            catch
            {
                // Игнорировать ошибки
            }

            deferral.Complete();
        }

        // ...
    }
}

Файл MainPage.xaml.cs также отвечает за обработку кнопок строки приложения Color и Thickness; они вызывают объекты Popup, содержащие экземпляры классов, производных от UserControl, с именами ColorSettingDialog и ThicknessSettingDialog:

// ...

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        // ...

        private void colorbtn_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            // Добавим реализацию в следующей статье
            // DisplayDialog(sender, new ColorSettingDialog());
        }

        private void thicknessbtn_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            DisplayDialog(sender, new ThicknessSettingDialog());
        }

        private void DisplayDialog(object sender, FrameworkElement dialog)
        {
            dialog.DataContext = appSettings;

            Popup popup = new Popup
            {
                Child = dialog,
                IsLightDismissEnabled = true
            };

            dialog.SizeChanged += (dialogSender, dialogArgs) =>
            {
                // Определение местонахождения Button относительно экрана
                Button btn = sender as Button;
                Point pt = btn.TransformToVisual(null).TransformPoint(
                                                            new Point(btn.ActualWidth / 2,
                                                                      btn.ActualHeight / 2));

                popup.HorizontalOffset = Math.Max(24, pt.X - dialog.ActualWidth / 2);

                if (popup.HorizontalOffset + dialog.ActualWidth > this.ActualWidth)
                    popup.HorizontalOffset = this.ActualWidth - dialog.ActualWidth;

                popup.HorizontalOffset = Math.Max(0, popup.HorizontalOffset);

                popup.VerticalOffset = this.ActualHeight - dialog.ActualHeight
                                                         - this.BottomAppBar.ActualHeight - 24;
            };

            popup.Closed += (popupSender, popupArgs) =>
            {
                this.BottomAppBar.IsOpen = false;
            };

            popup.IsOpen = true;
        }
    }
}

Безусловно, диалоговое окно ThicknessSettingDialog является более простым. Оно просто содержит список ListBox с набором допустимых значений толщины линии. Я хотел, чтобы список включал степени 2 (2, 4, 8, 16, 32), а также некоторые промежуточные значения, поэтому следующий член последовательности вычисляется умножением предыдущего на кубический корень из 2, с округлением и устранением избыточности:

<UserControl ...>

    <Grid>
        <Border Background="White" BorderBrush="Black"
                BorderThickness="3" Padding="32">

            <ListBox SelectedItem="{Binding Thickness, Mode=TwoWay}"
                     Width="150">
                <x:Double>2</x:Double>
                <x:Double>3</x:Double>
                <x:Double>4</x:Double>
                <x:Double>5</x:Double>
                <x:Double>6</x:Double>
                <x:Double>8</x:Double>
                <x:Double>10</x:Double>
                <x:Double>13</x:Double>
                <x:Double>16</x:Double>
                <x:Double>20</x:Double>
                <x:Double>25</x:Double>
                <x:Double>32</x:Double>
                <x:Double>40</x:Double>

                <ListBox.Foreground>
                    <SolidColorBrush Color="{Binding Color}" />
                </ListBox.Foreground>

                <ListBox.ItemContainerStyle>
                    <Style TargetType="ListBoxItem">
                        <Setter Property="Background" Value="Transparent" />
                        <Setter Property="Template">
                            <Setter.Value>
                                <ControlTemplate TargetType="ListBoxItem">
                                    <Grid>
                                        <VisualStateManager.VisualStateGroups>
                                            <VisualStateGroup x:Name="SelectionStates">
                                                <VisualState x:Name="Unselected">
                                                    <Storyboard>
                                                        <ObjectAnimationUsingKeyFrames 
                                                            Storyboard.TargetName="border"
                                                            Storyboard.TargetProperty="BorderBrush">
                                                            <DiscreteObjectKeyFrame 
                                                                            KeyTime="0" 
                                                                            Value="Transparent" />
                                                        </ObjectAnimationUsingKeyFrames>
                                                    </Storyboard>
                                                </VisualState>

                                                <VisualState x:Name="Selected" />
                                                <VisualState x:Name="SelectedUnfocused" />
                                                <VisualState x:Name="SelectedDisabled" />
                                                <VisualState x:Name="SelectedPointerOver" />
                                                <VisualState x:Name="SelectedPressed" />
                                            </VisualStateGroup>
                                        </VisualStateManager.VisualStateGroups>

                                        <Border Name="border"
                                                BorderBrush="Black"
                                                BorderThickness="1"
                                                Background="Transparent"
                                                Padding="12">

                                            <ContentPresenter Content="{TemplateBinding Content}" />

                                        </Border>
                                    </Grid>
                                </ControlTemplate>
                            </Setter.Value>
                        </Setter>
                    </Style>
                </ListBox.ItemContainerStyle>

                <ListBox.ItemTemplate>
                    <DataTemplate>
                        <Grid Height="{Binding}"
                              Width="120">
                            <Canvas VerticalAlignment="Center"
                                    HorizontalAlignment="Center">
                                <Polyline Points="-36 0 36 0"
                                  Stroke="{Binding RelativeSource={RelativeSource TemplatedParent}, 
                                                   Path=Foreground}"
                                  StrokeThickness="{Binding}"
                                  StrokeStartLineCap="Round"
                                  StrokeEndLineCap="Round" />
                            </Canvas>
                        </Grid>
                    </DataTemplate>
                </ListBox.ItemTemplate>
            </ListBox>
        </Border>
    </Grid>
</UserControl>

Конечно, все «волшебство» происходит в шаблонах. (В файле фонового кода нет ничего, кроме вызова InitializeComponent.) ItemTemplate использует значение ListBox как толщину линии, a ItemContainerStyle рисует прямоугольник вокруг выбранного варианта.

Рисование на изображении

Когда MainPage выводит это диалоговое окно, свойству DataContext задается экземпляр AppSettings, так что значение свойства Thickness в этом классе обновляется через привязку данных. Значение используется во всех последующих операциях рисования до следующего изменения.

Пройди тесты
Лучший чат для C# программистов