Wednesday, December 15, 2010

Lessons Learned in Personal Web Page Part 3: Custom Panel and Listbox

In the third part of this series I wanted to cover the "3D Panel" effect you can see on my Projects page. While there are some terrific third-party carousel controls, I wanted to see what it takes to build one from scratch using a custom panel and a Listbox control.

Custom Listbox Carousel for Silverlight

The first step was to create the panel itself. I started by inheriting from Panel and called my new class ThreeDPanel

public class ThreeDPanel : Panel 
{
}

In my panel I decided to simplify things by fixing the size of a panel item. The entire panel will stretch to fit the available width and handle scroll bars, but the individual panels are the same size. To establish some parameters, I created a set of constants:

private const double ITEM_WIDTH = 450.0;
private const double ITEM_HEIGHT = 300.0;
private const double LEFT_ANGLE = -65.0;
private const double RIGHT_ANGLE = 65.0;
private const double MARGIN = 10.0;
private const double OFFSET = 80.0;
private const double ANIMATION_DURATION = 300.0;
private static readonly Size _itemSize = new Size(ITEM_WIDTH, ITEM_HEIGHT);
private const string ROTATION_Y = "(PlaneProjection.RotationY)";

First, I set the width and height of a panel. Next, the angle a non-selected panel should be at, depending on whether it is to the left or the right of the currently selected item. I provide a slight margin between items and a fixed offset from the edge of one angled item to the next. The animation to turn panels is controlled by the duration (milliseconds). The size is a simple fixed way to specify the size for an item based on the width and height settings, and the rotation Y is what is called "path notation" to get to the rotation angle of a plane projection: this is what we animate when the selection changes.

For this panel, I'm assuming it is hosted in a listbox. A more generic panel would accommodate any number of hosts, but I'm keeping it simple. One thing I know needs to happen is when a selection changes on the host listbox, I'll need to re-arrange all of my items to reflect the new selection. Therefore, the panel needs a hook into the selection change event of the listbox. First, a helper method to walk up the visual tree and find the listbox that is hosting the custom panel:

private ListBox GetListBox()
{
    var parent = VisualTreeHelper.GetParent(this);
    while (!(parent is ListBox) && parent != null)
    {
        parent = VisualTreeHelper.GetParent(parent);
    }
    return parent == null ? null : (ListBox) parent;
}

When you create a custom control, there are two main events to be concerned with: Measure and Arrange. The first step, the measure step, asks each child how much space it needs and attempts to figure out the total available size for the control. For example, a grid with fixed columns would tell each child "you have x and y space, how much of that do you need?" A grid with flexible space will give the child infinite space to work with, then compute the actual width based on how much space is needed.

In the arrange pass, the children have all flagged their desired sizes and the panel knows how much total space there is to work with. It now has the task of actually arranging the children - that is, based on the sizes they requested, it can ask them to render within that space and then place them on the surface. We'll tackle both of these passes for our custom control.

Because these passes happen anytime the view is resized, the measure pass is the perfect place to find our host listbox and hook into the selection event. I used a flag to track when this happens so I do it only once.

private bool _eventHooked;

protected override Size MeasureOverride(Size availableSize)
{
    // get the list box
    var listBox = GetListBox();

    // hook into selection change to recalculate whenever the user clicks on a panel
    if (!_eventHooked && listBox != null)
    {                
            listBox.SelectionChanged += (sender, e) => InvalidateArrange();
            _eventHooked = true;                
    }
}

Notice that when the selection changes, I call InvalidateArrange. This informs the visual tree that our layout is no longer valid, so we must recompute. This gives me the opportunity to measure and arrange the children again, and reposition everything based on the new selection.

To complete the measure pass, I do a few things. First, I check to see if I have any children. I also check to see if I've set up any animations for the children. If not, I'm going to iterate the list of children and create a storyboard and add that to the resources collection. I'll use these storyboards to animate transitions. Keep in mind this logic assumes that once I have children, the children do not change - obviously this won't work with lists that change, and would require a bit more code to track the change to the underlying collection so that the old animations could be removed and new ones inserted. I knew my list would be fixed once set so I didn't go down that path.

The animations are designed to run for the duration we configured earlier, and then stop. The animations are given a unique name. This name is set on the Tag property of the child element, to make it easy to find later. I hook that event and also target the plane projection. Again, I'm using a helper method to guarantee there is always a plane projection class to work with:

public static PlaneProjection WithPlaneProjection(this UIElement control)
{
    PlaneProjection projection;
    if (control.Projection == null || !(control.Projection is PlaneProjection))
    {
        projection = new PlaneProjection();
        control.Projection = projection;
    }
    else
    {
        projection = control.Projection as PlaneProjection;
    }
    return projection;
}

The animation set up only happens the first time there is a child in the list. After that, I call each child and pass in the size it has available. I measure my own override with the available size, but then there's a trick: I must let the layout engine know how much size I actually need based on my computations. You can see a simple equation I use to compute this knowing how my panels will be angled. If I wanted to be more precise, I could use trigonometry to compute the actual height needed based on the angle of rotation, but again I wanted to keep this example more simple so I guestimate and use a factor of 1.33.

Here is the rest of the measure code:

if (Children.Any() && !(from sb in Resources where sb.Value is Storyboard select sb).Any())
{
    foreach(var child in Children)
    {
        var projection = child.WithPlaneProjection();                    

        var tag = child.SetUniqueName();
        var sb = new Storyboard();
        sb.Completed += (o, e) => sb.Stop();         
        var da = new DoubleAnimation {Duration = TimeSpan.FromMilliseconds(ANIMATION_DURATION)};
                    
        Storyboard.SetTarget(da, projection);
        Storyboard.SetTargetProperty(da, new PropertyPath(ROTATION_Y));                    
                   
        sb.Children.Add(da);                 
        Resources.Add(tag, sb);
    }
}

foreach (var child in Children)
    child.Measure(_itemSize);

base.MeasureOverride(availableSize);

return new Size(OFFSET*(Children.Count - 1) + ITEM_WIDTH + (2.0*MARGIN), ITEM_HEIGHT * 1.33);

The last step is important because that is what lets the parent containers know how big this panel really is, so they can size accordingly. It's also needed by the built-in scrollviewer in the listbox to know when to show scrollbars.

The arrange step is where we start to do some real work. First, we just check the listbox and make sure there is a selected item because we position the panels based on which item is selected.

protected override Size ArrangeOverride(Size finalSize)
{
    var listBox = GetListBox();

    if (listBox == null || Children.Count < 1)
    {
        return base.ArrangeOverride(finalSize);
    }

    if (listBox.SelectedIndex < 0)
    {
        listBox.SelectedIndex = 0;
    }

    var selectedIndex = listBox.SelectedIndex;

    var top = (finalSize.Height - ITEM_HEIGHT)/2;
    var zIndex = 10000;
}

The "top" is a margin from the top to help center the panel. The zIndex will be used to ensure the selected panel is on top of the other panels. That's a little-known tip: you can use the Canvas.ZIndex property even if you are not in a canvas.

Now, we'll handle any panels to the left of the selected panel. We'll position them with an offset from the left edge of the panel. When you arrange, you provide a Rect that includes the offset from the left and top of the container, as well as the width and height of the child. Based on this, we'll tell the child to arrange itself with the Rect we pass in. This positions it on the panel. The next thing we do is set up an animation. If the child is already angled, the animation won't do anything because we're only setting the To property. If it was selected, it will animate to the desired angle and position. Because we stop the animation automatically, we want to set that final angle as well so the new property takes over when the animation is done. Finally, we increment the zIndex so successive panels are in front of the previous ones.

for (var leftCounter = 0; leftCounter < selectedIndex; leftCounter++)
{
    var left = leftCounter*OFFSET + MARGIN;
    var finalRect = new Rect(left, top, ITEM_WIDTH, ITEM_HEIGHT);
    Children[leftCounter].Arrange(finalRect);

    _AnimateTo(Children[leftCounter].GetValue(TagProperty).ToString(), LEFT_ANGLE);
    Children[leftCounter].WithPlaneProjection().SetValue(PlaneProjection.RotationYProperty, LEFT_ANGLE);
                
    Children[leftCounter].SetValue(Canvas.ZIndexProperty, zIndex);
    zIndex++;
}

The animation method takes the identifier we stored in the Tag property and uses that to look up the storyboard, then sets the animation based on the target and fires it off:

private void _AnimateTo(string sbKey, double angle)
{            
    if (!Resources.Contains(sbKey)) return;

    var sb = (Storyboard) Resources[sbKey];
    var da = (DoubleAnimation) (sb.Children[0]);
            
    if (!sb.GetCurrentState().Equals(ClockState.Stopped))
    {
        sb.Stop();
    }
    da.To = angle;           
    sb.Begin();
}

We can then do the same thing for the right panels, going from the outside in:

for (var rightCounter = Children.Count - 1; rightCounter > selectedIndex; rightCounter--)
{
    var left = rightCounter*OFFSET + MARGIN;
    var finalRect = new Rect(left, top, ITEM_WIDTH, ITEM_HEIGHT);
    Children[rightCounter].Arrange(finalRect);

    _AnimateTo(Children[rightCounter].GetValue(TagProperty).ToString(), RIGHT_ANGLE);
    Children[rightCounter].WithPlaneProjection().SetValue(PlaneProjection.RotationYProperty, RIGHT_ANGLE);
                
    Children[rightCounter].SetValue(Canvas.ZIndexProperty, zIndex);
    zIndex++;
}

The final step is to show the selected item with no rotation (we'll animate it to that position from wherever it was). This will have the highest zIndex so it appears on top. We return the size based on our final arrange call, which takes into account all of the children:

var leftMargin = selectedIndex*OFFSET + MARGIN;
Children[selectedIndex].Arrange(new Rect(leftMargin, top, ITEM_WIDTH, ITEM_HEIGHT));
_AnimateTo(Children[selectedIndex].GetValue(TagProperty).ToString(), 0d);                
Children[selectedIndex].WithPlaneProjection().SetValue(PlaneProjection.RotationYProperty, 0d);
Children[selectedIndex].SetValue(Canvas.ZIndexProperty, zIndex);

return base.ArrangeOverride(finalSize);

Now comes the fun part: wiring it up. First, I created a JSON file to easily add projects as I have new ones to share:

{
    "Projects": [
        { 
            "Title": "Sterling Object-Oriented Database for Silverlight 4 and Windows Phone 7",
            "Description": "I am the main developer and coordinator for Sterling, a lightweight object-oriented database implementation on Silverlight and Windows Phone 7 that works with your existing class structures. Sterling supports full LINQ to Object queries over keys and indexes for fast retrieval of information from large data sets.",
            "Uri" : "http://sterling.codeplex.com/"
        },
        { 
            "Title": "Microsoft 2010 Vancouver Winter Olympics",
            "Description": "In this project, I used Silverlight 3, ASP.NET, C# Windows Services and the Managed Extensibility Framework (MEF) to build an internal health monitoring system with a realtime dashboard that enabled on-demand remote configuration changes for the IIS Smooth Streaming servers that delivered the live and archived Olympic content. The system these tools supported generated over 12 petabytes of data, and at peak loads streamed 374 gigabits of video per second and handled 2.4 million pages per second. With 82 million mobile page views, 1.9 million mobile video streams, and 5,000 hours of live and on-demand video, the Olympics streaming video system was a wonderful demonstration of Microsoft technology and media platforms.",
            "Uri": "http://wintellect.com/Consulting/Case-Studies/2010-Winter-Olympic-Health-Monitoring-System"
        },
        {
            "Title": "Jounce MVVM with MEF Guidance for Silverlight Applications",
            "Description": "Jounce is a reference framework I built for Silverlight intended to provide guidance for building modular line of business applications that follow the MVVM pattern and utilize the Managed Extensibility Framework (MEF). Jounce is inspired by existing frameworks including Prism and Caliburn.Micro. Jounce is based on my real world experience building enterprise line of business modular Silverlight applications.",
            "Uri": "http://jounce.codeplex.com/"
        },
        {
            "Title": "Social Analytics and Marketing Tool",
            "Description": "Microsoft’s Looking Glass product is a Social Network Analytics and Marketing tool that is the result of a two-year effort by the Developer and Platform Evangelist (DPE) team. For this project I was brought in to redesign the architecture of the Silverlight client to support Silverlight 4 and the MVVM pattern. The code was also migrated fully to Silverlight 4. Considerations for this part of the effort included provision of a core framework that addresses the MVVM implementation, modularity, and extensibility using the Managed Extensibility Framework (MEF), isolated storage for caching where performance benefits were identified, and full unit testing using the Silverlight Unit Testing Framework.",
            "Uri": "http://wintellect.com/Consulting/Case-Studies/Looking-Glass"
        },
        { 
            "Title": "Silverlight SharePoint Business Application Proof of Concept",
            "Description": "I worked with Microsoft Corp. and PricewaterhouseCoopers using Silverlight 4 to build integrated SharePoint web parts for a SharePoint-based line of business application. Taking advantage of the SharePoint 2010 new client object model, we developed rich, interactive web parts that integrated directly to SharePoint lists. This included design-time aware Silverlight controls that could be easily styled and manipulated in Microsoft Expression Blend via sample data automatically generated when the controls are in design mode. The Collaborative Application Markup Language (CAML) was used to query the SharePoint lists to interact directly with SharePoint data.",
            "Uri": "http://wintellect.com/Consulting/Case-Studies/Integrating-SharePoint-Silverlight-Web-Parts"
        },
        {
            "Title": "MEF Contrib",
            "Description": "MefContrib is a community-developed set of extensions, tools and samples for the Managed Extensibility Framework (MEF). The project is an open source project, licensed under the MS-PL license. I provide quick start articles and video tutorials for this project.",
            "Uri": "http://mefcontrib.com/"
        }

    ]
}

Next, the view model. It does two things: first, it fetches the list of projects and parses them from JSON into a list of classes that can be bound to the list. Second, it exposes a command for paging through the projects. It takes a parameter to specify if it bound for forward or backward and will advanced the selected index until it hits the end (or vice versa).

The external command is passed to the individual project items, and is used to open a new web page where the project or case study exists.

[ExportAsViewModel(Constants.VIEWMODEL_PROJECTS)]
public class ProjectsViewModel : BaseViewModel
{
    private const string PROJECT_TEXT = "Projects.js";

    public ObservableCollection<ProjectItem> Projects { get; private set; }        

    private ProjectItem _currentItem;
    public ProjectItem CurrentItem
    {
        get { return _currentItem; }
        set
        {
            _currentItem = value;
            RaisePropertyChanged(() => CurrentItem);
            NavigateProject.RaiseCanExecuteChanged();
        }
    }

    public IActionCommand<bool> NavigateProject { get; private set; }

    public IActionCommand<Uri> NavigateExternalProject { get; private set; }

    /// 
    ///     Constructor checks for design time or initiates download of items
    /// 
    public ProjectsViewModel()
    {
        Projects = new ObservableCollection<ProjectItem>();

        NavigateProject = new ActionCommand<bool>(_Navigate, _CanNavigate);

        NavigateExternalProject = new ActionCommand<Uri>(_NavigateExternal, _CanNavigateExternal);

        if (InDesigner)
        {
            Projects.Add(new ProjectItem
                                {
                                    Title = "Sample Project",
                                    Description = "This is a sample project that I worked on."
                                });
            Projects.Add(new ProjectItem
            {
                Title = "Yet Another Project with a Much Longer Title",
                Description = "This is another project that I worked on."
            });
        }
        else
        {
            HelperExtensions.LoadResource(new Uri(PROJECT_TEXT, UriKind.Relative), ParseProjects);                
        }
    }

    private static bool _CanNavigateExternal(Uri arg)
    {
        return arg == null ? false : !arg.Equals(HtmlPage.Document.DocumentUri);
    }

    private static void _NavigateExternal(Uri target)
    {
        HtmlPage.Window.Navigate(target, "_blank");
    }

    private bool _CanNavigate(bool forward)
    {
        if (Projects.Count == 0)
        {
            return false;
        }
            
        return forward 
                    ? _currentItem != Projects[Projects.Count - 1]
                    : _currentItem != Projects[0];
    }

    private void _Navigate(bool forward)
    {
        if (!_CanNavigate(forward)) return;

        var curIndex = Projects.IndexOf(_currentItem);
        CurrentItem = forward ? Projects[curIndex + 1] : Projects[curIndex - 1];
    }

    private void ParseProjects(string result)
    {
        const string PROJECTS = "Projects";
        const string TITLE = "Title";
        const string DESCRIPTION = "Description";
        const string URI = "Uri";     
            
        if (string.IsNullOrEmpty(result))
        {
            return;
        }

        var json = JsonValue.Parse(result);

        var projects = json[PROJECTS] as JsonArray;
        if (projects == null) return;
        foreach (var projectItem in projects.Select(project => new ProjectItem
                                                                    {
                                                                        Title = project[TITLE],
                                                                        Description = project[DESCRIPTION],
                                                                        NavigationUri = project.ContainsKey(URI) ? new Uri(project[URI],UriKind.Absolute) :
                                                                        HtmlPage.Document.DocumentUri,
                                                                        NavigationCommand = NavigateExternalProject
                                                                    }))
        {
            Projects.Add(projectItem);
        }
    }        
}

Now we just need to bind the XAML. First, I'll define the "true/false" parameters for the command buttons to navigate the project:

<Grid.Resources>
    <System:Boolean x:Key="True">true</System:Boolean>
    <System:Boolean x:Key="False">false</System:Boolean>
</Grid.Resources>

Next is the buttons, bound to the forward/backward respectively:

<StackPanel Grid.Row="1" Margin="3" HorizontalAlignment="Center" VerticalAlignment="Center"
            Orientation="Horizontal">
    <Button Content=" &lt; " Style="{StaticResource BlueButton}" Command="{Binding NavigateProject}"
            CommandParameter="{Binding Source={StaticResource False}}"/>                           
    <Button Content=" &gt; " Style="{StaticResource BlueButton}" Margin="3,0,0,0"
            Command="{Binding NavigateProject}" CommandParameter="{Binding Source={StaticResource True}}"/>
</StackPanel>

Finally, the listbox. Note we are using the built-in features that will highlight a panel when we hover, highlight the selected panel, and also swap the selected item when one is clicked. These services are provided by the listbox control, while the layout and view is arranged by our custom panel that we specify in the ItemsPanel section. The DataTemplate just contains the grid/button that goes inside each panel. Here is the listbox definition:

<ListBox Margin="5" HorizontalAlignment="Center" VerticalAlignment="Top"          
    Background="{x:Null}"
    Grid.Row="2" ItemsSource="{Binding Projects}"
            SelectedItem="{Binding CurrentItem,Mode=TwoWay}">    
<ListBox.ItemsPanel>
    <ItemsPanelTemplate>
        <Controls:ThreeDPanel/>
    </ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
    <Style TargetType="ListBoxItem">
        <Setter Property="HorizontalContentAlignment" Value="Stretch"></Setter>
        <Setter Property="VerticalContentAlignment" Value="Stretch"></Setter>
    </Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
        <DataTemplate>...panel content...        
        </DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

Notice the inline style that targets a listbox item. By default, the items will not stretch to fill the container. This can create some issues and headaches with layout. Adding this style implicitly styles the listbox items so they do stretch and fill the parent container. This allows the grid we define in our data template to properly size itself within the individual panel.

And that's it - you've now learned how to create a custom panel and use it inside a listbox to create a custom carousel-style effect. I'm sure from here you can imagine the steps to center the panel, make a reflection, etc. that ultimately leads to full blown carousel controls. You should also realize by now that almost any type of UI paradigm can be created simply by styling the listbox.

Jeremy Likness

No comments:

Post a Comment