Making a Menu the Blend-y Way

A couple days ago, I was playing around in Blend (4) because I wanted to make a menu for a keyboard that runs a modified version of Silverlight 3. I thought that I might be able to do the whole menu without writing much code, since there are a bunch of built-in controls and behaviors. In the end, though, my menu ended up being about 99% C# (and 1% auto-generated cruft), since I needed the menu for later parts in my project and I knew I could code it quickly enough.

Here’s a build of the menu from before I integrated it with a real data source: AdaptiveMenu

(Pressing Edit, File on the top, Recent, and then “x” should give you a good idea of what how it animates.)

If I had known how to use Blend… might I have been able to make this menu mostly from the design mode? Would that have been simpler?

So you can see how it was structured, here’s the main C# file:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;

using System.ComponentModel;

namespace AdaptiveMenu
{
	public partial class MainPage : UserControl
	{
		private static int ADAPTIVE_WIDTH = 1366;
		private static int ADAPTIVE_MENU_WIDTH = 1266;
		private static int ADAPTIVE_HEIGHT = 216;
		private static int ADAPTIVE_TAB_HEIGHT = 66;
		private static int ADAPTIVE_MAIN_HEIGHT = 150;
		private List<AdaptiveButton> buttons;
		private List<AdaptiveButton> subButtons;
		
		// hard-coded demo menu.  these will be supplied by the host computer's UIAutomation
		private string[] tabTitles = {"Google.com", "ROC HCI", "to.", "example.com"};
		private string[] items = {"File", "Edit", "View", "Object", "Project", "Back"};
		private string[] fileItems = {"New", "Open", "Recent", "Close", "Save"};
		private string[] editItems = {"Undo", "Redo", "Cut", "Copy", "Paste", "Find", "Replace"};
		private string[] viewItems = {"Zoom In", "Zoom Out", "Actual Size"};
		private string[] recentItems = {"files.txt", "secretInfo.docx", "hmmm.xlsx"};
		private string[] subItems = {"Copy", "Paste", "Preferences"};
		
		private List<AdaptivePath> tabs;
		private List<AdaptiveButton> tabButtons;
		
		private List<AdaptiveButton> mainButtons;
		private List<AdaptiveButton> topButtons;
		private List<AdaptiveButton> incomingButtons;
		
		private Button resetButton;
		
		private Dictionary<AdaptivePath, List<AdaptivePath>> menuMap;
		private AdaptivePath root;
		
		public MainPage()
		{
			// Required to initialize variables
			InitializeComponent();
			
			menuMap = new Dictionary<AdaptivePath, List<AdaptivePath>>();
			InitMenu();
			this.Loaded += new RoutedEventHandler(OnLoad);
		}
		
		void OnLoad(object sender, RoutedEventArgs e){
			InitInitialButtons();
			InitResetButton();
		}
		
		void InitInitialButtons(){
			// at the start, we display the root (null) menu and some tabs
			mainButtons = MakeButtons(GetRow(null), ADAPTIVE_MAIN_HEIGHT);
			topButtons = MakeButtons(tabs, ADAPTIVE_TAB_HEIGHT);
			
			// position main-sized buttons
			for(int i = 0; i < mainButtons.Count; i++){
				AdaptiveButton b = mainButtons*;
				b.Click += new RoutedEventHandler(OnClick);
				Canvas.SetTop(b, ADAPTIVE_TAB_HEIGHT);
			}
		}
		
		void InitResetButton(){
			resetButton = new Button();
			resetButton.Content = "x";
			resetButton.Width = ADAPTIVE_WIDTH - (ADAPTIVE_MENU_WIDTH);
			resetButton.Height = ADAPTIVE_MAIN_HEIGHT + ADAPTIVE_TAB_HEIGHT;
			LayoutRoot.Children.Add(resetButton);
			Canvas.SetLeft(resetButton, ADAPTIVE_MENU_WIDTH);
			resetButton.IsEnabled = false;
			resetButton.Click += new RoutedEventHandler(OnResetClick);
		}
		
		// handler for "main" button clicks
		void OnClick(object sender, RoutedEventArgs e){
			AdaptiveButton b = sender as AdaptiveButton;
			AdaptivePath p = b.Path;
			
			// don't do anything unless this button leads somewhere
			// TODO: in actuality, this needs to contact the main app and use UIAutomation here
			if(p != null && GetRow(p) != null){
				incomingButtons = MakeButtons(GetRow(p), ADAPTIVE_MAIN_HEIGHT);
				for(int i = 0; i < incomingButtons.Count; i++){
					AdaptiveButton ib = incomingButtons*;
					Canvas.SetTop(ib, ADAPTIVE_MAIN_HEIGHT + ADAPTIVE_TAB_HEIGHT);
				}
				
				CompositionTarget.Rendering += new EventHandler(AnimateShiftUp);
			}
		}
		
		// handler for the top-row buttons
		void OnClickTop(object sender, RoutedEventArgs e){
			AdaptiveButton b = sender as AdaptiveButton;
			AdaptivePath p = b.Path;
			
			// TODO: again, we need UIAutomation here for the actual implementation of this menu
			if(p != null && GetRow(p) != null){
				// remove old main buttons
				foreach(AdaptiveButton ob in mainButtons){
					ob.Click -= OnClick;
					LayoutRoot.Children.Remove(ob);
				}
				
				// add replacement buttons
				mainButtons = MakeButtons(GetRow(p), ADAPTIVE_MAIN_HEIGHT);
				for(int i = 0; i < mainButtons.Count; i++){
					AdaptiveButton mb = mainButtons*;
					Canvas.SetTop(mb, ADAPTIVE_TAB_HEIGHT);
					mb.Opacity = 0;
				}
				CompositionTarget.Rendering += new EventHandler(AnimateFadeIn);
			}
		}
		
		void OnResetClick(object sender, RoutedEventArgs e){
			foreach(AdaptiveButton b in topButtons){
				b.Click -= OnClickTop;
				LayoutRoot.Children.Remove(b);
			}
			foreach(AdaptiveButton b in mainButtons){
				b.Click -= OnClick;
				LayoutRoot.Children.Remove(b);
			}
			
			InitInitialButtons();
			
			foreach(AdaptiveButton b in topButtons){
				b.Opacity = 0;
			}
			foreach(AdaptiveButton b in mainButtons){
				b.Opacity = 0;
			}
			
			resetButton.IsEnabled = false;
			CompositionTarget.Rendering += new EventHandler(AnimateResetFadeIn);
		}
		
		void AnimateShiftUp(object sender, EventArgs e){
			double dIncoming = Canvas.GetTop(incomingButtons[0]) - ADAPTIVE_TAB_HEIGHT;
			double dRest = Canvas.GetTop(mainButtons[0]);
			double dScale = mainButtons[0].Height - ADAPTIVE_TAB_HEIGHT;
			
			double moveAmountIncoming = MoveFunc(dIncoming);
			double moveAmountRest = MoveFunc(dRest);
			double scaleAmount = MoveFunc(dScale);
			
			if(Canvas.GetTop(mainButtons[0]) > 0){ // still animating
				foreach(AdaptiveButton b in topButtons){
					Canvas.SetTop(b, Canvas.GetTop(b) - moveAmountRest);
				}
				foreach(AdaptiveButton b in mainButtons){
					Canvas.SetTop(b, Canvas.GetTop(b) - moveAmountRest);
					b.Height -= scaleAmount;
				}
				foreach(AdaptiveButton b in incomingButtons){
					Canvas.SetTop(b, Canvas.GetTop(b) - moveAmountIncoming);
				}
			} else { // done animating
				foreach(AdaptiveButton b in topButtons){
					b.Click -= OnClickTop;
					LayoutRoot.Children.Remove(b);
				}
				foreach(AdaptiveButton b in mainButtons){
					b.Click -= OnClick;
					b.Click += new RoutedEventHandler(OnClickTop);
				}
				foreach(AdaptiveButton b in incomingButtons){
					b.Click += new RoutedEventHandler(OnClick);
				}
				
				topButtons = mainButtons;
				mainButtons = incomingButtons;
				
				resetButton.IsEnabled = true;
				
				CompositionTarget.Rendering -= AnimateShiftUp;
			}
		}
		
		void AnimateFadeIn(object sender, EventArgs e){
			double d = 100 * (1 - mainButtons[0].Opacity);
			double delta = MoveFunc(d, true) / 100;
			
			// still animating opacity up to 1
			if(mainButtons[0].Opacity < 1){
				foreach(AdaptiveButton b in mainButtons){
					b.Opacity += delta;
				}
			} else { // done animating
				foreach(AdaptiveButton b in mainButtons){
					b.Click += new RoutedEventHandler(OnClick);
				}
				CompositionTarget.Rendering -= AnimateFadeIn;
			}
		}
		
		// very similar to AnimateFadeIn, but it also fades in the top buttons
		void AnimateResetFadeIn(object sender, EventArgs e){
			double d = 100 * (1 - mainButtons[0].Opacity);
			double delta = MoveFunc(d, true) / 100;
			
			if(mainButtons[0].Opacity < 1){
				foreach(AdaptiveButton b in topButtons){
					b.Opacity += delta;
				}
				foreach(AdaptiveButton b in mainButtons){
					b.Opacity += delta;
				}
			} else {
				CompositionTarget.Rendering -= AnimateResetFadeIn;
			}
		}
		
		// easing function for animating button motion
		double MoveFunc(double distance, bool slow = false){
			double speed = 20;
			if(slow){
				speed = 6;
			}
			int step = 1;
			if(slow){
				step = 4;
			}
			double moveAmount = distance * (speed / 100);
			return Math.Min(Math.Max(moveAmount, step), distance);
		}
		
		// a wrapper for Dictionary access that handles invalid keys and the null key as our root
		List<AdaptivePath> GetRow(AdaptivePath pressed){
			if(pressed == null){
				return menuMap[root];
			}
			
			if(!menuMap.ContainsKey(pressed)){
				return null;
			}
			
			return menuMap[pressed];
		}
		
		List<AdaptiveButton> MakeButtons(List<AdaptivePath> row, int height){
			List<AdaptiveButton> buttonRow = new List<AdaptiveButton>(row.Count);
			for(int i = 0; i < row.Count; i++){
				AdaptivePath p = row*;
				AdaptiveButton b = new AdaptiveButton();
				b.Path = p;
				//b.Name = p.Name; // names MUST be unique
				b.Content = p.Name;
				b.Width = ADAPTIVE_MENU_WIDTH / row.Count;
				b.Height = height;
				buttonRow.Add(b);
				LayoutRoot.Children.Add(b);
				Canvas.SetLeft(b, i * ((double) ADAPTIVE_MENU_WIDTH / row.Count));
			}
			return buttonRow;
		}
		
		/**
		 * This is just a hardcoded menu, because normally, Silverlight won't be generating the menu
		 * structure or items, and it won't know it ahead of time.
		 */
		void InitMenu(){
			root = new AdaptivePath();
			root.Name = "~Root~";
			
			List<AdaptivePath> topLevel = new List<AdaptivePath>(items.Length);
			foreach(string s in items){
				AdaptivePath ap = new AdaptivePath();
				ap.Name = s;
				topLevel.Add(ap);
				if(s == "File"){
					List<AdaptivePath> fileLevel = new List<AdaptivePath>(fileItems.Length);
					foreach(string ss in fileItems){
						AdaptivePath ap2 = new AdaptivePath();
						ap2.Name = ss;
						fileLevel.Add(ap2);
						if(ss == "Recent"){
							List<AdaptivePath> recentLevel = new List<AdaptivePath>(recentItems.Length);
							foreach(string s3 in recentItems){
								AdaptivePath ap3 = new AdaptivePath();
								ap3.Name = s3;
								recentLevel.Add(ap3);
								
								// this conditional makes a recursive section
								if(s3 == "hmmm.xlsx"){
									menuMap[ap3] = fileLevel;
								}
								if(s3 == "secretInfo.docx"){
									menuMap[ap3] = topLevel;
								}
							}
							menuMap[ap2] = recentLevel;
						}
					}
					menuMap[ap] = fileLevel;
				} else if(s == "Edit"){
					List<AdaptivePath> editLevel = new List<AdaptivePath>(editItems.Length);
					foreach(string ss in editItems){
						AdaptivePath ap2 = new AdaptivePath();
						ap2.Name = ss;
						editLevel.Add(ap2);
					}
					menuMap[ap] = editLevel;
				} else if(s == "View"){
					List<AdaptivePath> viewLevel = new List<AdaptivePath>(viewItems.Length);
					foreach(string ss in viewItems){
						AdaptivePath ap2 = new AdaptivePath();
						ap2.Name = ss;
						viewLevel.Add(ap2);
					}
					menuMap[ap] = viewLevel;
				}
			}
			menuMap[root] = topLevel;
			
			tabs = new List<AdaptivePath>();
			foreach(string title in tabTitles){
				AdaptivePath tp = new AdaptivePath();
				tp.Name = title;
				tabs.Add(tp);
			}
		}
	}
}