No book covering the use of an accelerometer would be complete without showing you how to create a level! This chapter’s Level app not only features four classic tubular bubble levels (one on each edge), but it also shows the current angle of the phone with little accent lines that line up with companion lines when one of the edges of the phone is parallel to the ground. This makes it even easier to visually align the phone exactly as you wish.
Getting smooth, stable results from the accelerometer is important for an app such as this that relies on slow, small movements. Therefore, as in the preceding chapter, Level makes use of Microsoft’s AccelerometerHelper class to perform data smoothing. The key to this app is to use a little bit of trigonometry in order to determine the angle of the phone based on the accelerometer data.
The BubbleWindow User Control
The classic bubble display, shown in Figure 48.1, is implemented as a user control. That’s because Level’s main page uses four instances of this display. Listing 48.1 contains its XAML and Listing 48.2 contains its code-behind.
LISTING 48.1 BubbleWindow.xaml—The User Interface for the BubbleWindow User Control
[code]
<UserControl x:Class=”WindowsPhoneApp.BubbleWindow”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”>
<!– Add one storyboard to the control’s resource dictionary –>
<UserControl.Resources>
<Storyboard x:Name=”BubbleStoryboard” Storyboard.TargetName=”BubbleTransform”>
<!– Stretch the bubble horizontally –>
<DoubleAnimation Storyboard.TargetProperty=”ScaleX” By=”.5”
Duration=”0:0:.8” AutoReverse=”True”>
<DoubleAnimation.EasingFunction>
<QuadraticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<!– Shrink the bubble horizontally –>
<DoubleAnimation Storyboard.TargetProperty=”ScaleY” By=”-.2”
Duration=”0:0:.8” AutoReverse=”True”>
<DoubleAnimation.EasingFunction>
<QuadraticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</UserControl.Resources>
<Canvas Background=”#333”>
<Ellipse x:Name=”Bubble” Width=”115” Height=”115” Visibility=”Collapsed”
Fill=”{StaticResource PhoneAccentBrush}” RenderTransformOrigin=”.5,.5”>
<Ellipse.RenderTransform>
<!– Used in the animations –>
<CompositeTransform x:Name=”BubbleTransform”/>
</Ellipse.RenderTransform>
</Ellipse>
<Line x:Name=”Line1” Stroke=”White”/>
<Line x:Name=”Line2” Stroke=”White”/>
<Rectangle x:Name=”Rectangle” Stroke=”#A555” StrokeThickness=”6”/>
</Canvas>
</UserControl>
[/code]
LISTING 48.2 BubbleWindow.xaml.cs—The Code-Behind for the BubbleWindow User Control
[code]
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace WindowsPhoneApp
{
public partial class BubbleWindow : UserControl
{
public BubbleWindow()
{
InitializeComponent();
this.Loaded += BubbleWindow_Loaded;
}
void BubbleWindow_Loaded(object sender, RoutedEventArgs e)
{
// Adjust the two centered lines and the rectangular border to fit
// the size given to this instance of the control
this.Line1.X1 = this.Line1.X2 =
(this.ActualWidth / 2) – (this.Bubble.ActualWidth / 2);
this.Line1.Y2 = this.ActualHeight;
this.Line2.X1 = this.Line2.X2 =
(this.ActualWidth / 2) + (this.Bubble.ActualWidth / 2);
this.Line2.Y2 = this.ActualHeight;
this.Rectangle.Width = this.ActualWidth;
this.Rectangle.Height = this.ActualHeight;
// Don’t allow the bubble to render past this control’s border
this.Clip = new RectangleGeometry {
Rect = new Rect(0, 0, this.ActualWidth, this.ActualHeight) };
this.Bubble.Visibility = Visibility.Visible;
}
// Set the horizontal position of the bubble on a scale from 0 to 100
public void SetXPercentage(double percentage)
{
percentage = percentage / 100;
double left = (-this.Bubble.ActualWidth/2) + this.ActualWidth*percentage;
Canvas.SetLeft(this.Bubble, left);
}
// Set the vertical position of the bubble on a scale from 0 to 100
public void SetYPercentage(double percentage)
{
percentage = percentage / 100;
// Allow the bubble to go more off-screen with a taller possible range
double range = this.ActualHeight + 20 * 2;
double top = -20 + range * percentage – range / 2;
Canvas.SetTop(this.Bubble, top);
}
public void Animate()
{
// Stretch out the bubble if the animation isn’t already running
if (this.BubbleStoryboard.GetCurrentState() != ClockState.Active)
{
this.BubbleStoryboard.Stop();
this.BubbleStoryboard.Begin();
}
}
}
}
[/code]
This user control has nothing to do with the accelerometer. It simply enables its consumer to do three things:
- Set the horizontal position of the bubble from 0% (all the way to the left) to 100% (all the way to the right).
- Set the vertical position of the bubble from 0% (all the way to the top) to 100% (all the way to the bottom).
- Trigger an animation that stretches the bubble, appropriate to use when the bubble is moving quickly to give it more realism. This stretching is shown in Figure 48.2.
Note that the manual layout code in Listing 48.2 that adjusts the lines and rectangle could be eliminated by leveraging a grid’s automatic layout rather than using a canvas.
The Main Page
Besides the same calibration page used by Moo Can and shown in the preceding chapter, Level only has the single main page. Listing 48.3 contains the XAML for the main page, and Listing 48.4 contains its code-behind.
LISTING 48.3 MainPage.xaml—The User Interface for Level’s Main 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”
xmlns:shell=”clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone”
xmlns:local=”clr-namespace:WindowsPhoneApp”
SupportedOrientations=”Portrait”>
<Grid>
<Grid.Background>
<!– The metallic background –>
<ImageBrush ImageSource=”Images/background.png”/>
</Grid.Background>
<!– The four lines in the middle that don’t move –>
<Line Y1=”240” Y2=”290” X1=”240” X2=”240” Stroke=”#555” StrokeThickness=”6”/>
<Line Y1=”510” Y2=”560” X1=”240” X2=”240” Stroke=”#555” StrokeThickness=”6”/>
<Line Y1=”400” Y2=”400” X1=”80” X2=”130” Stroke=”#555” StrokeThickness=”6”/>
<Line Y1=”400” Y2=”400” X1=”350” X2=”400” Stroke=”#555” StrokeThickness=”6”/>
<!– The four lines that tilt based on the phone’s angle –>
<Canvas RenderTransformOrigin=”.5,.5” Width=”480” Height=”800”>
<Canvas.RenderTransform>
<RotateTransform x:Name=”CenterTransform” Angle=”45”/>
</Canvas.RenderTransform>
<Line Y1=”240” Y2=”290” X1=”240” X2=”240”
Stroke=”{StaticResource PhoneAccentBrush}” StrokeThickness=”6”/>
<Line Y1=”510” Y2=”560” X1=”240” X2=”240”
Stroke=”{StaticResource PhoneAccentBrush}” StrokeThickness=”6”/>
<Line Y1=”400” Y2=”400” X1=”80” X2=”130”
Stroke=”{StaticResource PhoneAccentBrush}” StrokeThickness=”6”/>
<Line Y1=”400” Y2=”400” X1=”350” X2=”400”
Stroke=”{StaticResource PhoneAccentBrush}” StrokeThickness=”6”/>
</Canvas>
<!– The display for the exact angle –>
<TextBlock x:Name=”AngleTextBlock” Foreground=”#555”
HorizontalAlignment=”Center” VerticalAlignment=”Center”
FontSize=”{StaticResource PhoneFontSizeExtraExtraLarge}”
RenderTransformOrigin=”.5,.5”>
<TextBlock.RenderTransform>
<RotateTransform x:Name=”AngleTextBlockTransform”/>
</TextBlock.RenderTransform>
</TextBlock>
<!– The circle in the center –>
<Ellipse Width=”220” Height=”220” Stroke=”#555” StrokeThickness=”6”/>
<!– The four bubble level displays –>
<local:BubbleWindow x:Name=”TopWindow” Width=”275” Height=”75”
VerticalAlignment=”Top”/>
<local:BubbleWindow x:Name=”BottomWindow” Width=”275” Height=”75”
VerticalAlignment=”Bottom”/>
<local:BubbleWindow x:Name=”LeftWindow” Width=”622” Height=”75”
HorizontalAlignment=”Left” Margin=”-274,0,0,0” RenderTransformOrigin=”.5,.5”>
<local:BubbleWindow.RenderTransform>
<CompositeTransform Rotation=”-90”/>
</local:BubbleWindow.RenderTransform>
</local:BubbleWindow>
<local:BubbleWindow x:Name=”RightWindow” Width=”622” Height=”75”
HorizontalAlignment=”Right” Margin=”0,0,-274,0” RenderTransformOrigin=”.5,.5”>
<local:BubbleWindow.RenderTransform>
<CompositeTransform Rotation=”-90”/>
</local:BubbleWindow.RenderTransform>
</local:BubbleWindow>
<!– The calibrate pseudo-button that tilts based on the phone’s angle –>
<Border Margin=”0,0,0,120” HorizontalAlignment=”Center”
VerticalAlignment=”Bottom” local:Tilt.IsEnabled=”True”
MouseLeftButtonUp=”Calibrate_MouseLeftButtonUp”>
<TextBlock Text=”calibrate” Padding=”36” Foreground=”#555”
FontSize=”{StaticResource PhoneFontSizeExtraLarge}”
RenderTransformOrigin=”.5,.5”>
<TextBlock.RenderTransform>
<RotateTransform x:Name=”CalibrateTextBlockTransform”/>
</TextBlock.RenderTransform>
</TextBlock>
</Border>
</Grid>
</phone:PhoneApplicationPage>
[/code]
LISTING 48.4 MainPage.xaml.cs—The Code-Behind for Level’s Main Page
[code]
using System;
using System.Windows.Input;
using System.Windows.Navigation;
using Microsoft.Phone.Applications.Common; // For AccelerometerHelper
using Microsoft.Phone.Controls;
using Microsoft.Phone.Shell;
namespace WindowsPhoneApp
{
public partial class MainPage : PhoneApplicationPage
{
// Used to track every 5th call to Accelerometer_ReadingChanged
double readingCount = 0;
// The angle 5 calls ago
double previousAngle = 0;
public const double RADIANS_TO_DEGREES = 180 / Math.PI;
public MainPage()
{
InitializeComponent();
// Use the accelerometer via Microsoft’s helper
AccelerometerHelper.Instance.ReadingChanged += Accelerometer_ReadingChanged;
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// Start the accelerometer with Microsoft’s helper
AccelerometerHelper.Instance.Active = true;
// While on this page, don’t allow the screen to auto-lock
PhoneApplicationService.Current.UserIdleDetectionMode =
IdleDetectionMode.Disabled;
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// Restore the ability for the screen to auto-lock when on other pages
PhoneApplicationService.Current.UserIdleDetectionMode =
IdleDetectionMode.Enabled;
}
// Process data coming from the accelerometer
void Accelerometer_ReadingChanged(object sender,
AccelerometerHelperReadingEventArgs e)
{
// This is the key formula
double rawAngle =
Math.Atan2(e.AverageAcceleration.X, e.AverageAcceleration.Y)
* RADIANS_TO_DEGREES;
// Express the angle from 0-90°, used by some calculations
double angle0to90 = Math.Abs(rawAngle) % 90;
// Calculate the horizontal % of the left & right bubbles by
// using the angle as an offset from the midpoint (50%)
double landscapeXPercentage = Clamp(Math.Abs(rawAngle) > 90
? 50 + angle0to90
: 50 – (90 – angle0to90), 0, 100);
// The horizontal % of the top & bottom bubbles requires more cases
double portraitXPercentage;
// When the bottom bubble window is on top
if (Math.Abs(rawAngle) <= 25)
portraitXPercentage = Clamp(rawAngle > 0
? 50 – angle0to90 * 2
: 50 + angle0to90 * 2, 0, 100);
// When the top bubble window is on top
else if (Math.Abs(rawAngle) >= 155)
portraitXPercentage = Clamp(rawAngle > 0
? 50 – (90 – angle0to90) * 2
: 50 + (90 – angle0to90) * 2, 0, 100);
// When the right bubble window is on top
else if (rawAngle < 0)
portraitXPercentage = 100;
// When the left bubble window is on top
else
portraitXPercentage = 0;
// The Y% for the left/right bubbles is the same
// as the X% for the top/bottom bubbles
double landscapeYPercentage = portraitXPercentage;
// The Y% for the top/bottom bubbles is the same
// as the inverse of the X% for the left/right bubbles
double portraitYPercentage = 100 – landscapeXPercentage;
this.Dispatcher.BeginInvoke(delegate()
{
// Set the primary (horizontal) position of each bubble
this.TopWindow.SetXPercentage(portraitXPercentage);
this.BottomWindow.SetXPercentage(portraitXPercentage);
this.LeftWindow.SetXPercentage(landscapeXPercentage);
this.RightWindow.SetXPercentage(landscapeXPercentage);
// Set the vertical position of each bubble
this.TopWindow.SetYPercentage(portraitYPercentage);
this.BottomWindow.SetYPercentage(portraitYPercentage);
this.LeftWindow.SetYPercentage(landscapeYPercentage);
this.RightWindow.SetYPercentage(landscapeYPercentage);
// On every 5th call, check to see if significant motion has occurred.
// If the angle has changed by more than 8 degrees, trigger the
// animation on any bubbles that are vertically close to the top
// or bottom of their window (< 10% or > 90%).
readingCount = (readingCount + 1) % 5;
if (readingCount == 0)
{
if (Math.Abs(previousAngle – rawAngle) > 8)
{
if (portraitYPercentage < 10 || portraitYPercentage > 90)
{
this.TopWindow.Animate();
this.BottomWindow.Animate();
}
if (landscapeYPercentage < 10 || landscapeYPercentage > 90)
{
this.LeftWindow.Animate();
this.RightWindow.Animate();
}
}
this.previousAngle = rawAngle;
}
// Update the exact angle display, using values from 0 to 45°
double angle0to45 = angle0to90 <= 45 ? angle0to90 : 90 – angle0to90;
this.AngleTextBlock.Text = angle0to45.ToString(“##0.0°”);
// Tilt the non-bubble pieces of UI accordingly
this.AngleTextBlockTransform.Angle = rawAngle – 180;
this.CalibrateTextBlockTransform.Angle = rawAngle – 180;
this.CenterTransform.Angle = rawAngle – 180;
});
}
void Calibrate_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
this.NavigationService.Navigate(
new Uri(“/Shared/Calibrate/CalibratePage.xaml?” +
“appName=Level&calibrateX=true&calibrateY=true”, UriKind.Relative));
}
// “Clamp” the incoming value so it’s no lower than min & no larger than max
static double Clamp(double value, double min, double max)
{
return Math.Max(min, Math.Min(max, value));
}
}
}
[/code]
- The most important calculation in this app is done as the first line of Accelerometer_ReadingChanged. The X and Y acceleration vectors are perpendicular to each other. Therefore, you can imagine the X and Y values from the accelerometer as two sides of a right triangle, as pictured in Figure 48.3. Trigonometry tells us that the tangent of an angle is the length of the opposite side divided by the length of the adjacent side (the “TOA” in the classic “SOHCAH- TOA” mnemonic). Therefore, the angle equals the arctangent of the opposite length divided by the adjacent length. Math.Atan2 performs this calculation for us, and even handles the potential divide-by-zero case so we don’t have to.
The trigonometric functions in System.Math return angles specified in radians!
This is why Listing 48.4 multiplies the result of Math.Atan2 by the RADIANS_TO_DEGREES constant.This converts the value expressed in radians to a value expressed in degrees by multiplying the value by 180 / π.
- For the call to Math.Atan2, the X axis is chosen as the opposite side of the right triangle and the Y axis is chosen as the adjacent side. This choice is arbitrary, but it does impact the resultant angle. Using X as the opposite side produces the values for rawAngle illustrated in Figure 48.4.
- The bulk of the code in Accelerometer_ReadingChanged determines the appropriate horizontal percentage for the four BubbleWindow user controls based on the current angle. (The two “portrait” bubbles—top and bottom— always have the same values as each other, as do the two “landscape” bubbles.)
- Because of the symmetry between the raw angle reported when the left side is facing the sky and when the right side is facing the sky, calculating the horizontal percentage for the landscape bubbles is relatively straightforward. Each degree away from 90° (or –90°) adds or subtracts one percentage point from the 50% midpoint.
- Because of the asymmetry of the top/bottom raw angles, the horizontal percentage calculation for the portrait bubbles is more complex.
- The angle displayed in the middle of the screen is 0.0° when any of the four sides is exactly level with the ground. Therefore, the displayed angle never gets higher than 45°. At such a point, the value starts decreasing as the next side gets closer to becoming level.
- Rather than checking on every ReadingChanged event, this listing checks for large motion that should trigger the bubble animation every fifth event. This is a tradeoff between performance and latency.
To get even more stability in its readings, the Level app tweaks the code for AccelerometerHelper to make AverageAcceleration examine the previous 50 samples rather than 25.This was done by simply changing the value of a SamplesCount static variable from 25 to 50. This has the side effect of doubling the latency in the reported data, but this is acceptable for a level app.The latency simply makes the liquid in each bubble window act a little “thicker.”
The Finished Product