Freeform Custom Activity Designers using ICompositeView (Part 5)

This is Part 5 of a series of posts, [Part 1Part 2 - Part 3 - Part 4 – Part 5 – Part 6].

[Warning: today’s post is yet another one where (for educational purposes) I will be posting a lot of buggy code, if you are looking for code to copy, the next post has less buggy code.]

Laying out the CanvasActivity

Memory jog: in part 1 we wrote some custom activity designer XAML.

<sap:ActivityDesigner x:Class="ActivityLibrary1.CanvasDesigner" ... />

    <Canvas Name="ContentCanvas" Width="500" Height="500" />

</sap:ActivityDesigner>

We added a Canvas control as a way to layout all our child activities, but up until now our child activities have resolutely remained stuck at (0,0) . This makes it really hard to tell how many activities are actually there. We’ve lived with this only because we had more urgent problems to fix. Finally (thanks to Part 4) we can drag and drop activities, and undo and redo. Now we’re ready to deal with the problem of positioning activities on the canvas according to where they got dropped.

"OK, A WPF Canvas, that should be really easy, right? I probably just need to add about 2 lines of code... (tap tap tap) ...oh wait, the compiler is telling me something is wrong."

    protected override void OnDrop(DragEventArgs e)

    {

        ModelItem canvasActivity = this.ModelItem;

        //droppedItem – may be a ModelItem or a newly instantiated

        //object not yet wrapped in ModelItem (from toolbox)

        object droppedItem = DragDropHelper.GetDroppedObject(this, e, this.Context);

        ModelItem droppedModelItem = canvasActivity.Properties["Children"].Collection.Add(droppedItem);

        Point p = e.GetPosition(this.ContentCanvas);

        Canvas.SetLeft(droppedModelItem, p.X);

        Canvas.SetTop(droppedModelItem, p.Y);

        e.Handled = true;

        DragDropHelper.SetDragDropCompletedEffects(e, DragDropEffects.Move);

        base.OnDrop(e);

    }

The compiler is complaining that droppedModelItem is not a UIElement. Oh yeah. The View is separate from the Model. We need to position the View, not the Model.

Is there a View to position yet? We did call ModelCollection.Add(), which invokes our ModelCollectionChanged handler and creates a view (see Part 3). After which ModelItem.View ought to be initialized, right? So we can just call the attached property helper functions on droppedModelItem.View, right?

Um, nope, wrong! That would be too easy. So why would that be too easy?

Issue #1) XAML serialization round trip

Unfortunately when we save the XAML file, the Canvas.Left and Canvas.Top attached properties we set above aren't going to get saved anywhere in the XAML file. When we reload the XAML file, the activities which we had carefully positioned on the Canvas have magically gravitated to the top-left corner of our Canvas. Yuck.

2) Breadcrumbing

Problem number two is that even in scenarios involving no serialization, if we just double-click our CanvasActivity to zoom in, the View elements are recreated. And they have no positioning again. D'oh.

Extending the Model

A solution to both these problems requires us to have somewhere to store the positioning information, X and Y, tied to the Activity in the Model. Then every time we reconstruct the View, we can also position it correctly. What ways could we try to extend the model to add this information? Here's a quick brain storm:

Idea 1) Complicate our CanvasActivity design. Wrap the Activities inside another class, like the FlowNode class used in System.Activities.Statements.Flowchart, which holds the extra information. X and Y coordinates can be stored as part of the actual object.

Idea 2) Change our object model in other, simpler ways. Add a dictionary or list on the CanvasActivity, and use this to store all the coordinates.

Idea 3) Use AttachedPropertiesService (Matt Winkler blog post) to store the properties somewhere – on the Activity ModelItem.

Idea 4) Use ViewStateService (another wonderful Matt Winkler blog post) to store the properties somewhere (the service takes care of where)tied somehow to the Activity object or its ModelItem itself.

I'd rather list lots of possibilities before picking one, because we start to get into implementation choices where I don’t know if there is a clearcut ‘best’ choice.

For now, I have no special factors forcing me towards any of these, I would rather try using what looks the simplest solution - ViewStateService should enable the round-tripping of the data we need with little fuss. So, here is the next version of OnDrop():

        protected override void OnDrop(DragEventArgs e)

        {

            ModelItem canvasActivity = this.ModelItem;

            //droppedItem - may be a ModelItem or a newly instantiated object (from toolbox)

            object droppedItem = DragDropHelper.GetDroppedObject(this, e, this.Context);

            Point p = e.GetPosition(this.ContentCanvas);

            ModelItem droppedModelItem = canvasActivity.Properties["Children"].Collection.Add(droppedItem); //***

            ViewStateService vss = Context.Services.GetService<ViewStateService>();

            vss.StoreViewState(droppedModelItem, "CanvasActivity.X", p.X);

            vss.StoreViewState(droppedModelItem, "CanvasActivity.Y", p.Y);

            Canvas.SetLeft((UIElement)droppedModelItem.View, p.X);

            Canvas.SetTop((UIElement)droppedModelItem.View, p.Y);

            e.Handled = true;

         DragDropHelper.SetDragDropCompletedEffects(e, DragDropEffects.Move);

            base.OnDrop(e);

        }

 

Along with that, a new version of our Update() method from Part 4.

        void Update(ModelItem canvasActivity)

        {

            this.ContentCanvas.Children.Clear();

            foreach (ModelItem modelItem in canvasActivity.Properties["Children"].Collection)

            {

                ViewStateService vss = Context.Services.GetService<ViewStateService>();

                double x = (double)(vss.RetrieveViewState(modelItem, "CanvasActivity.X") ?? 0.0);

                double y = (double)(vss.RetrieveViewState(modelItem, "CanvasActivity.Y") ?? 0.0);

                var view = Context.Services.GetService<ViewService>().GetView(modelItem);

                Canvas.SetLeft((UIElement)view, x);

       Canvas.SetTop((UIElement)view, y);

                this.ContentCanvas.Children.Add((UIElement)view);

                DragDropHelper.SetCompositeView((WorkflowViewElement)view, this);

            }

        }

 

It basically works. Points to note:

  • Update() is getting called as part of the asterisked line. ***

  • Canvas.SetLeft() and SetTop() are called twice each upon drop, this is because Update() won’t get the coordinates right unless there is view state on the model item and, Catch-22 style, there is no model item to set the view state on until after Update() has been called.

     

Getting undo and redo just right

I said it basically works but there is one more bug to fix. As always getting Undo/Redo right takes some conscious effort!

If you go and look at the docs for ViewStateService there are actually two different functions for storing view state. Matt gives away which one we should be using in his blog post:

“You will see StoreViewState and StoreViewStateWithUndo.  The primary distinction as the name implies is that one will simply write the view state down and will bypass the undo/redo stack.  This is for view state like an expanded/collapsed view.  You don’t really want ctl-z to simply flip expanded versus collapsed for you.  But for something like flowchart, where changing some of the viewstate, like position, might be such a thing that you want support for undoing the action.  That’s the primary difference.”

So we should be calling StoreViewStateWithUndo(). But, if that is all we do, there’s one more bit of weirdness. The X coordinate setting will be undone and redone separately from the Y coordinate setting. And both of those are separate Undo actions from the adding of the new ModelItem to the Children ModelItemCollection. Gah!

Meet the ModelEditingScope again

Back in Part 2 we met the ModelEditingScope. There we were being called inside of a ModelEditingScope that someone else set up. Here we need to set up our own EditingScope or ModelEditingScope so that the adding of the child activity, the setting of the X coordinate, and the setting of the Y coordinate all happen as one action. This should correspond to the software user’s point of view that they performed one action. “I dropped the activity.”

 

The weird thing about ModelEditingScope that caused problems in Part 2 and could cause some new problems when we start using it here is that introducing the scope changes the timing of the ModelCollectionChanged notification. That is, introducing an editing scope to your code is not a transparent operation, which makes using it hard. Let’s see it in action in the context of our previous function:

 

        using (ModelEditingScope scope = canvasActivity.BeginEdit())

        {

            ModelItem droppedModelItem = canvasActivity.Properties["Children"].Collection.Add(droppedItem);

            Debug.WriteLine(droppedModelItem.View != null ? "hasView" : "nullView");

            Debug.WriteLine("X,Y {0},{1}", p.X, p.Y);

            ViewStateService vss = Context.Services.GetService<ViewStateService>();

            vss.StoreViewStateWithUndo(droppedModelItem, "CanvasActivity.X", p.X);

            vss.StoreViewStateWithUndo(droppedModelItem, "CanvasActivity.Y", p.Y);

            //Canvas.SetLeft((UIElement)droppedModelItem.View, p.X);

       //Canvas.SetTop((UIElement)droppedModelItem.View, p.Y);

            Debug.WriteLine("Canvas Children Count: {0}", canvasActivity.Properties["Children"].Collection.Count);

            scope.Complete(); //***

            Debug.WriteLine("Canvas Children Count: {0}", canvasActivity.Properties["Children"].Collection.Count);

        }

 

Actual debug output:

 

                nullView
                X,Y 205.5,91.04
                Canvas Children Count: 0
                Canvas Children Count: 1

 

The commented out lines are commented out because they would now throw, just because we introduced an EditingScope. How could we stop this from happening?

[Last Revision: 01/20/2010]