Friday, March 14, 2008

Top Drawer, Part II

Q: Why do graphics geeks write vector art applications? A: We're just drawn to it.

Q: Why couldn't the blank canvas get a date? A: He didn't have any good lines.

Q: Why did the rectangle go to the hospital? A: Because it had a stroke.

Q: Why is TopDrawer sluggish when the canvas is empty? A: Because it's out of Shapes.

Welcome to the second installment of TopDrawer, a series in which I describe how a simple vector-drawing application was implemented in Flex.

Here's the application again, in case you missed it last time:

In Part I, we went through the GUI layer of the application, stepping through most of the code in TopDrawer.mxml. This, time, we'll take a look at the ActionScript3 class ArtCanvas.as, which is where most of the application logic for TopDrawer is found, including the mouse input that handles creating and editing the shapes on the canvas.

ArtCanvas.as

The source code for this file can be found here; you may find it helpful to download it and view it in a separate window, as I'll only be showing snippets from the file below as we walk through it.

ArtCanvas is a custom component, a subclass of UIComponent, which is the base class of all Flex components (and the class which you might usually want to subclass for custom components of your own if you don't need any of the specific capabilities of the existing subclasses). The ArtCanvas component is the visual area in which the shapes are drawn and edited. It handles both the display of the objects (through other classes that we'll see later) as well as the mouse and keyboard events that drive the creation and manipulation of the shapes.

Architecture

Functionality

The functionality of ArtCanvas is fairly simple:

  • Properties: The properties of ArtCanvas determine the type of shape being drawn, the properties of that shape, and the gridSnap properties that affect how drawing occurs.
  • Creation: Through handling mouse events, ArtCanvas creates shapes as the user drags the mouse around on the canvas.
  • Selection: Mouse events also result in selecting and deselecting objects for editing.
  • Editing: This is perhaps an optimistic term for what the user can do with the shapes in this very simplistic application, but selected shapes can be either moved (through mouse dragging) or deleted (through keyboard events or clicking on the Clear button).
  • Rendering: The canvas has a simple white background that ArtCanvas creates with a small display list. All other rendering (of the shapes on the canvas) is handled by the shape objects themselves.

Standard Overrides

A custom component like ArtCanvas that subclasses directly from UIComponent will typically override the following four methods:

        override protected function createChildren():void   
        override protected function commitProperties():void
        override protected function measure():void
        override protected function updateDisplayList(unscaledWidth:Number,
                unscaledHeight:Number):void

These methods are all called during specific phases during the lifecycle of any component and are the appropriate places to perform certain operations for the component:

  • createChildren(): This method is called during the creation phase of the component. If the component is a container for other child components, this is a good place to create them. Note, however, that if children of this component are created dynamically, or otherwise not a permanent feature of this component, then it is not required to create them here. For example, in our case the drawing shapes will be children of the canvas, but none yet exist when the canvas is created, so there is no need to create any children at this stage. So ArtCanvas does not bother to override this method.
  • commitProperties(): This method is called prior to updating the rendering of this component during a frame, if any properties have changed. Imagine a component with several properties, some of which might cause a significant amount of work to update the state of the component. In this case, it might be advantageous to update the state of a component all at once, based on the cumulative effect of all property changes since the last frame. In the case of our simple canvas component there is no need for this, so once again we get away with not needing to override this method.
  • measure(): This method is called prior to rendering the component during a frame if anything has changed that may affect the size of your component, to make sure that Flex knows the preferred size of your component. Some components may base their display size on some internal state, such as the amount of text within a button, or the size of an image, or other factors which the Flex rendering system may not know how to calculate for you. In these cases, your component needs to set some internal UIComponent sizing values. Yet again, we have work here for ArtCanvas; the size of the canvas is completely defined by the container of the canvas (which Flex controls), so we have no preferred size to communicate to Flex and do not need to override this method.
  • updateDisplayList(): This method is called prior to rendering the component during a frame, when the component needs to change the way it is being drawn. Note that because Flash uses a display list to render objects, this method will only be called when the rendering of an object needs to be updated. During normal frame processing, Flash already knows how to draw the component. We actually do have some custom rendering for our component, so we did not escape this time and we do override this method and draw our component accordingly. We'll see this simple code below.

Event Handling

Objects can choose to listen to events that occur in the system, such as mouse and keyboard events on components. For any events that they want to handle, they call addEventListener() and point to a function that will be called when any of those events occur. In the case of ArtCanvas, we handle both mouse events (on the canvas itselft) and keyboard events (in the overall application window). For any event, we can get information from the Event object to find out what we need: x/y information for mouse events, keyboard characters typed for keyboard events, and so on.

The Code

Now that we understand what ArtCanvas is trying to do, let's step through the interesting parts of the code of ArtCanvas.

We Got Style

There are a couple of styles used by ArtCanvas, which are declared outside of the class:

    [Style(name="gridSize", type="int", format="int", inherit="no")]
    [Style(name="gridSnap", type="Boolean", format="Boolean", inherit="no")]

These styles control an invisible grid on the canvas that the cursor will "snap" to during drawing. This feature can be useful for making more exact drawings where objects need to line up and typical mouse movement precision makes that difficult. These metadata tags ("[Style...]") communicate to Flex that this class can be styled in MXML to affect these properties. Now, developers can use CSS style tags to change the values of these grid properties, as we saw previously in the code for TopDrawer.mxml.

We retrieve the values for these properties dynamically as we track the mouse:

        private function getGridSize():int
        {
            return (getStyle("gridSize") == undefined) ? 10 : getStyle("gridSize");
        }   

        private function getGridSnap():Boolean
        {
            return (getStyle("gridSnap") == undefined) ? false : getStyle("gridSnap");
        } 

And we use these values when we call snapPoint(), which is responsible for returning the nearest point on the snap grid to a given (x, y) location (which will typically just be the current mouse location). I won't bother putting the code to snapPoint() here, since it's pretty simple; just trust me that it does what I said, and check out the code in the file for the details.

A: Is it difficult aligning objects in TopDrawer? A: No, it's a snap

Events

We create a custom event in ArtCanvas, and use the following metadata to communicate this to Flex:

        [Event(name="drawingModeChange", type="events.DrawingModeEvent")]

When the internal variable currentShapeMode changes, we will dispatch the drawingModeChange event so that listeners (which are declared in TopDrawer.mxml, as we saw last time) are aware of the change.

Properties

Now, let's see the instance variables that will be used for the canvas:

        // The drawing shapes on the canvas
        private var shapes:ArrayCollection = new ArrayCollection();

        // Point at which a shape starts being drawn - used to track
        // delta movement in mouse drag operations
        private var startPoint:Point;

        // Current shape drawing mode
        private var _currentShapeMode:int = LINE;
   
        // Current shape being drawn
        private var currentShape:ArtShape;
   
        // Currently selected shape. If null, no shape is selected.
        private var selectedShape:ArtShape;
   
        // Shape which renders selection handles for currently selected shape
        private var selectionShape:Shape;
   
        // Color with which following primitives will be created and drawn
        private var _drawingColor:uint;
   
        // Stroke width for future line-drawn primitives
        private var _strokeWidth:int;

Some of these are used to track internal state, such as the startPoint of the current ArtShape. Others are values that are controlled through the GUI, such as the drawingColor, which can be changed by the user via the ColorPicker component. It is helpful, at least if you're just learning ActionScript3, to see the pattern for these externally-modifiable properties in the language. Let's look at currentShapeMode as an example.

Note that our class variable for _currentShapeMode is private. But we want this variable to be settable from outside the class. In addition,we would like to dispatch our custom event drawingModeChanged when this variable changes. A typical pattern in other languages is to have a parallel "setter" method, such as setDrawingColor(), that can be called to affect the value of _currentShapeMode. A similar setter is created in ActionScript3 by the following code:

        public function set currentShapeMode(value:int):void
        {
            _currentShapeMode = value;
            dispatchEvent(new DrawingModeEvent(value));
        }

Note the syntax here; the method is not actually called "setCurrentShapeMode()", but instead uses the keyword "set" to indicate that it is a setter for the class variable currentShapeMode. The cool thing about this approach is that external users of this variable simply reference currentShapeMode directly, instead of calling a method, like this:

        canvas.currentShapeMode = somemode;

For example, the DrawingShapeIcon component defined in TopDrawer.mxml that handles setting the drawing mode to LINE does this by the following click handler:

        click="canvas.currentShapeMode = ArtCanvas.LINE;"

This approach has the terseness of setting a field from the caller's standpoint, but actually calls your set method, where you can perform more than a simple assignment of the variable. In this case, we need to both set the value of the variable and dispatch an event; we do both of these in our setter.

Shapes

Finally, we're onto the heart of this class, and the entire application: creating and manipulating shapes. This is done mainly through mouse handling; mouse clicks allow selection and mouse drags enable either creation (if nothing is selected) or movement of a selected shape.

Since we will need to handle mouse events, we need to inform Flex that we want to listen for these events. We do this in our constructor:

        public function ArtCanvas()
        {
            super();
            addEventListener(MouseEvent.MOUSE_DOWN, handleMouseDown);
            addEventListener(MouseEvent.MOUSE_UP, handleMouseUp);
        } 

Of course, these events will only give us move up/down information. In order to handle mouse drag events we also need to track mouse movement. But since we only care about mouse movement while the mouse button is down (that is, mouse drags, not just mouse moves), we will only add a listener for mouse movement when there is a mouse down event. We'll see how that is done that later in the handleMouseDown() method.

First, a utility method. There are two different places where an object may be selected: when an existing shape is clicked on by the user and when the user finishes creating a shape. To avoid duplicating the code, there is a selectShape() method. This method registers that a shape is selected, creates a new selectionShape, which is basically a set of selection handles (filled rectangles), the position of which is determined by the shape being selected, and adds the selectionShape to the children of the canvas so that it is displayed appropriately:

        private function selectShape(shape:ArtShape):void
        {
            selectedShape = shape;
            selectionShape = selectedShape.getSelectionShape();
            addChild(selectionShape);
        }

Here is our handler for mouse down events. startPoint gets the point where the mouse was clicked on the canvas or, if grid snapping is enabled, the nearest point on the grid:

        private function handleMouseDown(event:MouseEvent):void
        {
            startPoint = snapPoint(event.localX, event.localY);

Next, we get the global mouse location relative to the Flex window (otherwise known as the Flash "stage"), which we will use in testing for object selection. Note that we do not use a grid-snapped point for selection because we want to test against the actual pixels of a shape, and many or most of those pixels will actually not be on the grid (picture a diagonal line, for example, most of whose pixels lie between, not on, grid points):

            var selectPoint:Point = localToGlobal(new Point(event.localX, event.localY));

Next, we see whether there is already a currently-selected shape. If so, we see whether we hit that shape with this mouse-down operation. If not, we deselect the shape (which includes removing the transient selectionShape from the children displayed by the canvas):

            if (selectedShape) {
                if (!selectedShape.hitTestPoint(selectPoint.x, selectPoint.y, true)) {
                    removeChild(selectionShape);
                    selectedShape = null;
                }
            }

If there is no shape selected (or if we deselected a previously selected shape because the current mouse location missed it), then see whether we should select a shape, based on the global mouse position. Note that the true parameter in the hitTestPoint() call tells the method to base hits only on actual shape pixels not the simple bounding box of the shape:

            if (!selectedShape) {
                for each (var shape:ArtShape in shapes)
                {
                    if (shape.hitTestPoint(selectPoint.x, selectPoint.y, true))
                    {
                        selectShape(shape);
                        break;
                    }
                }
            }

If we still do not have a selected shape, then the mouse truly didn't hit any of the existing shapes. So it's time to create a new a new shape. This is done by instantiating one of the handful of specific Shape subclasses, according to the currentShapeMode variable (the value of which is determined by which icon the user selected in the TopDrawer.mxml UI), sending in this initial point to the new shape, and adding that shape to the display list of the canvas:

            if (!selectedShape) {
                switch (currentShapeMode) {
                    case LINE:
                        currentShape = new Line(strokeWidth, drawingColor);
                        break;
                    case ELLIPSE:
                        currentShape = new Ellipse(strokeWidth, drawingColor, false);
                        break;
                    // and so on: other cases deleted for brevity
                }
                currentShape.start(startPoint);
                addChild(currentShape);
            }

Finally, we now need to track further mouse-move events, which will be used to either move a selected shape or continue creating the new one:

            addEventListener(MouseEvent.MOUSE_MOVE, handleMouseMove);
        }

As long as the mouse button is held down, we will receive mouse movement events, which will be handled as drag operations. If there is no currently selected object, then each drag operation sends another point into the in-creation shape. Otherwise, each drag moves the currently selected object (and its selectionShape) according to how much the mouse moved since the last mouse event:

        private function handleMouseMove(event:MouseEvent):void
        {
            var location:Point = snapPoint(event.localX, event.localY);
            if (!selectedShape) {
                currentShape.drag(location);
            } else {
                var deltaX:int = location.x - startPoint.x;
                var deltaY:int = location.y - startPoint.y;
                selectedShape.x += deltaX;
                selectedShape.y += deltaY;
                selectionShape.x += deltaX;
                selectionShape.y += deltaY;
                startPoint = location;
            }
        }

When the mouse button is released, we will receive that event in our handleMouseUp() method. In this method, we will only process position information for objects being created (selected objects being moved do not need a final operation to complete; they will be handled just by the previous drag events). We add the final point to the created shape, then test whether it is valid; this prevents spurious null objects where the first/last/intermediate points are all the same. If the shape is valid, we select it and add it to the current list of shapes for the canvas. Finally, we remove our mouse movement listener since we only care about movement for dragging between mouse up and down events:

        private function handleMouseUp(event:MouseEvent):void
        {
            if (!selectedShape) {
                if (currentShape.addPoint(snapPoint(event.localX, event.localY))) {
                    if (currentShape.validShape()) {
                        shapes.addItem(currentShape);
                        selectShape(currentShape);
                    } else {
                        removeChild(currentShape);
                    }
                    // done creating current shape
                    currentShape = null;
                }
            }
            removeEventListener(MouseEvent.MOUSE_MOVE, handleMouseMove);           
        }

There is one more event that we track, which is the key-down event from the keyboard. We do this just to allow easy deletion of the currently-selected object. Our handler simply deletes the current object from the list of shapes, removes it from the children of the canvas (which removes it from the objects being displayed by Flex), and removes the current selectionShape as well:

        public function handleKeyDown(event:KeyboardEvent):void
        {
            if (event.keyCode == Keyboard.DELETE) {
                if (selectedShape) {
                    var itemIndex:int = shapes.getItemIndex(selectedShape);
                    if (itemIndex >= 0)
                    {
                        shapes.removeItemAt(itemIndex);
                        removeChild(selectedShape);
                        selectedShape = null;
                        removeChild(selectionShape);
                        selectionShape = null;
                    }
                }
            }
        }

Similarly, clicking on the "clear" button in the UI will cause all objects in the list to be deleted by a call to the clear() method:

        public function clear():void
        {
            for each (var shape:Shape in shapes) {
                removeChild(shape);
            }
            shapes.removeAll();
            if (selectedShape) {
                selectedShape = null;
                removeChild(selectionShape);
            }
        }

Display

There's one final method that is interesting to look at: updateDisplayList(). We saw this method earlier in our discussion of the typical four overrides from UIComponent, where this is the only method that we actually need to override in ArtCanvas. In our case, all we have to do here is draw our canvas to look like what we want. Here, we set the fill to be solid white and fill a rectangle the size of our component:

        override protected function updateDisplayList(unscaledWidth:Number,
                unscaledHeight:Number):void
        {
            super.updateDisplayList(unscaledWidth, unscaledHeight);
            graphics.clear();
            graphics.beginFill(0xffffff, 1);
            graphics.drawRect(0, 0, unscaledWidth, unscaledHeight);
        }

Note that all of the interesting rendering, that of the shapes that have been and are being created, is handled by the shapes themselves; when they are added as children of ArtCanvas, Flex automatically works with those shapes directly to get their display lists. So all we need to do here was handle the rendering for the canvas itself.

goto end;

That's it for ArtCanvas, which has most of the logic in the entire TopDrawer application. I'm tempted to put the rest of the code here, but I'd like to keep each blog somewhat shorter than way too long, so I'll defer it to my next entry. In that next TopDrawer article (ooooh, I can feel the suspense building...), I'll go over the code in the Shape classes and other minor classes. I'll also post the full source tree so that you can build and play with it yourself.

1 comment:

Danno Ferrin said...

Doesn't flex have a direct drag support? If not you need to poll mouse state in the movement.

* Mouse-Down on a shape
* Drag to the left, have a cursor over one of the buttons.
* Mouse up on a shape

Shape now follows mouse in non drag mode.

Also, shouldn't you be checking the boundaries of the canvas on a move, or at least clipping it?

Yes, it's a demo app. But answering these questions under the guise of improving the app could be another great demo-app tweak post.