WPF Series -10- InkCanvas with initial StrokeCollection

One of the requirements our WPF project had to meet describes the ability to create a sketch of the sewage shaft for which data is collected. The functional acceptance criteria are the following.

  • There has to be an area on the form to create a sketch of the sewage shaft using Surface Pen, touchscreen or mouse
  • On the sketches area there have to be the following initial geometric figures (same as on the paper version of the protocol)
    • 1 horizontal line from the left border to the right border of the area that divides the area in two equal parts
    • 1 circle on the left side of the area whose center lies on the horizontal line
    • 1 ellipse on the right side of the area whose center lies on the horizontal line
    • 1 vertical line from the top border to the bottom border of the area that goes through the center of the circle
    • 1 vertical line from the top border to the bottom border of the area that goes through the center of the ellipse
  • Sketch has to be storable together with the protocol
  • Sketch of an existing protocol has to be reloadable
  • Sketch of an existing protocol has to be editable
  • Custom Strokes of a sketch have to be deletable

After some research we decided to use InkCanvas control to realize the sketches area. InkCanvas class defines a property called Strokes, which is of type StrokeCollection, that holds all the stroke objects contained within the InkCanvas. To support storing and reloading (i.e. to/from a file) StrokeCollection can be saved to a MemoryStream and then the MemoryStrema can be written to a byte array. However to meet all the acceptance criteria we somehow had to add the initial geometric figures. Unfortunately it does not seem possible to transform predefined geometric figures like circles and ellipses to strokes for adding them to a StrokeCollection so we decided to create these strokes manually.

Let’s have a look at the code.

StrokeCollectionBuilder.cs

We first implemented a class called StrokeCollectionBuilder that allows building the initial geometric figures. For performance reasons the unit circle and unit ellipse points get calculated in the static constructor using angular methods of System.Math class (i.e. Math.Sin(...) and Math.Cos()). The StrokeCollectionBuilder class implements the builder pattern and provides methods to build and add the necessary strokes to the private StrokeCollection property. The resulting StrokeCollection can be returned by calling GetStrokeCollection() method.

/**
 * Copyright 2017 d-fens GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Windows.Ink;
using System.Windows.Input;
using biz.dfch.CS.Commons.Linq;

namespace biz.dfch.CS.ArbitraryWpfApplication.UI.Geometry
{
    public class StrokeCollectionBuilder
    {
        private const int FULL_CIRCLE_ANGLE = 360;
        private static readonly ICollection<Tuple<double, double>> _unitCirclePoints;
        private static readonly ICollection<Tuple<double, double>> _unitEllipsePoints;

        private readonly StrokeCollection strokeCollection;

        static StrokeCollectionBuilder()
        {
            _unitCirclePoints = new List<Tuple<double, double>>();

            for (var angle = 0; angle < FULL_CIRCLE_ANGLE; angle++)
            {
                var x = Math.Sin(angle);
                var y = Math.Cos(angle);
                _unitCirclePoints.Add(new Tuple<double, double>(x, y));
            }

            _unitEllipsePoints = new List<Tuple<double, double>>();

            for (var angle = 0; angle < FULL_CIRCLE_ANGLE; angle++)
            {
                var x = Math.Cos(angle);
                var y = Math.Sin(angle);
                _unitEllipsePoints.Add(new Tuple<double, double>(x, y));
            }
        }

        public StrokeCollectionBuilder()
        {
            strokeCollection = new StrokeCollection();
        }

        public StrokeCollectionBuilder BuildHorizontalStroke(double xStart, double xEnd, double y)
        {
            Contract.Requires(xStart >= 0);
            Contract.Requires(xEnd > xStart);
            Contract.Requires(y >= 0);

            var horizontalLinePoints = new StylusPointCollection()
            {
                new StylusPoint(xStart, y),
                new StylusPoint(xEnd, y)
            };

            strokeCollection.Add(new Stroke(horizontalLinePoints));

            return this;
        }

        public StrokeCollectionBuilder BuildVerticalStroke(double x, double yStart, double yEnd)
        {
            Contract.Requires(x >= 0);
            Contract.Requires(yStart >= 0);
            Contract.Requires(yEnd > yStart);

            var verticalLinePoints = new StylusPointCollection()
            {
                new StylusPoint(x, yStart),
                new StylusPoint(x, yEnd)
            };

            strokeCollection.Add(new Stroke(verticalLinePoints));

            return this;
        }

        public StrokeCollectionBuilder BuildCircleStroke(double centerX, double centerY, double radius)
        {
            Contract.Requires(centerX >= radius);
            Contract.Requires(centerY >= radius);
            Contract.Requires(radius > 0);

            var circlePoints = new StylusPointCollection();

            _unitCirclePoints.ForEach(
                ucp => circlePoints.Add(
                    new StylusPoint(centerX + ucp.Item1 * radius, centerY + ucp.Item2 * radius)
                )
            );

            var circleStroke = new Stroke(circlePoints);
            strokeCollection.Add(circleStroke);

            return this;
        }

        public StrokeCollectionBuilder BuildEllipseStroke(double centerX, double centerY, double radiusX, double radiusY)
        {
            Contract.Requires(centerX >= radiusX);
            Contract.Requires(centerY >= radiusY);
            Contract.Requires(radiusX > 0);
            Contract.Requires(radiusY > 0);

            var ellipsePoints = new StylusPointCollection();

            _unitEllipsePoints.ForEach(
                uep => ellipsePoints.Add(
                    new StylusPoint(centerX + uep.Item1 * radiusX, centerY + uep.Item2 * radiusY)
                )
            );

            var ellipseStroke = new Stroke(ellipsePoints);
            strokeCollection.Add(ellipseStroke);

            return this;
        }

        public StrokeCollection GetStrokeCollection()
        {
            Contract.Ensures(null != Contract.Result<StrokeCollection>());

            return strokeCollection;
        }
    }
}

EditShaftProtocolViewModel.cs

In the constructors of the corresponding view model class InitialiseSketchData method gets called to initialise the StrokeCollection of the InkCanvas. When loading an existing protocol that contains a StrokeCollection it gets loaded using MemoryStream and the resulting StrokeCollection then gets passed as an argument to the overloaded InitialiseSketchData method (InitialiseSketchData(new StrokeCollection(memoryStream))) otherwise the parameterless InitialiseSketchData method gets called. The parameterless InitialiseSketchData method calls the overloaded InitialiseSketchData method by passing an initial StrokeCollection as an argument that just contains the initial geometric figures (built by the StrokeCollectionBuilder). In the overloaded InitialiseSketchData method the StrokeCollection passed as argument gets assigned to the _sketchData property and a event handler gets registered to the CollectionChanged event.
The registered event handler EditShaftProtocolViewModel_SketchDataChanged converts the StrokeCollection on every change to a byte array and assigns the byte array to the corresponding model property.
Furthermore there is a command called ClearSketchCommand that clears the SketchData property of the ViewModel and reinitialises the SketchData property with an initial StrokeCollection.

/**
 * Copyright 2017 d-fens GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Ink;
using System.Windows.Media.Imaging;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Attributes;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Commands;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Controls;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Domain;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Domain.Sewer;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Geometry;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Managers;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Pages;
using MahApps.Metro.Controls;
using MahApps.Metro.Controls.Dialogs;
using Validation = biz.dfch.CS.ArbitraryWpfApplication.UI.Constants.Validation;

namespace biz.dfch.CS.ArbitraryWpfApplication.UI.ViewModels
{
    public class EditShaftProtocolViewModel : ViewModelBase
    {
        internal readonly ShaftProtocol ShaftProtocol;
        private readonly ProtocolManager protocolManager = new ProtocolManager();
        private readonly PhotoManager photoManager = new PhotoManager();

        public EditShaftProtocolViewModel()
        {
            // ReSharper disable once UseObjectOrCollectionInitializer
            ShaftProtocol = new ShaftProtocol
            {
                Id = string.Empty,
                Metadata = new Metadata
                {
                    CreationDate = CurrentApp.CreationDate,
                    MunicipalityName = CurrentApp.MunicipalityName,
                    Operator = CurrentApp.Operator
                }
            };

            ShaftProtocol.Data.Owner = CurrentApp.Organisation;
            ShaftProtocol.Data.Maintainer = CurrentApp.Organisation;

            InitialiseSketchData();
        }

        public EditShaftProtocolViewModel(ShaftProtocol shaftProtocolToBeEdited)
        {
            Contract.Requires(null != shaftProtocolToBeEdited);

            ShaftProtocol = shaftProtocolToBeEdited;

            _coverSizeOption = CoverDiameter == 0;
            _shaftSizeOption = ShaftDiameter == 0;

            if (null != ShaftProtocol.Data.SketchByteData)
            {
                using (var memoryStream = new MemoryStream(ShaftProtocol.Data.SketchByteData))
                {
                    InitialiseSketchData(new StrokeCollection(memoryStream));
                }
            }
            else
            {
                InitialiseSketchData();
            }
        }

        private void InitialiseSketchData()
        {
            InitialiseSketchData(GetInitialStrokeCollection());
        }

        private void InitialiseSketchData(StrokeCollection strokeCollection)
        {
            Contract.Requires(null != strokeCollection);

            _sketchData = strokeCollection;
            ((INotifyCollectionChanged)_sketchData).CollectionChanged += EditShaftProtocolViewModel_SketchDataChanged;
        }

        ...

        private StrokeCollection _sketchData;
        public StrokeCollection SketchData
        {
            get
            {
                return _sketchData;
            }
            set
            {
                _sketchData = value;
                RaisePropertyChangedEvent(nameof(SketchData));
            }
        }

        ...

        #region EventHandler

        void EditShaftProtocolViewModel_SketchDataChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            SketchData = (StrokeCollection) sender;
            using (var memoryStream = new MemoryStream())
            {
                SketchData.Save(memoryStream);
                ShaftProtocol.Data.SketchByteData = memoryStream.ToArray();
            }
        }

        #endregion EventHandler

        #region Commands

        public SimpleCommand ClearSketchCommand => new ClearSketchCommand(ClearSketch);

        private void ClearSketch()
        {
            SketchData.Clear();
            SketchData.Add(GetInitialStrokeCollection());
        }

        ...

        private StrokeCollection GetInitialStrokeCollection()
        {
            var builder = new StrokeCollectionBuilder();
            builder.BuildHorizontalStroke(Constants.Controls.Attribute.INK_CANVAS_START_POS_X,
                Constants.Controls.Attribute.INK_CANVAS_WIDTH, Constants.Controls.Attribute.INK_CANVAS_HEIGHT / 2);

            builder.BuildVerticalStroke(Constants.Controls.Attribute.INK_CANVAS_WIDTH / 4,
                Constants.Controls.Attribute.INK_CANVAS_START_POS_Y, Constants.Controls.Attribute.INK_CANVAS_HEIGHT);

            builder.BuildVerticalStroke(Constants.Controls.Attribute.INK_CANVAS_WIDTH / 4 * 3,
                Constants.Controls.Attribute.INK_CANVAS_START_POS_Y, Constants.Controls.Attribute.INK_CANVAS_HEIGHT);

            builder.BuildCircleStroke(Constants.Controls.Attribute.INK_CANVAS_WIDTH / 4, Constants.Controls.Attribute.INK_CANVAS_HEIGHT / 2, 100.0);

            builder.BuildEllipseStroke(Constants.Controls.Attribute.INK_CANVAS_WIDTH / 4 * 3, Constants.Controls.Attribute.INK_CANVAS_HEIGHT / 2, 150.00, 100.0);

            return builder.GetStrokeCollection();
        }

EditShaftProtocol.xaml

In XAML we just defined a label, an InkCanvas control and a button for clearing custom strokes. The InkCanvas control is bound to the SketchData property of the ViewModel and the button is bound to the ClearSketchCommand.

<Label Grid.Row="4" Grid.Column="0" Content="{x:Static p:Resources.Page_EditShaftProtocol_InkCanvas_Sketch__Label}" />
                        <Border x:Name="CanvasBorder" Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="4" BorderBrush="Black" BorderThickness="2">
                            <InkCanvas Name="Sketch" Height="250" Width="625" HorizontalAlignment="Stretch" ForceCursor="True" Cursor="Pen" Strokes="{Binding SketchData}" TouchEnter="InkCanvas_TouchEnter" TouchLeave="InkCanvas_TouchLeave" />
                        </Border>
                        <Button Name="ButtonClearSketch" Grid.Row="6" Grid.Column="3" HorizontalAlignment="Right" VerticalAlignment="Center" Style="{DynamicResource MetroCircleButtonStyle}" Content="{StaticResource appbar_delete}" Command="{Binding ClearSketchCommand}" />

That’s it. Let’s have a look at the result.

Sketches Area with initial StrokeCollection

Sketches Area with custom Strokes

« WPF Series -9- CustomListBox

Trackbacks

  1. […] InkCanvas with initial StrokeCollection […]

  2. […] WPF Series -10- InkCanvas with initial StrokeCollection » […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: