Saturday, December 11, 2010

Lessons Learned in Personal Web Page Part 2: Enter the Matrix

This is the second part in the post of more "fun" effects and challenges I worked through in Silverlight when building my personal web page.

One effect I really wanted to work out is the Matrix-style effect you can see on my blog page. After looking at the video I determined the main characters seemed to remain in a fixed position and it's the shading of them that creates the motion. I was able to create the effect using the MVVM model (I wasn't trying to force this, it just happened naturally) and storyboards - there is no timer or code behind involved as it is all done with XAML, data-binding, and the defined behaviors that create the storyboards.

Working from the bottom up, I first wanted to tackle having some random, funky characters. I knew that I would be handling "columns" of these, so I started with what a column would look like. The MatrixText class takes the size of the column and generates the characters for me, exposing them as a list of strings. I inherited from observable collection in case I wanted to manipulate the strings:

public class MatrixText : ObservableCollection<string>
{     
    private static readonly Random _random = new Random();        

    public Guid Id { get; private set; }

    public MatrixText() 
    {
        Id = Guid.NewGuid();
    }

    public MatrixText(int size) : this()
    {
        var bytes = new byte[size];
        _random.NextBytes(bytes);
        foreach(var c in bytes)
        {
            Add(new string((char)c, 1));
        }
    }

    public override bool Equals(object obj)
    {
        return obj is MatrixText && ((MatrixText) obj).Id.Equals(Id);
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

Next, I created the MatrixStack class to hold any number of MatrixText to complete the grid. It also takes a constructor with the number of items, and then simply spins up a list. Keep in mind each element is automatically generating characters:

public class MatrixStack : ObservableCollection<MatrixText>
{
    public Guid Id { get; private set; }       

    public MatrixStack() 
    {
        Id = Guid.NewGuid();
    }

    public MatrixStack(int size, int textSize) : this()
    {
        var remaining = size;
        while (remaining-- > 0)
        {
            Add(new MatrixText(textSize));
        }
    }

    public override bool Equals(object obj)
    {
        return obj is MatrixStack && ((MatrixStack) obj).Id.Equals(Id);
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

So now I can easily create the matrix of characters with a single class. Now I could build the view model, which exposes the matrix stack:

[ExportAsViewModel(Constants.VIEWMODEL_BLOG)]
public class BlogViewModel : BaseViewModel 
{
    private const string CSHARPER = "http://feeds.feedburner.com/csharperimage";

    public BlogViewModel()
    {
        Stack = InDesigner ? new MatrixStack(40, 20) : new MatrixStack(76, 70);
        Blog = new ObservableCollection<BlogEntryItem>();
        NavigateExternalProject = new ActionCommand<Uri>(_NavigateExternal);

        if (InDesigner)
        {
            Blog.Add(new BlogEntryItem
                            {
                                Link = new Uri("http://www.bing.com/", UriKind.Absolute),
                                Title = "This is a Sample Blog Entry of Mine",
                                Posted = DateTime.Now                                 
                            }
                );
            Blog.Add(new BlogEntryItem
                            {
                                Link = new Uri("http://www.bing.com/", UriKind.Absolute),
                                Title = "This is a Another Sample Blog Entry of Mine",
                                Posted = DateTime.Now                                 
                            }
                );
        }
        else
        {
            HelperExtensions.LoadResource(new Uri(CSHARPER, UriKind.Absolute), ParseBlog);  
        }
    }

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

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

    private void ParseBlog(string feedSrc)
    {
        if (string.IsNullOrEmpty(feedSrc))
        {
            return;
        }

        try
        {
            var reader = XmlReader.Create(new StringReader(feedSrc));
            var feed = SyndicationFeed.Load(reader);
            JounceHelper.ExecuteOnUI(()=>{
            foreach (var feedItem in feed.Items)
            {
                Blog.Add(new BlogEntryItem
                                {
                                    Link = feedItem.Links[0].GetAbsoluteUri(),
                                    Posted = feedItem.PublishDate.Date,
                                    Title = feedItem.Title.Text,
                                    NavigateCommand = NavigateExternalProject
                                });
            }
            });           
        }
        catch(Exception ex)
        {
            Debug.WriteLine(ex.ToString());
        }
    }

    public MatrixStack Stack { get; private set; }

    public ObservableCollection<BlogEntryItem> Blog { get; private set; }
}

There are a few moving pieces here. The matrix is created and sized based on whether or not we are in the designer (a smaller matrix will be created at design time). I also have the list of blog entries and either populate these with sample data or use the extension helper I introduced in the last post to fetch the feed and parse it using the SyndicationFeed class.

That's it for the view model - it provides all of the binding I need to create the effect. It also has all of the code to download and parse the RSS feed from my blog.

The next thing I did was get the characters on the screen using binding. I used a combination of items controls, one for each column and another for each character:

<ItemsControl Margin="100 0 0 0" Opacity="0.6" ItemsSource="{Binding Stack}"> 
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Grid>
                <ItemsControl ItemsSource="{Binding}">
                    <Interactivity:Interaction.Behaviors>
                        <Behaviors:FreezeBehavior/>
                    </Interactivity:Interaction.Behaviors>
                    <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <StackPanel Orientation="Vertical"/>                                          
                        </ItemsPanelTemplate>
                    </ItemsControl.ItemsPanel>
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="16" FontWeight="Bold" Foreground="LawnGreen"/>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
                <Rectangle HorizontalAlignment="Stretch" VerticalAlignment="Stretch" 
                            Margin="0 -800 0 0"
                            Fill="Black">
                    <Rectangle.RenderTransform>
                        <ScaleTransform ScaleY="2.0"/>
                    </Rectangle.RenderTransform>
                    <Rectangle.OpacityMask>
                        <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                            <GradientStop Offset="0" Color="#FF000000"/>
                            <GradientStop Offset="0.3" Color="#FF000000"/>
                            <GradientStop Offset="0.6" Color="#00000000"/>
                            <GradientStop Offset="0.6001" Color="#FF000000"/>
                            <GradientStop Offset="1.0" Color="#FF000000"/>
                        </LinearGradientBrush>
                    </Rectangle.OpacityMask>
                    <Interactivity:Interaction.Behaviors>
                        <Behaviors:MatrixMotionBehavior Height="600"/>
                    </Interactivity:Interaction.Behaviors>
                </Rectangle>
            </Grid>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

One thing that may jump out is a few behaviors. We'll look at these in a second. Essentially, the binding will create our matrix of columns and rows of characters. The key to the effect is the rectangle in each column. You'll notice it has an opacity mask and a negative margin. This allows me to mask the intensity of green on the characters because the rectangle overlays them. The negative margin starts the rectangle over the top of the page so I can bring it down to cover the characters. In order to have different characters "streaming" at different speeds, I simply offset the vertical position of the rectangle using a storyboard run at different speeds. Here is the behavior:

public class MatrixMotionBehavior : Behavior<FrameworkElement>
{
    private static readonly Random _random = new Random();

    public int Height { get; set; }

    protected override void OnAttached()
    {
        var storyboard = new Storyboard();
        var sbName = HelperExtensions.UniqueName();
        storyboard.SetValue(FrameworkElement.NameProperty, sbName);

        var da = new DoubleAnimation
                        {
                            Duration = TimeSpan.FromSeconds(1.0 + _random.NextDouble()*8.0)
                        };

        da.SetValue(FrameworkElement.NameProperty, HelperExtensions.UniqueName());

        var translateTransform = AssociatedObject.WithTransform<TranslateTransform>();
        Storyboard.SetTarget(da, translateTransform);
        Storyboard.SetTargetProperty(da, new PropertyPath("(TranslateTransform.Y)"));

        da.RepeatBehavior = RepeatBehavior.Forever;
        da.From = -1*Height;
        da.To = Height*2;

        storyboard.Children.Add(da);
        AssociatedObject.Resources.Add(sbName, storyboard);
        storyboard.Begin();
    }
}

The behavior takes whatever it is attached to and creates a storyboard that moves the Y offset using a translate transform. It will animate through whatever range is specified by the height. I knew I'd tweak this in XAML so I didn't make it a dependency property, but it could be for binding. I have to give the storyboards a unique name and add them to the resources of the container before I can launch them.

To grab the translate transform, I use a helper method I created that always ensures I have a valid transformation to work with. It is non-destructive, so if there are other transformations, it will work nicely with them. For example, if there is a scale transform, this helper extension with create a transformation group, move the scale transform into the group, then add the translation transform. You can close this generic extension with any transformation:

public static T WithTransform<T>(this UIElement control) where T: Transform, new() 
{
    T transform;

    if (control.RenderTransform == null)
    {
        transform = new T();
        control.RenderTransform = transform;
    }
    else if (control.RenderTransform is TranslateTransform)
    {
        transform = (T) control.RenderTransform;
    }
    else if (control.RenderTransform is TransformGroup)
    {
        var g = (TransformGroup) control.RenderTransform;
        transform = (from t in g.Children where t is T select (T)t).FirstOrDefault();
        if (transform == null)
        {
            transform = new T();
            g.Children.Add(transform);
        }
    }
    else
    {
        var g = new TransformGroup();
        var temp = control.RenderTransform;
        control.RenderTransform = g;
        g.Children.Add(temp);
        transform = new T();
        g.Children.Add(transform);
    }

    return transform;
}

I also have an extension to ensure unique names:

public static string SetUniqueName(this UIElement control)
{
    var guid = UniqueName();
    control.SetValue(FrameworkElement.TagProperty, guid);
    return guid;
}

public static string UniqueName()
{
    return Guid.NewGuid().ToString().Replace("-", string.Empty);            
}

At this point we have a fairly serviceable effect, with two problems. First, the performance is less than par because the render engine has to manage every individual TextBlock on the page. The second issue is that without clipping, the rectangle ends up masking other areas of the page that wouldn't be affected. To fix the clip, I used a behavior that automatically creates a clip the size of the container it is bound to:

public class Clip
{
    public static bool GetToBounds(DependencyObject depObj)
    {
        return (bool)depObj.GetValue(ToBoundsProperty);
    }

    public static void SetToBounds(DependencyObject depObj, bool clipToBounds)
    {
        depObj.SetValue(ToBoundsProperty, clipToBounds);
    }

    public static readonly DependencyProperty ToBoundsProperty =
    DependencyProperty.RegisterAttached("ToBounds", typeof(bool),
    typeof(Clip), new PropertyMetadata(false, OnToBoundsPropertyChanged));

    private static void OnToBoundsPropertyChanged(DependencyObject d,
    DependencyPropertyChangedEventArgs e)
    {
        var fe = d as FrameworkElement;
        if (fe != null)
        {
            ClipToBounds(fe);
            fe.Loaded += FeLoaded;
            fe.SizeChanged += FeSizeChanged;
        }
    }

    private static void ClipToBounds(FrameworkElement fe)
    {
        if (GetToBounds(fe))
        {
            fe.Clip = new RectangleGeometry
                            {
                Rect = new Rect(0, 0, fe.ActualWidth, fe.ActualHeight)
            };
        }
        else
        {
            fe.Clip = null;
        }
    }

    static void FeSizeChanged(object sender, SizeChangedEventArgs e)
    {
        ClipToBounds(sender as FrameworkElement);
    }

    static void FeLoaded(object sender, RoutedEventArgs e)
    {
        ClipToBounds(sender as FrameworkElement);
    }
}

Attaching this creates a clip the size of the containing grid, so when the rectangles are off the top of the screen, they don't overlay the header of the page.

Next, to keep from having to re-render every character, I decided to capture each column as an image. The columns remain static and it is only the mask that moves (I could probably have extended this to make a single image of the entire frame as well). This considerably improved performance although it is still takes significant CPU to recalculate the masks each frame. The freeze behavior can attach to any UIElement inside a grid. It will automatically render the element to an image, then swap the element with the snapshot of the element:

public class FreezeBehavior : Behavior<FrameworkElement>
{
    protected override void OnAttached()
    {
        AssociatedObject.Loaded += AssociatedObject_Loaded;
    }

    void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
    {
        var parent = VisualTreeHelper.GetParent(AssociatedObject) as Panel;

        if (parent == null)
        {
            return;
        }

        var bitmap = new WriteableBitmap(AssociatedObject, new TranslateTransform());
        bitmap.Invalidate();

        var image = new Image {Width = bitmap.PixelWidth, Height = bitmap.PixelHeight, Source = bitmap};
        image.SetUniqueName();

        var pos = parent.Children.IndexOf(AssociatedObject);
        parent.Children.Remove(AssociatedObject);
        parent.Children.Insert(pos, image);            
            
        return;
    }        
}

While there may be some other ways to tweak this, I was very satisfied with the end result. By taking the challenge and breaking it into small, specific steps I was able to create the effect fairly closely to how I intended and then overlay the blog entries on top of it.

In the next post for this series, I'll tackle the more practical task of creating a custom panel to use in a list box for a carousel-like effect.

The source for this project is available from my home page: JeremyLikness.com.

Jeremy Likness