Migrating a Silverlight Application to Windows Phone 7

I’m amazed at how quickly Windows Phone 7 applications can be developed. It’s really nice to leverage existing skills and apply them directly to phone development. In my previous post I provided a high-level look at what Windows Phone 7 has to offer and showed a few code samples to help get started. In this post I’m going to walk through the process of migrating an existing Silverlight application from Silverlight to Windows Phone 7. It’s an app that I built back in the WPF/e days (the very first app I tried out on the alpha framework actually) and something that I thought would be fun to convert to Windows Phone 7.

The app allows users to search albums from Amazon.com and view details about individual albums. Data is retrieved using a REST API exposed by Amazon.com and displayed using a simulated 3D carousel. The initial incarnation of the application discussed here will have one screen and only work in portrait mode. I plan to write-up some posts in the future and detail different enhancements made to the application that take advantage of additional Windows Phone 7 features. The overall goal of this post is to convert the existing application to run on Windows Phone 7.  I’ll worry about enhancements, architecture and performance optimizations later.

Here’s what the existing Silverlight application looks like:

Listing2

 

The Windows Phone 7 version of the application provides similar functionality. A user can enter an artist that they'd like to search for and the application will go out and retrieve album details including images, pricing, reviews and more from Amazon.com. As the user selects an album or moves the albums around in the carousel, details will be shown at the bottom of the interface. Here’s an example of the phone application's interface:

Listing3

 

Creating the Windows Phone 7 Project

After installing the Windows Phone 7 tools for Visual Studio 2010 (available from http://create.msdn.com/en-US)  you'll see several different phone project types appear when creating a new project. For the album viewer application I selected the standard Windows Phone Application template (as shown in the image that follows) and named the project WP7AlbumViewer.

clip_image006


This project template provides you with a single screen initially but additional screens can be added as needed. Navigation between screens is accomplished by using the NavigationService object. In a future enhancement to the app I'll add additional screens into the application, but for those who just can't wait here's an example of using the NavigationService object to navigate between screens:

NavigationService.Navigate(new Uri("MyScreen.xaml", UriKind.Relative));


Before moving on, let's briefly discuss the other project templates available. The Panorama and Pivot application templates help get you started using those respective controls (see my previous post for details on the differences between the controls) and automatically add collection controls and sample data so that you can visually see the application in action at design time. The databound application template provides a nice starter project for working with data-centric applications and includes a data list screen along with a details screen.

Once a Windows Phone Application project is created you'll see a MainPage.xaml file in the project that serves as the initial screen. Looking at the document's root element you'll notice that it's PhoneApplicationPage instead of UserControl or Page as in standard Silverlight applications. PhoneApplicationPage provides functionality specific to Windows Phone 7 such as navigation services, orientation awareness, gesture support and access to the application bar.

For the sample application I wanted a blue background to be used within MainPage.xaml so I created an Images folder and added the desired background .png file (1024 X 768 in size) into it along with additional image files to handle navigation, loading and other aspects of the application. Each file was marked as a Resource in the Properties window. By using a background image and applying custom colors I'm overriding the theme selected by the user but in this case I want the application to have a specific look and feel. Although themes won't be discussed here, you can find additional information about them at http://msdn.microsoft.com/en-us/library/ff769554(v=VS.92).aspx.

Once the images were added, I opened MainPage.xaml and added the XAML shown next to create two rows in a Grid, assign the background image and define a TextBlock used for the title area of the application.

<Grid x:Name="LayoutRoot">
    <Grid.RowDefinitions>
        <RowDefinition Height="7*" />
        <RowDefinition Height="93*" />
    </Grid.RowDefinitions>
    <Grid.Background>
        <ImageBrush ImageSource="Images/NavyBackground.png"/>
    </Grid.Background>
<
Border Background="Black" BorderBrush="White" BorderThickness="0,0,0,1"> <TextBlock Text="Amazon Album Viewer" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="32" Foreground="White" /> </Border>
</
Grid>


Here’s what the application looks like in the Visual Studio designer after the XAML is added:

clip_image008

 

Displaying an Album in the Application

The overall goal of the Amazon album viewer application is to display albums retrieved from a REST call. To handle this task, a new user control named AlbumControl was added into a UserControls folder within the project. The AlbumControl file is used to display a single album in the carousel and contains the XAML to handle displaying album covers and showing a reflection:

<Canvas x:Name="Album" Visibility="Collapsed">
    <Rectangle x:Name="Rect" Stroke="Gray" Fill="{Binding ImageBrush}" 
                StrokeThickness="2" RadiusX="10" RadiusY="10" 
                Width="{Binding RectWidth}" 
                Height="{Binding RectHeight}" 
                Cursor="Hand"
                MouseEnter="Rectangle_MouseEnter" 
                MouseLeave="Rectangle_MouseLeave" 
                MouseLeftButtonDown="Rectangle_MouseLeftButtonDown">
    </Rectangle>
    <Rectangle x:Name="RectReflection" 
                Fill="{Binding ImageBrush}" 
                Stroke="Gray" 
                StrokeThickness="2" 
                RadiusX="10" 
                RadiusY="10" 
                Width="{Binding RectWidth}" 
                Height="{Binding RectHeight}" 
                Canvas.Top="{Binding ReflectionCanvasTop}" 
                Canvas.Left="0">
        <Rectangle.OpacityMask>
            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                <GradientStop Offset=".8" Color="Black"/>
                <GradientStop Offset="0" Color="Transparent"/>
            </LinearGradientBrush>
        </Rectangle.OpacityMask>
        <Rectangle.RenderTransform>
            <ScaleTransform ScaleX="1" ScaleY="-.8" CenterX="0" CenterY="0" />
        </Rectangle.RenderTransform>
    </Rectangle>
    <Canvas.RenderTransform>
        <TransformGroup>
            <ScaleTransform x:Name="AlbumScale" 
                            ScaleX=".9"
                            ScaleY=".9" 
                            CenterX="0" 
                            CenterY="0" />
        </TransformGroup>
    </Canvas.RenderTransform>
</Canvas>

 

Looking through the XAML you'll see several data binding expressions that handle binding images and height and width values. The properties that the different XAML attributes are bound to are exposed by an object called AlbumData which holds positioning and image information for each album. The following code shows the AlbumData class. The AlbumData object is assigned to the user control's DataContext so that controls within the user control can bind to the appropriate properties. It's important to note that Windows Phone 7 data bindings are defined just like you'd see in a regular Silverlight application. This really breaks down the learning curve if you already work with Silverlight and are used to binding controls to object properties.

public class AlbumData
{
    public double CanvasLeft { get; set; }
    public double CanvasTop { get; set; }
    public double RectHeight { get; set; }
    public double RectWidth { get; set; }
    public double ReflectionCanvasTop { get; set; }
    public double Scale { get; set; }
    public ImageBrush ImageBrush { get; set; }
    public Album Album { get; set; }
}

 

The code-behind class for the AlbumControl user control accepts an AlbumData object in its constructor and assigns it to the DataContext in the Loaded event handler. It also handles creating an ImageBrush from a URI returned from Amazon.com in order to display the album image. The constructor, Loaded event handler and additional helper methods defined in AlbumControl are shown next:

 

public AlbumControl(AlbumData albumData)
{
    InitializeComponent();
    Loaded += Album_Loaded;
    _AlbumData = albumData;
    CreateImageBrush();
}

private void CreateImageBrush()
{
    try
    {
        //Make main image brush
        var bm = new BitmapImage();
        bm.DownloadProgress += bm_DownloadProgress;
        bm.UriSource = new Uri(_AlbumData.Album.ImageUrlMedium);
        ImageBrush brush = new ImageBrush();
        brush.Stretch = Stretch.Fill;
        brush.ImageSource = bm;
        _AlbumData.ImageBrush = brush;
    }
    catch
    {
                
    }
}

void bm_DownloadProgress(object sender, DownloadProgressEventArgs e)
{
    if (e.Progress == 100)
    {
        Album.Visibility = Visibility.Visible;
        VerticalAnimationStoryBoard.Begin();
    }
}

 

Walking through the code you can see that the AlbumData object passed to the code-behind constructor is assigned to a field named _AlbumData. From there the code creates an ImageBrush object within the CreateImageBrush method that is based on the URI of the image returned from a call to the Amazon REST service. The ImageBrush object is assigned to the AlbumData object's ImageBrush property so that the appropriate image is displayed in the application. Although the remainder of the code won't be shown here, the sample project available contains everything you'll need to see how users can interact with an album once it's rendered.

Retrieving Album Data from the REST Service

Now that you've seen how each album is rendered let's discuss how data is retrieved from Amazon's REST service and displayed in the Windows Phone 7 application. After creating the AlbumControl user control I added another user control into the UserControls folder named AlbumsContainer and referenced it in MainPage.xaml as shown next:

<Grid x:Name="LayoutRoot">
    <!-- Grid row/column definitions and Border (shown earlier) omitted for brevity -->
    <my:AlbumsContainer Grid.Row="1" />
</
Grid>

 

AlbumsContainer is responsible for handling user input and arranging the individual album user controls to simulate the 3D carousel. It contains data loading functionality that handles displaying an animated image to the user as a search is performed, the TextBox and Button controls used to search, a Canvas that acts as the container for the different album images, an album navigation section with arrow images and a section to display album details (refer back to the phone UI shown earlier). Although I won't show the complete XAML file here it's available in the sample project.

To retrieve album data from the Amazon REST service a class named AmazonRESTSearch is used. It relies upon the WebClient class (located in the System.Net namespace) to handle sending and retrieving data. Specific values must be passed to the Amazon REST service in order to get back data successfully and it's important to note that you'll have to get your own keys in order to use the sample. You can create an account at http://tinyurl.com/29us25l. Once you have your keys update the variables at the bottom of the AmazonRESTSearcher class.

Amazon requires a signed request to be sent and the sample project includes a class named SignedRequestHelper that simplifies this process. The signed request object creates a URL that can be used to call the service using the WebClient object as shown next:

public void GetAlbumsAsync(string artistText, string page)
{
    SignedRequestHelper helper = new SignedRequestHelper(MY_AWS_ACCESS_KEY_ID, MY_AWS_SECRET_KEY, DESTINATION);
    IDictionary<string, string> r1 = new Dictionary<string, String>();
    r1["Service"] = "AWSECommerceService";
    r1["Version"] = "2009-03-31";
    r1["Operation"] = "ItemSearch";
    r1["SearchIndex"] = "Music";
    r1["Keywords"] = artistText;
    r1["ResponseGroup"] = "Large";

    string requestUrl = helper.Sign(r1);


    WebClient client = new WebClient();
    client.DownloadStringCompleted += new DownloadStringCompletedEventHandler(client_DownloadStringCompleted);
    //Go through proxy handler we control to get to Amazon if a proper cross domain file isn't there
    //client.DownloadStringAsync(new Uri("http://localhost:4359/WP7AlbumViewer_Web/ProxyHandler.ashx?Uri=" + requestURl));

    client.DownloadStringAsync(new Uri(requestUrl));
}

 

Once the REST service returns data it's parsed using LINQ to XML and serialized into an Album object array:

 

void client_DownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
{
    if (e.Error == null && e.Result != null)
    {
        string result = e.Result;
        XDocument doc = XDocument.Parse(result);

        List<Album> albums = new List<Album>();
        //Locate all of the items
        XNamespace ns = "http://webservices.amazon.com/AWSECommerceService/2009-03-31";

        IEnumerable<XElement> items = (from item in doc.Descendants(ns + "Item")
                                        select item).ToArray<XElement>();
        if (items.Count() > 0)
        {
            foreach (XElement item in items)
            {
                Album album = new Album();
                try
                {
                    album.ID = (string)item.Element(ns + "ASIN");
                    XElement itemAttributes = item.Element(ns + "ItemAttributes");
                    album.Artist = (itemAttributes.Elements(ns + "Artist") != null) 
? (string)itemAttributes.Elements(ns + "Artist").First() : String.Empty; album.Title = itemAttributes.Element(ns + "Title").Value; album.ImageUrlSmall = (item.Element(ns + "SmallImage") != null)
? (string)item.Element(ns + "SmallImage").Element(ns + "URL") : null; album.ImageUrlMedium = (item.Element(ns + "MediumImage") != null)
? (string)item.Element(ns + "MediumImage").Element(ns + "URL") : null; album.ImageUrlLarge = (item.Element(ns + "LargeImage") != null)
? (string)item.Element(ns + "LargeImage").Element(ns + "URL") : null; album.Price = (string)itemAttributes.Element(ns + "ListPrice")
.Element(ns + "FormattedPrice"); if (item.Element(ns + "CustomerReviews") != null) { decimal avgRating; if (decimal.TryParse((string)item.Element(ns + "CustomerReviews")
.Element(ns + "AverageRating"), out avgRating)) { album.AverageRating = avgRating; } int reviews; if (int.TryParse((string)item.Element(ns + "CustomerReviews")
.Element(ns + "TotalReviews"), out reviews)) { album.Reviews = reviews; } } if (item.Element(ns + "Tracks") != null) { foreach (XElement disc in item.Element(ns + "Tracks")
.Elements(ns + "Disc")) //Gets disc number and associated tracks { foreach (XElement track in disc.Elements(ns + "Track")) { album.AddSong( new Song(Int32.Parse(track.Attribute("Number").Value), (string)track)); } } } albums.Add(album); } catch { } Album[] sortedAlbums = albums.ToArray(); Array.Sort(sortedAlbums); OnSearchCompleted(sortedAlbums, null); } } else { OnSearchCompleted(null, new Exception("No albums found!")); } } else { OnSearchCompleted(null, e.Error); } } protected virtual void OnSearchCompleted(Album[] albums,Exception exp) { if (SearchCompleted != null) { SearchCompletedEventArgs args = new SearchCompletedEventArgs(); args.Result = albums; args.Error = exp; SearchCompleted(this, args); } }

 

The Album class contains properties such as Title, Artist, ImageUrlMedium, Reviews, Songs, plus more as shown next:

 

namespace WP7AlbumViewer.Model
{
    public class Song
    {
        public Song()
        {
        }

        public Song(int trackNumber, string title)
        {
            TrackNumber = trackNumber;
            Title = title;
        }

        public int TrackNumber { get; set; }
        public string Title { get; set; }
    }

    public class Album : IComparable<Album>
    {
        private List<Song> _Songs = new List<Song>();

        public string ID { get; set; }
        public string Price { get; set; }

        public Song[] Songs
        {
            get { return _Songs.ToArray(); }
            set { _Songs = new List<Song>(value); }
        }

        public string Title { get; set; }
        public string Artist { get; set; }
        public string ImageUrlSmall { get; set; }
        public string ImageUrlMedium { get; set; }
        public string ImageUrlLarge { get; set; }
        public decimal AverageRating { get; set; }
        public int Reviews { get; set; }

        #region IComparable<Album> Members

        public int CompareTo(Album other)
        {
            if (AverageRating < other.AverageRating) return -1;
            if (AverageRating > other.AverageRating) return 1;
            if (AverageRating == other.AverageRating)
            {
                if (Reviews < other.Reviews) return -1;
                if (Reviews > other.Reviews) return 1;
                return 0;
            }
            return 0;
        }

        #endregion

        public void AddSong(Song song)
        {
            _Songs.Add(song);
        }
    }
}

 

Once the Album array is created it's passed to the AlbumsContainer user control discussed earlier using an event named SearchCompleted. The event handler triggers rendering of the albums in the carousel:


private void searcher_SearchCompleted(object sender, SearchCompletedEventArgs e)
{
    _SearchInProgress = false;
    StartStopLoader(false, "");
    RemoveAlbums();

    if (e.Error == null && e.Result != null)
    {
        ArrangeAlbums(e.Result);
    }
    else
    {
        MessageBox.Show(e.Error.Message);
    }
}

public void ArrangeAlbums(Album[] albums)
{
    if (albums == null || albums.Length == 0) return;

    int albumCount = albums.Length;

    for (int i = 0; i < albumCount; i++)
    {
        Album a = albums[i];
        double angle = i*((Math.PI*2)/albumCount);
        double x = (Math.Cos(angle) * _RadiusX) + _CenterX;
        double y = (Math.Sin(angle) * _RadiusY) + _CenterY;
        double scale = Math.Round((y - _Perspective) / (_CenterY + _RadiusY - _Perspective), 2);

        //Add data that's bound to AlbumControl user control
        var posData = new AlbumData
        {
            CanvasLeft = x,
            CanvasTop = y,
            RectHeight = _ImageHeight,
            RectWidth = _ImageWidth,
            ReflectionCanvasTop = _ReflectY,
            Scale = scale,
            Album = a
        };

        var album = new AlbumControl(posData);
        album.AlbumCommand += album_AlbumCommand;
        AlbumsCanvas.Children.Add(album);
    }


    if (albums[0].Artist.Length > 40)
    {
        ArtistText.Text = albums[0].Artist.Substring(0, 40) + "...";
    }
    else
    {
        if (albums[0].Artist == String.Empty)
        {
            ArtistText.Text = tbSearch.Text;
        }
        else
        {
            ArtistText.Text = albums[0].Artist;
        }
    }
    StartStopLoader(false, ArtistText.Text);
    ArtistTextLine.Opacity = .5D;
}

 

As the user interacts with the navigation controls (arrow images shown earlier in the phone interface) the carousel moves and albums in the back are moved to the front. As an album comes around to the front its details are shown below the navigation controls including the album cover, title and price. Rotation of the albums in the carousel is controlled using sine and cosine functions handled by a method named MoveAlbums in the AlbumsContainer code-behind class:

 

private void MoveAlbums(object sender, EventArgs e)
{
    _CurrAngle = _CurrAngle + _AngleChange;
    int albumCount = AlbumsCanvas.Children.Count;

    for (int i = 0; i < albumCount; i++)
    {
        double angle = i*((Math.PI*2)/albumCount);
        angle += _CurrAngle;
        double x = (Math.Cos(angle)*_RadiusX) + _CenterX;
        double y = (Math.Sin(angle)*_RadiusY) + _CenterY;
        double scale = (y - _Perspective)/(_CenterY + _RadiusY - _Perspective);

        SetAlbumProperties(i, y, x, scale);
    }
}

private void SetAlbumProperties(int childPos, double y, double x, double scale)
{
    var c = (AlbumControl)AlbumsCanvas.Children[childPos];
    c.SetValue(Canvas.LeftProperty, x);
    c.SetValue(Canvas.TopProperty, y);
    if (y < _CenterY) c.SetValue(Canvas.ZIndexProperty, 0);
    if (y > _CenterY) c.SetValue(Canvas.ZIndexProperty, 1);
    if (Math.Abs(_CenterX - x) < 2 && y > _CenterY) ShowAlbum(((AlbumData) c.DataContext).Album, true);
    //var s = (y - c.RenderTransform.ScaleY) / (centerY + radiusY - c.RenderTransform.ScaleY);
    c.AlbumScale.ScaleX = scale;
    c.AlbumScale.ScaleY = scale;
}

 

There's still a lot of work I'd like to do on the Windows Phone 7 album viewer application but there's a nice starter foundation in place that can be refactored and enhanced going forward. Although the code runs fine in the emulator, it needs to be optimized for running on a real device. Additional features I'd like to add include the creation of an album details screen, support for gestures, refactoring of the code into a more MVVM compliant pattern, tombstoning functionality to store and reload search data if a user leaves the application and comes back to it later, adding sounds as animations play, integration with the accelerometer for the carousel rotation, plus more. It took less than 1 hour to migrate the existing application to run on Windows Phone 7 and then I spent several more hours refactoring and tweaking things (I still need to refactor quite a bit but that’s a topic for a later post).

If you have specific ideas or feature suggestions that you'd like to see in upcoming posts leave a comment or let me know on Twitter (http://twitter.com/DanWahlin).


Download the sample application here

 

comments powered by Disqus

2 Comments

  • Nice one Dan. One minor item, change 'Star.jpg' to 'Star.png' needed in AlbumsContainer.xaml for the ratings. :)

  • Good catch. I didn't officially plan to discuss ratings until a later post but since I left the code in there (and you took the time to look through it :-)) I'll get the source updated since the .jpg path is wrong as you point out. Thanks!

Comments have been disabled for this content.