For the past eight months I’ve been working on a WPF desktop application that has a tool to draw. Very similar to most drawing application, you select the Drawing tool in the ribbon/toolbox and then you are able to draw on the canvas until you close the shape or cancel the tool.
The new requirement
Now, we need to draw using snapping. Snapping means that when you are drawing close to a particular point (considering a distance) the mouse will move but the drawing line not, will stay snapped to the point.
In this case we need to consider three cases:
- Snapping on the X axis (horizontal axis)
- Snapping on the Y axis (vertical axis)
- Snapping when the line has an 90 degree angle with the previous line
Where should I add the snapping?
So I started by writing a test (Yes, full TDD) that checks when I’m close to the X axis (3 pixels or less) then the drawing tool should snap. My test failed gloriously and I was ready to start my implementation in order to make the test pass… now… the question is… who is responsible for snapping?
The Drawing Tool
Let’s review the drawing tool interaction to understand how it works using MVVM.
- The Canvas receives a mouse move event and passes that information to the DrawingTool
- The DrawingTool raises a LineMoved event
- The view model receives the event and notifies the view
- The view shows the line ending on the new point
A sequence diagram should look something like this:
Who is responsible for snapping?
does not know about snapping, but it should, because is raising the event… However considering the SRP (single responsibility principle) makes more sense to delegate this decision to another class, behold the ISnappingStrategy
I decided to inject the strategy into the DrawingTool
constructor (via IoC) and the DrawingTool
will query the strategy for each point before raising the event, thus the event will contain the point decided by the strategy.
Let’s look at the new sequence diagram with the strategy added to the interaction:
Why a strategy? Is that the GOF pattern?
Indeed it is, why? Because I wanted to encapsulate the algorithm I’m going to use to snap, snapping to X axis has different rules than snapping to the previous line when having a 90 degree angle. Please refer to the GOF book “Design Patterns” or Google for more examples and diagrams.
Snapping Strategy Hierarchy
I have so far one interface ISnappingStrategy
and three concrete strategies that implement the interface:
Each strategy when called will return the snapped point or, if no snapping is required the same point.
So far, so good…. now I have implemented them, and each one passes the unit tests. However I don’t want to use just one of the them, I want to use all of them combined. I’d like to use first SnapToX
and if does not snap, then SnapToY, etc, etc
Enters the Composite Strategy
The Composite pattern is another GOF pattern and the goal is to treat a group of objects like they where a single instance. In this case, I want to use multiple snapping strategies like they were just one strategy.
Here is the idea, let’s create the Composite with a collection of strategies, and when called the Composite will iterate thru them until it finds a new snapping point or, if no snapping should happen, return the same point.
So, with the Composite, the new hierarchy looks like:
And the code for the composite snapping would be:
1: public Point Snap(Point point, IDrawingContext context)
3: var found = this._strategies.Find(s => s.Snap(point, context) != point);
5: return found == null ? point : found.Snap(point, context);
The cherry on top
Now we need to configure all this, luckily we can use Binsor to configure our Windsor container and it will look something like this:
1: component "SnapToX", ISnappingStrategy, VectorSnappingStrategy:
2: x = 1
3: y = 0
5: component "SnapToY", ISnappingStrategy, VectorSnappingStrategy:
6: x = 0
7: y = 1
9: component "SnapToPrevious", ISnappingStrategy, PreviousLineSnappingStrategy
11: component "SnappingStrategy", ISnappingStrategy, CompositeSnappingStrategy:
12: strategies = [@SnapToX, @SnapToY, @SnapToPrevious]
14: component "DrawTool", IDrawTool, DrawTool:
15: snapping = @SnappingStrategy