XAML Editor (Dynamic XAML & Popup)

0
170

XAML Editor is a text editor for XAML, much like the famous XAMLPad program for the Windows desktop. At first, XAML Editor looks like nothing more than a page with a text box, but it is much more for a number of reasons:

  • It renders the XAML you type as live objects (including any interactivity).
  • It provides XAML-specific auto-completion via a custom text suggestions bar (somewhat like Intellisense).
  • It has a menu of samples, to aid in experimentation.
  • It enables you to email your XAML, in case you come up with something you want to save.
  • It shows error information for invalid XAML in an unobtrusive way.

The custom text suggestions bar is vital for making this app usable, as without it common XAML characters like angle brackets, the forward slash, quotes, and curly braces are buried in inconvenient locations. With this bar, users don’t normally need to leave the first page of keyboard keys unless they are typing numbers.

On the surface, the main lesson for this chapter seems like the mechanism for reading XAML at run-time and producing a dynamic user interface. However, this is accomplished with just one line of code. The main challenge to implementing XAML Editor is providing a custom text suggestions bar. The real text suggestions bar does not support customization, so XAML Editor provides one with a lot of trickery involving an element known as Popup.

The trickery (or, to be honest, hacks) done by this chapter also forms a cautionary tale. In the initial version of Windows Phone 7 (version 7.0.7004.0), the fake suggestions bar was a reasonable replacement for the built-in one, as shown in Figure 11.1. With the addition of the copy/paste feature (starting with version 7.0.7338.0), however, it can no longer act this way. The app had relied on suppressing the real bar by using the default input scope on the text box, but app authors can no longer reliably do this because the bar still appears whenever something can be pasted. Furthermore, there is no way for the fake bar to integrate with the clipboard and provide its own paste button. Therefore, the latest version of XAML Editor treats the fake suggestions bar as a second bar on top of the primary one.

FIGURE 11.1 Because the Windows Phone copy/paste feature did not yet exist, the first version of XAML Editor could reliably place the fake suggestions bar where the real one would be.
FIGURE 11.1 Because the Windows Phone copy/paste feature did not yet exist, the first version of XAML Editor could reliably place the fake suggestions bar where the real one would be.

Popup

A popup is an element that floats on top of other elements. It was designed for temporary pieces of UI, such as tooltips. However, as in this chapter, is often used in hacky ways to produce behavior that is difficult to accomplish otherwise.

A popup doesn’t have any visual appearance by itself, but it can contain a visual element as its single child (and that child could be a complex element containing other elements). By default, a popup docks to the top-left corner of its parent, although you can move it by giving it a margin and/or setting its HorizontalOffset and VerticalOffset properties.

On Top of (Almost) Everything

Figure 11.2 demonstrates the behavior of the popup in the following page:

[code]

<phone:PhoneApplicationPage
x:Class=”WindowsPhoneApp.MainPage”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:phone=”clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone”
SupportedOrientations=”PortraitOrLandscape” Orientation=”Landscape”>
<Grid>
<!– Inner grid with a button-in-popup and a separate button –>
<Grid Background=”Red” Margin=”100”>
<Popup IsOpen=”True”>
<Button Content=”button in popup in grid” Background=”Blue”/>
</Popup>
<Button Content=”button in grid” Canvas.ZIndex=”100”/>
</Grid>
<!– A rectangle that overlaps the inner grid underneath it –>
<Rectangle Width=”200” Height=”200” Fill=”Lime”
HorizontalAlignment=”Left” VerticalAlignment=”Top”/>
</Grid>
</phone:PhoneApplicationPage>

[/code]

FIGURE 11.2 The popup’s content doesn’t stretch, stays in the top-left corner of its parent, and stays on top of all other elements.
FIGURE 11.2 The popup’s content doesn’t stretch, stays in the top-left corner of its parent, and stays on top of all other elements.

There are three interesting things to note about Figure 11.2:

  • A popup is only visible when its IsOpen property is set to true.
  • The layout inside a popup is like the layout inside a canvas; a child element is only given the exact amount of space it needs.
  • Popups have a unique power: They can render on top of all other Silverlight elements! Although the sibling button in Figure 11.2 has a larger z-index, and although the lime rectangle is a sibling to the popup’s parent (making it the popup’s uncle?), it appears on top of both of them!

Exempt from Orientation Changes

Besides their topmost rendering, popups have another claim to fame: they are able to ignore orientation changes! This happens when you create and show a popup without attaching it to any parent element. In this case, it is implicitly attached to the root frame, which always acts as if it is in the portrait orientation.

The following empty page demonstrates this behavior:

[code]

<phone:PhoneApplicationPage x:Class=”WindowsPhoneApp.MainPage”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:phone=”clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone”
SupportedOrientations=”PortraitOrLandscape” Orientation=”Landscape”>
<Grid x:Name=”Grid”/>
</phone:PhoneApplicationPage>

[/code]

In this page’s code-behind, two popups are created in the constructor. The first one is attached to the grid, but the second one is implicitly attached to the frame:

[code]

public MainPage()
{
InitializeComponent();
Popup popup1 = new Popup();
popup1.Child = new Button { Content = “button in popup in grid”, FontSize=40 };
popup1.IsOpen = true;
this.Grid.Children.Add(popup1); // Attach this to the grid
Popup popup2 = new Popup();
popup2.Child = new Button { Content = “button in popup”, FontSize=55,
Foreground = new SolidColorBrush(Colors.Cyan),
BorderBrush = new SolidColorBrush(Colors.Cyan) };
popup2.IsOpen = true; // Show without explicitly attaching it to anything
}

[/code]

This page is shown in Figure 11.3. The cyan button (inside popup2) behaves like the whole screen would behave if it were marked as SupportedOrientations=”Portrait”, whereas the white button (inside popup1) adjusts to remain on the edges of the screen currently acting as the top and the left.

FIGURE 11.3 The popup that isn’t attached to the grid stays docked to the physical top and left of the phone for any orientation.
FIGURE 11.3 The popup that isn’t attached to the grid stays docked to the physical top and left of the phone for any orientation.

Frame-rooted popups also do not move with the rest of the page when the on-screen keyboard automatically pushes the page upward to keep the focused textbox visible. XAML Editor leverages this fact, as the popup containing the text suggestions bar must always be in the exact same spot regardless of what has happened to the page.

The User Interface

Listing 11.1 contains the XAML for this app’s only page, shown at the beginning of this chapter. The page contains a text box on top of a grid used to hold the rendered result from parsing the XAML, and an application bar with four buttons and four menu items.

LISTING 11.1 MainPage.xaml—The User Interface for XAML Editor

[code]

<phone:PhoneApplicationPage
x:Class=”WindowsPhoneApp.MainPage”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:phone=”clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone”
xmlns:shell=”clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone”
Loaded=”MainPage_Loaded”
SupportedOrientations=”PortraitOrLandscape”>
<!– Application bar with 3-4 buttons and 4 menu items –>
<phone:PhoneApplicationPage.ApplicationBar>
<shell:ApplicationBar>
<shell:ApplicationBarIconButton Text=”view” Click=”SwitchViewButton_Click”
IconUri=”/Shared/Images/appbar.view.png”/>
<shell:ApplicationBarIconButton Text=”clear” Click=”ClearButton_Click”
IconUri=”/Shared/Images/appbar.cancel.png”/>
<shell:ApplicationBarIconButton Text=”email” Click=”EmailButton_Click”
IconUri=”/Shared/Images/appbar.email.png”/>
<shell:ApplicationBarIconButton Text=”error” Click=”ErrorButton_Click”
IconUri=”/Shared/Images/appbar.error.png”/>
<shell:ApplicationBar.MenuItems>
<shell:ApplicationBarMenuItem Text=”simple shapes”
Click=”SampleMenuItem_Click”/>
<shell:ApplicationBarMenuItem Text=”gradient text”
Click=”SampleMenuItem_Click”/>
<shell:ApplicationBarMenuItem Text=”clipped image”
Click=”SampleMenuItem_Click”/>
<shell:ApplicationBarMenuItem Text=”controls”
Click=”SampleMenuItem_Click”/>
</shell:ApplicationBar.MenuItems>
</shell:ApplicationBar>
</phone:PhoneApplicationPage.ApplicationBar>
<!– 1×1 grid containing 2 overlapping child grids –>
<Grid>
<!– where the live XAML goes –>
<Grid x:Name=”ViewPanel”/>
<!– The text editor–>
<Grid x:Name=”EditorPanel” Background=”{StaticResource PhoneBackgroundBrush}”
Opacity=”.9”>
<ScrollViewer x:Name=”ScrollViewer”>
<TextBox x:Name=”XamlTextBox” AcceptsReturn=”True” VerticalAlignment=”Top”
Height=”2048” TextWrapping=”Wrap” InputScope=”Text”
FontFamily=”Courier New” FontSize=”19” FontWeight=”Bold”
SelectionChanged=”XamlTextBox_SelectionChanged”
GotFocus=”XamlTextBox_GotFocus” LostFocus=”XamlTextBox_LostFocus”
TextChanged=”XamlTextBox_TextChanged”/>
</ScrollViewer>
</Grid>
</Grid>
</phone:PhoneApplicationPage>

[/code]

Notes:

  • This page supports all orientations for the sake of text entry.
  • Courier New, the phone’s only built-in fixed-width font, is used to give the text box a code-editor feel.
  • If the text box were to use the default input scope, then the text suggestions bar may or may not appear based on whether there’s something to paste. This would make it impossible to properly place this app’s fake suggestions bar directly above the on-screen keyboard because there’s no way for an app to detect whether there’s currently something on the clipboard. Therefore, the text box is marked with the Text input scope. With the real text suggestions bar always present, the fake one can be reliably placed on top of it. Plus, its standard text suggestions might occasionally be useful while editing XAML.
  • Although the text box supports internal scrolling of its content when the user holds down a finger and drags the caret, it is pretty challenging for users to do this in a satisfactory way. To combat this, the text box is given its maximum supported height and placed in a scroll viewer that enables much more user-friendly scrolling. (It is also marked with word wrapping to avoid the need for horizontal scrolling.) The explicit height is used rather than letting the text box grow on its own because the implementation of the fake suggestions bar requires that part of the text box is always underneath it, and this overlapping would obscure the bottom few lines of text if the text box weren’t longer than its text.Unfortunately, this causes the loss of an important text box feature—the ability to keep the caret visible on the screen while the user is typing. If you knew the current vertical position of the caret, you could scroll the scroll viewer with its ScrollToVerticalOffset method whenever the text changes. Unfortunately, the only caret position exposed by a text box is the character index in the string, and it takes a significant amount of work to calculate coordinates from this.

    Therefore, XAML Editor forces the user to manually scroll the page if the caret goes off-screen or gets hidden under the keyboard.

Elements have a size limitation!

You should avoid making any Silverlight element larger than 2,048 pixels in any dimension, due to system limitations.Otherwise, a variety of behaviors can be seen, such as forced clipping or even the entire screen going blank! The best workaround for a text box would be to virtualize its contents, e.g. only make it contain the on-screen contents (and perhaps a little more) at any single time. Implementing such a scheme while making sure scrolling and typing works as expected can be complex. XAML Editor simply hopes that users don’t type more than approximately 93 lines of XAML!

The Code-Behind

Listing 11.2 contains the code-behind for the main page.

LISTING 11.2 MainPage.xaml.cs—The Code-Behind for XAML Editor

[code]

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Navigation;
using System.Windows.Threading;
using Microsoft.Phone.Controls;
using Microsoft.Phone.Shell;
using Microsoft.Phone.Tasks;
namespace WindowsPhoneApp
{
public partial class MainPage : PhoneApplicationPage
{
// Always remember the text box’s text, caret position and selection
Setting<string> savedXaml = new Setting<string>(“XAML”, Data.SimpleShapes);
Setting<int> savedSelectionStart = new Setting<int>(“SelectionStart”, 0);
Setting<int> savedSelectionLength = new Setting<int>(“SelectionLength”, 0);
// The popup and its content are not attached to the page
internal Popup Popup;
internal TextSuggestionsBar TextSuggestionsBar;
// Named fields for two application bar buttons
IApplicationBarIconButton viewButton;
IApplicationBarIconButton errorButton;
// Remember the current XAML parsing error in case the user wants to see it
string currentError;
// A timer for delaying the update of the view after keystrokes
DispatcherTimer timer =
new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
public MainPage()
{
InitializeComponent();
// Assign the application bar buttons because they can’t be named in XAML
this.viewButton = this.ApplicationBar.Buttons[0]
as IApplicationBarIconButton;
this.errorButton = this.ApplicationBar.Buttons[3]
as IApplicationBarIconButton;
// Initialize the popup and its content
this.TextSuggestionsBar = new TextSuggestionsBar(this.XamlTextBox);
this.Popup = new Popup();
this.Popup.Child = this.TextSuggestionsBar;
// PopupHelper does the dirty work of positioning & rotating the popup
PopupHelper.Initialize(this);
// When the timer ticks, refresh the view then stop it, so there’s
// only one refresh per timer.Start()
this.timer.Tick += delegate(object sender, EventArgs e)
{
RefreshView();
this.timer.Stop();
};
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
base.OnNavigatedFrom(e);
// Remember the text box’s text, caret position and selection
this.savedXaml.Value = this.XamlTextBox.Text;
this.savedSelectionStart.Value = this.XamlTextBox.SelectionStart;
this.savedSelectionLength.Value = this.XamlTextBox.SelectionLength;
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// Restore the text box’s text, caret position and selection
this.XamlTextBox.Text = this.savedXaml.Value;
this.XamlTextBox.SelectionStart = this.savedSelectionStart.Value;
this.XamlTextBox.SelectionLength = this.savedSelectionLength.Value;
}
void MainPage_Loaded(object sender, RoutedEventArgs e)
{
// Make on-screen keyboard instantly appear
this.XamlTextBox.Focus();
}
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
// Send mouse info to the text suggestions bar, if appropriate
if (PopupHelper.IsOnPopup(e))
this.TextSuggestionsBar.OnMouseDown(PopupHelper.GetPopupRelativePoint(e));
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
// Send mouse info to the text suggestions bar, if appropriate
if (PopupHelper.IsOnPopup(e))
this.TextSuggestionsBar.OnMouseMove(PopupHelper.GetPopupRelativePoint(e));
}
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonUp(e);
// Send mouse info to the text suggestions bar, in case its appropriate
this.TextSuggestionsBar.OnMouseUp(PopupHelper.IsOnPopup(e));
}
void XamlTextBox_GotFocus(object sender, RoutedEventArgs e)
{
// Show the popup whenever the text box has focus (and is visible)
if (this.EditorPanel.Visibility == Visibility.Visible)
this.Popup.IsOpen = true;
}
void XamlTextBox_LostFocus(object sender, RoutedEventArgs e)
{
// Hide the popup whenever the text box loses focus
this.Popup.IsOpen = false;
}
void XamlTextBox_SelectionChanged(object sender, RoutedEventArgs e)
{
// Update the suggestions based on the text behind the caret location
string text = this.XamlTextBox.Text;
int position = this.XamlTextBox.SelectionStart – 1;
// Initiate the suggestion-picking algorithm on a background thread
BackgroundWorker backgroundWorker = new BackgroundWorker();
backgroundWorker.DoWork += delegate(object s, DoWorkEventArgs args)
{
// This runs on a background thread
args.Result = UpdateTextSuggestions(text, position);
};
backgroundWorker.RunWorkerCompleted +=
delegate(object s, RunWorkerCompletedEventArgs args)
{
// This runs on the UI thread after BackgroundWorker_DoWork is done
// Grab the list created on the background thread
IList<Suggestion> suggestions = args.Result as IList<Suggestion>;
if (suggestions == null)
return;
// Clear the current list
this.TextSuggestionsBar.ClearSuggestions();
// Fill the bar with the new list
foreach (Suggestion suggestion in suggestions)
this.TextSuggestionsBar.AddSuggestion(suggestion);
};
backgroundWorker.RunWorkerAsync();
}
void XamlTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
// Remember the current caret position and selection
int start = this.XamlTextBox.SelectionStart;
int length = this.XamlTextBox.SelectionLength;
// Ensure the text always ends with several newlines so the user
// can easily scroll to see the very bottom of the text
if (!this.XamlTextBox.Text.EndsWith(Constants.NEWLINES))
this.XamlTextBox.Text = this.XamlTextBox.Text.TrimEnd()
+ Constants.NEWLINES;
// Restore the caret position and selection, which gets
// overwritten if the text is updated
this.XamlTextBox.SelectionStart = start;
this.XamlTextBox.SelectionLength = length;
// Cancel any pending refresh
if (this.timer.IsEnabled)
this.timer.Stop();
// Schedule a refresh of the view for one second from now
this.timer.Start();
}
void RefreshView()
{
try
{
// Wrap the user’s text in a page with appropriate namespace definitions
string xaml = @”<phone:PhoneApplicationPage
xmlns=””http://schemas.microsoft.com/winfx/2006/xaml/presentation””
xmlns:x=””http://schemas.microsoft.com/winfx/2006/xaml””
xmlns:phone=””clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone””
FontFamily=””{StaticResource PhoneFontFamilyNormal}””
FontSize=””{StaticResource PhoneFontSizeNormal}””
Foreground=””{StaticResource PhoneForegroundBrush}””>”
+ this.XamlTextBox.Text
+ “</phone:PhoneApplicationPage>”;
// Parse the XAML and get the root element (the page)
UIElement root = System.Windows.Markup.XamlReader.Load(xaml) as UIElement;
// Replace ViewPanel’s content with the new elements
this.ViewPanel.Children.Clear();
this.ViewPanel.Children.Add(root);
// An exception wasn’t thrown, so clear any error state
this.XamlTextBox.Foreground = new SolidColorBrush(Colors.Black);
this.ApplicationBar.Buttons.Remove(this.errorButton);
}
catch (Exception ex)
{
// The XAML was invalid, so transition to an error state
this.XamlTextBox.Foreground = new SolidColorBrush(Colors.Red);
if (!this.ApplicationBar.Buttons.Contains(this.errorButton))
this.ApplicationBar.Buttons.Add(this.errorButton);
// Use the exception message as the error message, but remove the line #
this.currentError = ex.Message;
if (this.currentError.Contains(“ [Line:”))
this.currentError = this.currentError.Substring(0,
this.currentError.IndexOf(“ [Line:”));
}
}
IList<Suggestion> UpdateTextSuggestions(string text, int position)
{
// The list of suggestions to report
List<Suggestion> suggestions = new List<Suggestion>();
if (position == -1)
{
// We’re at the beginning of the text box
suggestions.Add(new Suggestion { Text = “<”, InsertionOffset = 0 });
return suggestions;
}
char character = text[position];
if (Char.IsDigit(character))
{
// A number is likely a value to be followed by an end quote, or it could
// be a property like X1 or X2 to be followed by an equals sign
suggestions.Add(new Suggestion { Text = “””, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “=”, InsertionOffset = 0 });
}
else if (!Char.IsLetter(character))
{
// Choose various likely completions based on the special character
switch (character)
{
case ‘<’:
suggestions.Add(new Suggestion { Text = “/”, InsertionOffset = 0 });
break;
case ‘/’:
suggestions.Add(new Suggestion { Text = “>”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “””, InsertionOffset = 0 });
break;
case ‘ ‘:
case ‘r’:
case ‘n’:
suggestions.Add(new Suggestion { Text = “<”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “/”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “>”, InsertionOffset = 0 });
break;
case ‘>’:
suggestions.Add(new Suggestion { Text = “<”, InsertionOffset = 0 });
break;
case ‘=’:
case ‘}’:
suggestions.Add(new Suggestion { Text = “””, InsertionOffset = 0 });
break;
case ‘{‘:
suggestions.Add(
new Suggestion { Text = “Binding “, InsertionOffset = 0 });
suggestions.Add(
new Suggestion { Text = “StaticResource “, InsertionOffset = 0 });
break;
case ‘“‘:
suggestions.Add(new Suggestion { Text = “/”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “>”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “{“, InsertionOffset = 0 });
break;
}
}
else
{
// This is a letter
// First add a few special symbols
suggestions.Add(new Suggestion { Text = “/”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “>”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “=”, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “””, InsertionOffset = 0 });
suggestions.Add(new Suggestion { Text = “}”, InsertionOffset = 0 });
// Keep traversing backwards until we hit a non-letter
string letters = null;
while (position >= 0 && (letters == null ||
Char.IsLetter(text[position])))
letters = text[position–] + letters;
// Add words from our custom dictionary that match the current text as
// as prefix
for (int i = 0; i < Data.Words.Length; i++)
{
// Only include exact matches if the case is different
// (so the user can tap the suggestion to fix their casing)
if (Data.Words[i].StartsWith(letters,
StringComparison.InvariantCultureIgnoreCase) &&
!Data.Words[i].Equals(letters, StringComparison.InvariantCulture))
{
suggestions.Add(new Suggestion { Text = Data.Words[i],
InsertionOffset = -letters.Length });
}
}
}
return suggestions;
}
// Application bar handlers
void ViewButton_Click(object sender, EventArgs e)
{
// Switch between viewing the results and viewing the XAML text box
if (this.EditorPanel.Visibility == Visibility.Visible)
{
this.EditorPanel.Visibility = Visibility.Collapsed;
this.viewButton.IconUri = new Uri(“/Images/appbar.xaml.png”,
UriKind.Relative);
this.viewButton.Text = “xaml”;
}
else
{
this.EditorPanel.Visibility = Visibility.Visible;
this.viewButton.IconUri = new Uri(“/Shared/Images/appbar.view.png”,
UriKind.Relative);
this.viewButton.Text = “view”;
this.XamlTextBox.Focus();
}
}
void ClearButton_Click(object sender, EventArgs e)
{
// Clear the text box if the user agrees
if (MessageBox.Show(“Are you sure you want to clear this XAML?”,
“Clear XAML”, MessageBoxButton.OKCancel) == MessageBoxResult.OK)
this.XamlTextBox.Text = “”;
}
void EmailButton_Click(object sender, EventArgs e)
{
// Launch an email with the content of the text box
EmailComposeTask emailLauncher = new EmailComposeTask {
Body = this.XamlTextBox.Text, Subject = “XAML from the XAML Editor app” };
emailLauncher.Show();
}
void ErrorButton_Click(object sender, EventArgs e)
{
// Show whatever the current error is
MessageBox.Show(this.currentError, “XAML Error”, MessageBoxButton.OK);
}
void SampleMenuItem_Click(object sender, EventArgs e)
{
if (this.XamlTextBox.Text.Trim().Length != 0 &&
MessageBox.Show(“Are you sure you want to replace the XAML?”,
“Replace XAML”, MessageBoxButton.OKCancel) != MessageBoxResult.OK)
return;
// Fill the text box with the chosen sample
switch ((sender as IApplicationBarMenuItem).Text)
{
case “simple shapes”:
this.XamlTextBox.Text = Data.SimpleShapes;
break;
case “gradient text”:
this.XamlTextBox.Text = Data.GradientText;
break;
case “clipped image”:
this.XamlTextBox.Text = Data.ClippedImage;
break;
case “controls”:
this.XamlTextBox.Text = Data.Controls;
break;
}
}
}
}

[/code]

Notes:

  • The popup’s child is set to an instance of a TextSuggestionsBar user control, implemented in the next section, which handles the display and interaction of the bar.
  • A fair amount of code is needed to properly position the popup and report where it is being touched, so this is factored into a separate PopupHelper class examined next.
  • In MainPage_Loaded, the on-screen keyboard is automatically deployed (unless a hardware keyboard is active) because there’s no other UI to obscure.
  • Inside the three OnMouse… handlers, the data is being passed along to the text suggestions bar. This highlights the main challenge of implementing this bar—it must never get focus because the on-screen keyboard would go away if the text box loses focus! Therefore, the root of the TextSuggestionsBar user control is marked with IsHitTestVisible=”False”, and the control exposes its own trio of OnMouse… methods, so it can act like it’s being touched when it’s really the text box underneath that is receiving these events.
  • The performance of updating the text suggestions bar is important because it happens on every keystroke (or other movement of the caret). Inside XamlTextBox_SelectionChanged, a background worker is used to execute the time-consuming work—UpdateTextSuggestions. This only works because UpdateTextSuggestions doesn’t interact with any UI or do anything else that requires being run on the UI thread.With a background worker, you can attach a delegate to its DoWork event, which gets raised on a background thread once RunWorkerAsync is called (done at the end of XamlTextBox_ SelectionChanged). When the background work has completed, the RunWorkerCompleted event is raised on the original (UI) thread. This enables user interface updates to occur based on whatever work was done in the background. (Alternatively, the backgroundthread code could call BeginInvoke on the page’s dispatcher to schedule work on the UI thread.) The DoWork handler can pass data to the RunWorkerCompleted handler via a Result property on the event-args parameter.
  • XamlTextBox_TextChanged uses another technique to improve this app’s performance. Rather than instantly re-render the XAML every time it changes, it uses a timer to wait one second. That way, it can cancel a pending update if another change occurs within that second. This technique, as well as the use of a background worker for filling the text suggestions bar, vastly improves the performance when the user holds down a repeatable key (the space bar, backspace, or Enter).
  • RefreshView contains the single line of code needed to turn XAML into a tree of live objects. The static XamlReader.Load method accepts a XAML string as input and returns an object corresponding to the root element in the string. If there’s anything wrong with the XAML, it throws a XamlParseException. RefreshView captures any exception and shows the message to the user if they tap the error button that appears on the application bar. This code strips out any line and position information from the message because (a) the surrounding page element throws off the line number and (b) it’s often not accurate anyway.The XAML string must be selfcontained, so its elements cannot have event handlers assigned, nor can it have unresolved XML namespace prefixes. RefreshView wraps the user’s XAML in a page element with the main namespaces so the user’s XAML doesn’t need to be cluttered with this. (This could have been a grid, and the result would look the same.) Therefore, this code ends up attaching an instance of a page as a child of the ViewPanel grid. It’s weird for a page to contain another page, but it works just fine.
  • UpdateTextSuggestions contains the simple algorithm for providing suggestions based on the text preceding the current caret location. It treats numbers, letters, and symbols differently. Perhaps the most clever thing it does is suggest “Binding “ and “StaticResource “ (with the trailing space included) immediately after a {. It makes use of a simple structure defined in Suggestion.cs as follows:[code]
    namespace WindowsPhoneApp
    {
    public struct Suggestion
    {
    public string Text { get; set; }
    public int InsertionOffset { get; set; }
    }
    }
    [/code]

    The insertion offset captures how much of the suggestion has already been typed before the caret.

  • The custom dictionary of XAML-relevant words (over 300) is a static string array called Words in a static Data class. It contains common element names, property names, and some common property values. The XAML samples accessed via the application bar menu are stored as static fields on the same class. The Data class is not shown in this chapter, but as with all the apps, you can download the complete source code.

PopupHelper

Listing 11.3 contains the implementation of the PopupHelper class used by Listing 11.2. It is directly tied to the main page rather than being any sort of reusable control.

LISTING 11.3 PopupHelper.cs—A Class That Manipulates the Popup Containing the Text Suggestions Bar

[code]

using System;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using Microsoft.Phone.Controls;
namespace WindowsPhoneApp
{
internal static class PopupHelper
{
static MainPage page;
static bool textSuggestionsBarSlidDown;
internal static void Initialize(MainPage p)
{
page = p;
page.OrientationChanged += Page_OrientationChanged;
page.TextSuggestionsBar.DownButtonTap += TextSuggestionsBar_DownButtonTap;
AdjustForCurrentOrientation();
}
// Report whether the mouse event occurred within the popup’s bounds
internal static bool IsOnPopup(MouseEventArgs e)
{
if (!page.Popup.IsOpen)
return false;
Point popupRelativePoint = GetPopupRelativePoint(e);
return (popupRelativePoint.Y >= 0 &&
popupRelativePoint.Y < page.TextSuggestionsBar.ActualHeight);
}
// Return the X,Y position of the mouse, relative to the popup
internal static Point GetPopupRelativePoint(MouseEventArgs e)
{
Point popupRelativePoint = new Point();
// We can use the page-relative X as the popup-relative X
Point pageRelativePoint = e.GetPosition(page);
popupRelativePoint.X = pageRelativePoint.X;
// We can’t use the page-relative Y because the page can be automatically
// “pushed” by the on-screen keyboard, whereas the floating popup remains
// still. Therefore, first get the frame-relative Y:
Point frameRelativePoint = e.GetPosition(null /* the frame */);
popupRelativePoint.Y = frameRelativePoint.Y;
// A frame-relative point is always portrait-oriented, so invert
// the value if we’re currently in a landscape orientation
if (IsMatchingOrientation(PageOrientation.Landscape))
popupRelativePoint.Y = frameRelativePoint.X;
// Now adjust the Y to be relative to the top of the popup
// rather than the top of the screen
if (IsMatchingOrientation(PageOrientation.LandscapeLeft))
popupRelativePoint.Y = -(popupRelativePoint.Y+page.Popup.VerticalOffset);
else
popupRelativePoint.Y -= page.Popup.VerticalOffset;
return popupRelativePoint;
}
static void Page_OrientationChanged(object sender,
OrientationChangedEventArgs e)
{
// Clear the slid-down setting on any orientation change
textSuggestionsBarSlidDown = false;
AdjustForCurrentOrientation();
}
static void TextSuggestionsBar_DownButtonTap(object sender, EventArgs e)
{
textSuggestionsBarSlidDown = true;
AdjustForCurrentOrientation();
}
static bool IsMatchingOrientation(PageOrientation orientation)
{
return ((page.Orientation & orientation) == orientation);
}
static void AdjustForCurrentOrientation()
{
page.TextSuggestionsBar.ResetScrollPosition();
if (IsMatchingOrientation(PageOrientation.Portrait))
{
// Adjust the position, size, and rotation for portrait
page.TextSuggestionsBar.MinWidth = Constants.SCREEN_WIDTH;
page.Popup.HorizontalOffset = 0;
page.Popup.VerticalOffset = Constants.SCREEN_HEIGHT –
Constants.APPLICATION_BAR_THICKNESS – Constants.PORTRAIT_KEYBOARD_HEIGHT
– Constants.TEXT_SUGGESTIONS_HEIGHT*2; // 1 for the real bar, 1 for this
page.Popup.RenderTransform = new RotateTransform { Angle = 0 };
if (textSuggestionsBarSlidDown)
page.Popup.VerticalOffset += Constants.PORTRAIT_KEYBOARD_HEIGHT;
}
else
{
// Adjust the position, size, and rotation for landscape
page.TextSuggestionsBar.MinWidth = Constants.SCREEN_HEIGHT –
Constants.APPLICATION_BAR_THICKNESS;
if (IsMatchingOrientation(PageOrientation.LandscapeLeft))
{
page.Popup.RenderTransform = new RotateTransform { Angle = 90 };
page.Popup.HorizontalOffset = 0;
page.Popup.VerticalOffset = -(Constants.LANDSCAPE_KEYBOARD_HEIGHT +
Constants.TEXT_SUGGESTIONS_HEIGHT*2);
// 1 for the real bar, 1 for this
}
else // LandscapeRight
{
page.Popup.RenderTransform = new RotateTransform { Angle = 270 };
page.Popup.Width = Constants.SCREEN_HEIGHT –
Constants.APPLICATION_BAR_THICKNESS;
page.Popup.HorizontalOffset = -page.Popup.Width;
page.Popup.VerticalOffset = Constants.SCREEN_WIDTH –
Constants.LANDSCAPE_KEYBOARD_HEIGHT –
Constants.TEXT_SUGGESTIONS_HEIGHT*2;
// 1 for the real bar, 1 for this
}
if (textSuggestionsBarSlidDown)
page.Popup.VerticalOffset += Constants.LANDSCAPE_KEYBOARD_HEIGHT;
}
}
}
}

[/code]

  • Due to the manual rotation being done to the popup to make it always match the page’s orientation, GetPopupRelativePoint must adjust the page-relative mouse position in a number of ways, depending on the current orientation.
  • This app uses a number of constants. They are defined in Constants.cs as follows:[code]
    public static class Constants
    {
    public const int SCREEN_WIDTH = 480;
    public const int SCREEN_HEIGHT = 800;
    public const int APPLICATION_BAR_THICKNESS = 72;
    // Part of it is 259px tall, but this is the # we need:
    public const int LANDSCAPE_KEYBOARD_HEIGHT = 256;
    public const int PORTRAIT_KEYBOARD_HEIGHT = 339;
    public const int TEXT_SUGGESTIONS_HEIGHT = 62;
    public const int MARGIN = 12;
    public const int TAP_MARGIN = 14;
    public const int MIN_SCROLL_AMOUNT = 10;
    public static readonly string NEWLINES = Environment.NewLine +
    Environment.NewLine + Environment.NewLine + Environment.NewLine +
    Environment.NewLine;
    }
    [/code]
  • This code handles an event on the TextSuggestionsBar called DownButtonTap and moves the position of the popup to the bottom of the screen when this happens. The next section explains what this is about.

The TextSuggestionsBar User Control

The TextSuggestionsBar user control handles the display of the dot-delimited text suggestions and the proper tapping and scrolling interaction. It also contains a workaround for a problem with hardware keyboards.

Ideally, the popup containing this control would automatically position itself above the on-screen keyboard when it is used, but close to the bottom edge of the screen when a hardware keyboard is used instead. Unfortunately, there is no good way to detect when a hardware keyboard is in use, so this app relies on the user to move it. The TextSuggestionsBar has an extra “down” button that is hidden under the on-screen keyboard when it is in use, but revealed when a hardware keyboard is used. The user can tap this button to move the bar to the bottom, just above the real text suggestions bar. Figure 11.4 shows what this looks like. Rather than consuming space with a corresponding “up” button, this app only moves the bar back to its higher position when the phone’s orientation changes.

FIGURE 11.4 The user must manually move the custom text suggestions bar to the appropriate spot when using a hardware keyboard.
FIGURE 11.4 The user must manually move the custom text suggestions bar to the appropriate spot when using a hardware keyboard.

Listing 11.4 contains the XAML for this user control, and Listing 11.5 contains the codebehind.

LISTING 11.4 TextSuggestionsBar.xaml—The User Interface for the TextSuggestionsBar User Control

[code]

<UserControl x:Class=”WindowsPhoneApp.TextSuggestionsBar”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
IsHitTestVisible=”False”>
<StackPanel>
<Canvas Background=”{StaticResource PhoneChromeBrush}” Height=”62”>
<!– The suggestions go in this stack panel –>
<StackPanel x:Name=”StackPanel” Orientation=”Horizontal” Height=”62”/>
</Canvas>
<!– The double-arrow “button” (just a border with a path) –>
<Border Background=”{StaticResource PhoneChromeBrush}” Width=”62”
Height=”62” HorizontalAlignment=”Left”>
<Path Fill=”{StaticResource PhoneForegroundBrush}”
HorizontalAlignment=”Center” VerticalAlignment=”Center”
Data=”M0,2 14,2 7,11z M0,13 14,13 7,22”/>
</Border>
</StackPanel>
</UserControl>

[/code]

LISTING 11.5 TextSuggestionsBar.xaml.cs—The Code-Behind for the TextSuggestionsBar User Control

[code]

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace WindowsPhoneApp
{
public partial class TextSuggestionsBar : UserControl
{
// A custom event, raised when the down button is tapped
public event EventHandler DownButtonTap;
TextBox textBox;
double mouseDownX;
double mouseMoveX;
Border pressedSuggestionElement;
int selectionStart;
int selectionLength;
public TextSuggestionsBar(TextBox textBox)
{
InitializeComponent();
this.textBox = textBox;
}
public void OnMouseDown(Point point)
{
// Grab the current position/selection before it changes! The text box
// still has focus, so the tap is likely to change the caret position
this.selectionStart = this.textBox.SelectionStart;
this.selectionLength = this.textBox.SelectionLength;
this.mouseDownX = this.mouseMoveX = point.X;
this.pressedSuggestionElement = FindSuggestionElementAtPoint(point);
if (this.pressedSuggestionElement != null)
{
// Give the pressed suggestion the hover brushes
this.pressedSuggestionElement.Background =
Application.Current.Resources[“PhoneForegroundBrush”] as Brush;
(this.pressedSuggestionElement.Child as TextBlock).Foreground =
Application.Current.Resources[“PhoneBackgroundBrush”] as Brush;
}
else if (point.Y > this.StackPanel.Height)
{
// Treat this as a tap on the down arrow
if (this.DownButtonTap != null)
this.DownButtonTap(this, EventArgs.Empty);
}
}
public void OnMouseMove(Point point)
{
double delta = point.X – this.mouseMoveX;
if (delta == 0)
return;
// Adjust the stack panel’s left margin to simulate scrolling.
// Don’t let it scroll past either its left or right edge.
double newLeft = Math.Min(0, Math.Max(this.ActualWidth –
this.StackPanel.ActualWidth, this.StackPanel.Margin.Left + delta));
this.StackPanel.Margin = new Thickness(newLeft, 0, 0, 0);
// If a suggestion is currently being pressed but we’ve now scrolled a
// certain amount, cancel the tapping action
if (pressedSuggestionElement != null && Math.Abs(this.mouseMoveX
– this.mouseDownX) > Constants.MIN_SCROLL_AMOUNT)
{
// Undo the hover brushes
pressedSuggestionElement.Background = null;
(pressedSuggestionElement.Child as TextBlock).Foreground =
Application.Current.Resources[“PhoneForegroundBrush”] as Brush;
// Stop tracking the element
pressedSuggestionElement = null;
}
this.mouseMoveX = point.X;
}
public void OnMouseUp(bool isInBounds)
{
if (this.pressedSuggestionElement != null)
{
if (isInBounds)
InsertText();
// Undo the hover brushes
pressedSuggestionElement.Background = null;
(pressedSuggestionElement.Child as TextBlock).Foreground =
Application.Current.Resources[“PhoneForegroundBrush”] as Brush;
// Stop tracking the element
pressedSuggestionElement = null;
}
}
public void ResetScrollPosition()
{
this.StackPanel.Margin = new Thickness(0, 0, 0, 0);
}
public void ClearSuggestions()
{
this.StackPanel.Children.Clear();
ResetScrollPosition();
}
// Each suggestion is added to the stack panel as two elements:
// – A border containing a textblock with a • separator
// – A border containing the suggested text
public void AddSuggestion(Suggestion suggestion)
{
// Add the • element to the stack panel
TextBlock textBlock = new TextBlock { Text = “•”, FontSize = 16,
Margin = new Thickness(this.StackPanel.Children.Count == 0 ? 20 : 3, 6, 4,
0), Foreground = Application.Current.Resources[“PhoneForegroundBrush”]
as Brush, VerticalAlignment = VerticalAlignment.Center };
Border border = new Border();
border.Child = textBlock;
this.StackPanel.Children.Add(border);
// Add the suggested-text element to the stack panel
textBlock = new TextBlock { Text = suggestion.Text, FontSize = 28,
Margin = new Thickness(10, 6, 10, 0),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Foreground = Application.Current.Resources[“PhoneForegroundBrush”]
as Brush };
// MinWidth makes single-character suggestions like / easier to tap
// Stuff the insertion offset into the tag for easy retrieval later
border = new Border { MinWidth = 28, Tag = suggestion.InsertionOffset };
border.Child = textBlock;
this.StackPanel.Children.Add(border);
}
void InsertText()
{
string newText = (this.pressedSuggestionElement.Child as TextBlock).Text;
int numCharsToDelete = ((int)this.pressedSuggestionElement.Tag) * -1;
string allText = this.textBox.Text;
// Perform the insertion
allText = allText.Substring(0, this.selectionStart – numCharsToDelete)
+ newText
+ allText.Substring(this.selectionStart + this.selectionLength);
this.textBox.Text = allText;
// Place the caret immediately after the inserted text
this.textBox.SelectionStart = this.selectionStart + newText.Length –
numCharsToDelete;
}
// Find the Border element at the current point
Border FindSuggestionElementAtPoint(Point point)
{
Border border = null;
// Loop through the borders to find the right one (if there is one)
for (int i = 0; i < this.StackPanel.Children.Count; i++)
{
Border b = this.StackPanel.Children[i] as Border;
// Transform the point to be relative to this border
GeneralTransform generalTransform = this.StackPanel.TransformToVisual(b);
Point pt = generalTransform.Transform(point);
pt.X -= this.StackPanel.Margin.Left; // Adjust for scrolling
// See if the point is within the border’s bounds.
// The extra right margin ensures that there are no “holes” in the bar
// where tapping does nothing.
if (pt.X >= 0 && pt.X < b.ActualWidth + Constants.TAP_MARGIN
&& pt.Y <= this.StackPanel.Height)
{
border = b;
// If this is the • element, treat it as part of the next element
// (the actual word), so return that one instead
if ((b.Child as TextBlock).Text == “•”)
border = this.StackPanel.Children[i + 1] as Border;
break;
}
}
return border;
}
}
}

[/code]

Notes:

  • OnMouseDown takes care of highlighting the tapped suggestion, OnMouseMove performs the scrolling of the bar, and OnMouseUp inserts the highlighted suggestion (if there is one).

The Finished Product

XAML Editor (Dynamic XAML & Popup)

Can I write code that interacts with the phone’s copy & paste feature?

No. Copy & paste functionality is automatically supported for any text box, but there is currently no way for a developer to interact with the clipboard, disable the feature, or otherwise influence its behavior.