Thought Cloud UI #5: Arrange your thoughts

I’ll be presenting Thought Cloud at the Dallas .NET User Group and the North Texas Silverlight User Group in a couple of weeks. I’m finishing up the demo now.

In the last installment, we added the ability to open a cloud in a tab. Now we need to display the thoughts in a cloud of bubbles rather than in a list. To make a plan, I created a mock view.

image

The mock is not backed by sample data. It does not bind to the view models. But it does exercise some XAML that we want to use for the real view. Most significantly, I determined how to position the bubbles.

First, I determined how to position the text within an ellipse. I want the ellipse to be a fixed margin away from the text, but to expand with the text’s length. After playing around with a few variations, the one that worked best for me was to put both the ellipse and the text block in a grid. The text determines the size of the grid, and the grid determines the size of the ellipse.

<Grid>
    <Ellipse />
    <TextBlock Text="Star Wars" Margin="15,10" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>

Then there was the question of positioning the thought bubble within the cloud. My first thought was to use a Canvas. Unfortunately, Canvas lays out its children based on the left and top. I want to position my thought bubbles based on the center. In order to convert center to left and top, I would need to measure width and height. That would require doing the layout in the view. I’m going to try to do everything through data binding in the view model.

What I settled on was using a Grid as a container, centering each thought bubble, and positioning them thought their margins. A centered bubble at {0,0,0,0} will appear in the center of the Grid. When I change the margin to {10,0,-10,0}, it moves to the right by 10 units. So I need to bind the margin to {X, Y, -X,-Y}.

Throw in a text block for editing the thought and this is the item template that I need to bind.

<DataTemplate x:Key="ThoughtItemTemplate">
    <Grid HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{Binding Margin}">
        <Ellipse />
        <TextBlock Text="{Binding Text}" Margin="15,10" HorizontalAlignment="Center" VerticalAlignment="Center" />
        <TextBox Text="{Binding Text, Mode=TwoWay}" VerticalAlignment="Center" HorizontalAlignment="Center" Visibility="{Binding Editing, Converter={StaticResource VisibleWhenTrueConverter}}"/>
    </Grid>
</DataTemplate>

Let’s add these properties to the ThoughtViewModel.

public interface ThoughtViewModel
{
    string Text { get; set; }
    Thickness Margin { get; }
    bool Editing { get; }
}

Calculate position

The position of a thought is calculated. In other words, it’s dependent. The user doesn’t get to position it. The tricky part is that it depends upon where the thought falls within the cloud among its neighbors. So a ThoughtViewModel doesn’t have enough information to calculate position. That is the responsibility of the CloudViewModel.

Let’s give the CloudViewModel a dependent dictionary of Points by Thought. We introduce the Dependent class from Update Controls, which tracks dependency using an update method. It will invoke the update method only when the property is out-of-date. In this case, the dictionary depends upon the collection of thoughts.

public class CloudViewModel
{
    ...
    private Dictionary<Thought, Point> _centerByThought = new Dictionary<Thought, Point>();
    private Dependent _depCenterByThought;

    public CloudViewModel(Cloud cloud)
    {
        _depCenterByThought = new Dependent(() => _centerByThought = CalculateCenterByThought());
    }

    private Dictionary<Thought, Point> CalculateCenterByThought()
    {
        Thought centralThought = _cloud.CentralThought;
        Dictionary<Thought, Point> centerByThought = new Dictionary<Thought, Point>();
        if (centralThought != null)
        {
            centerByThought.Add(centralThought, new Point(0.0, 0.0));
            List<Thought> neighbors = centralThought.Neighbors
                .Where(n => n != centralThought)
                .ToList();
            int step = 0;
            double horizontalRadius = 230.0;
            double verticalRadius = 120.0;
            foreach (Thought neighbor in neighbors)
            {
                double theta = 2 * Math.PI * (double)step / (double)neighbors.Count;
                centerByThought.Add(neighbor, new Point(
                    horizontalRadius * Math.Cos(theta),
                    verticalRadius * Math.Sin(theta)));
                ++step;
            }
        }
        return centerByThought;
    }
}

Because the update method gets the neighbors of the central thought, it will become out-of-date whenever a neighbor is added or removed, or the central thought is changed.

Next we need to give the ThoughtViewModel a way to access its own center point. Let’s pass a function into its constructor. This function will look up the thought’s center point from the dictionary. The call to OnGet will ensure that the dictionary is up-to-date.

public class CloudViewModel
{
    ...

    public IEnumerable<ThoughtViewModel> Thoughts
    {
        get
        {
            Thought centralThought = _cloud.CentralThought;
            if (centralThought == null)
            {
                return Enumerable.Repeat(new ThoughtViewModelSimulated(_cloud), 1)
                    .OfType<ThoughtViewModel>();
            }
            else
            {
                return Enumerable.Repeat(new ThoughtViewModelActual(centralThought, GetCenterByThought), 1).Union(
                    from n in centralThought.Neighbors
                    where n != centralThought
                    select new ThoughtViewModelActual(n, GetCenterByThought))
                    .OfType<ThoughtViewModel>();
            }
        }
    }

    private Point GetCenterByThought(Thought thought)
    {
        _depCenterByThought.OnGet();
        Point center = new Point();
        _centerByThought.TryGetValue(thought, out center);
        return center;
    }
}

Now the ThoughtViewModel can calculate its own Margin.

public class ThoughtViewModelActual : ThoughtViewModel
{
    ...
    private readonly Func<Thought, Point> _getCenterByThought;

    public ThoughtViewModelActual(Thought thought, Func<Thought, Point> getCenterByThought)
    {
        _getCenterByThought = getCenterByThought;
    }

    public Thickness Margin
    {
        get
        {
            Point center = _getCenterByThought(_thought);
            return new Thickness(center.X, center.Y, -center.X, -center.Y);
        }
    }
}

By using a dependent dictionary, the cloud can calculate the position of all of its thoughts once, and let each thought access it to data bind into place. When a new thought arrives, either from the local user or from a remote collaborator, the positioning will be recalculated and all thoughts will move to their new locations.

Thought Cloud

Leave a Reply

You must be logged in to post a comment.