Windows Phone 7: Dynamically adding an Rss feed per panorama page
My first WP7 app was a little Rss reader for one of my favourite news sites. It’s quite easy to learn how to consume Rss feeds in a WP7 app. A quick search will throw up numerous samples including those on msdn (https://dev.windowsphone.com/en-us/home). So I got as far as consuming feeds and displaying the titles, associated pictures etc... fairly quickly. My basic functional design flow has a panorama page per new category and lets the user chose articles by headline. Once they have selected an article to read I launch that link in a new webbrowser instance.
So far so good. Now for the challenge. This particular site has 14 different feeds, so I want/need to let the user configure their preferred feeds. I interpreted this as a requirement for a dynamic no. of panorama pages – one per news category.
1. Managing user selections:
First off I needed to detect and save the user settings. There are two types of storage on WP7, read-only for reference files etc... supplied with your app and the more useful read/write isolated storage for files created by your app. I supply the full list of feeds in an initial xml file with a default set of marked as currently selected.
Initial xml settings file:
<?xml version="1.0" encoding="utf-8" ?>
<RssFeeds>
<Feed Title="Category 1" href="https://thefeedurl/category1 " Selected="0"/>
<Feed Title="Category 2" href="https://thefeedurl/category2" Selected="1"/>
<Feed Title="Category 3" href="https://thefeedurl/category3 " Selected="0"/>
<Feed Title="Category 4" href="https://thefeedurl/category4" Selected="1"/>
<Feed Title="Category 5" href="https://thefeedurl/category5 " Selected="0"/>
<Feed Title="Category 6" href="https://thefeedurl/category6" Selected="1"/>
<Feed Title="Category 7" href="https://thefeedurl/category7 " Selected="0"/>
<Feed Title="Category 8" href="https://thefeedurl/category8" Selected="1"/>
</RssFeeds>
When the user wants to configure their selections I display the list of feed categories with checkboxes and on save write their selection to a new isolated storage file:
IsolatedStorageFile myIsolatedStorage = IsolatedStorageFile.GetUserStoreForApplication();
IsolatedStorageFileStream isoStream = new IsolatedStorageFileStream("Feeds.xml", FileMode.Create, FileAccess.Write, myIsolatedStorage);
XmlWriterSettings settings = new XmlWriterSettings();
settings.Indent = true;
XmlWriter writer = XmlWriter.Create(isoStream, settings);
writer.WriteStartDocument();
writer.WriteStartElement("RssFeeds");
foreach (Category cSetting in MainPage.feedList)
{
writer.WriteStartElement("Feed");
writer.WriteStartAttribute("Title");
writer.WriteString(cSetting.categoryName);
writer.WriteEndAttribute();
writer.WriteStartAttribute("href");
writer.WriteString(cSetting.categoryFeedURI);
writer.WriteEndAttribute();
writer.WriteStartAttribute("Selected");
writer.WriteString(Convert.ToInt16(cSetting.currentlySelected).ToString());
writer.WriteEndAttribute();
writer.WriteEndElement();
}
writer.WriteEndElement();
writer.WriteEndDocument();
writer.Flush();
writer.Close();
isoStream.Close();
Then on startup (PhoneApplicationPage_Loaded in the main page) I check to see if the isolated storage file exists and if it does I read the selections from there, otherwise I load the default selections from the read-only file:
public void ReadCategorySelections()
{
XElement xmlFeeds = null;
IsolatedStorageFileStream isoFileStream = null;
try
{
IsolatedStorageFile myIsolatedStorage = IsolatedStorageFile.GetUserStoreForApplication();
if (!myIsolatedStorage.FileExists("feeds.xml"))
{
Uri uri = new Uri("Feeds.xml", UriKind.Relative);
StreamResourceInfo sri = App.GetResourceStream(uri);
xmlFeeds = XElement.Load(sri.Stream, LoadOptions.None);
}
else
{
isoFileStream = myIsolatedStorage.OpenFile("feeds.xml", FileMode.Open);
xmlFeeds = XElement.Load(isoFileStream, LoadOptions.None);
}
feedList.Clear();
foreach (XElement childElement in xmlFeeds.Elements())
{
Category rssCat = new Category();
rssCat.categoryName = childElement.Attribute("Title").Value;
rssCat.categoryFeedURI = childElement.Attribute("href").Value;
rssCat.currentlySelected = Convert.ToBoolean(Convert.ToInt16(childElement.Attribute("Selected").Value));
feedList.Add(rssCat);
if (rssCat.currentlySelected)
{
AddItem(rssCat);
}
}
if (isoFileStream != null)
{
isoFileStream.Close();
}
}
catch (Exception ex)
{
Trace(ex.Message);
MessageBox.Show("An initialization error has occurred");
NavigationService.GoBack();
}
}
2. Managing a dynamic no. of Panorama pages
The easiest way I could see to do this was to have a resource template which I use for each panorama page. The template basically has one user control which is a PanoramaItem which has a ListBox to which I can add the news category headlines, pictures if available or publish date and times.
Category template:
<controls:PanoramaItem Name="panoramaFeedList" Header="News" Height="643" Width="450">
<ListBox Name="NewsList" Width="442" Height="516" Margin="0,0,0,0">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<!--<Setter Property="Background" Value="#3F1F1F1F"/>-->
<Setter Property="Background" Value="WhiteSmoke"/>
<Setter Property="Foreground" Value="Black"/>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderThickness="0,0,0,1" BorderBrush="Gray">
<StackPanel Name="Headline" Orientation="Horizontal" Loaded="Headline_Loaded">
<Image Name="NewsPic" Width="100" Height="100" Source="{Binding Media}" Margin="0,0,0,5" Visibility="Visible"/>
<TextBlock Name="PubString" CacheMode="BitmapCache" Height="100" Width="100" Text="{Binding PubString}" TextWrapping="NoWrap" Margin="0,0,5,5" Visibility="Collapsed" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Name="HeadLine" CacheMode="BitmapCache" Height="100" Width="300" Margin ="5,0,0,0" Text="{Binding Title}" TextWrapping="Wrap" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</controls:PanoramaItem>
To fill the Panorama with pages and lists of articles then I read the the list (I’ve stored the category url data in local class called RssFeed – see below snippet).
This next method “AddItem” is called for each selected category. Here I add the panorama page using the template and give it the category name as a title. The next bit is potentially messy if there is any conflict between the list of categories/feeds and the actual titres in the feed data streams. To re-use code and avoid cloning the DownloadStringCompletedEventHandler for each category I give the same handler delegate to every call to DownloadStringAsync.
So I am dependent on the category titles for matching each returned data feed stream to the correct panorama page. In the case of the site I’m using this works - the stream returned contains the category title, so when I parse the returned data I can use the title to put the article list on the correct panorama page.
The AddItem method:
private void AddItem(Category rssFeed)
{
try
{
var pItem = new NewsFeedControl();
pItem.panoramaFeedList.Header = rssFeed.categoryName;
pItem.NewsList.SelectionChanged += new SelectionChangedEventHandler(newsList_SelectionChanged);
pItem.panoramaFeedList.HeaderTemplate = App.Current.Resources["NewsFeedHeaderTemplate"] as DataTemplate;
Item.ApplyTemplate();
panoramaControlMain.Items.Add(pItem);
WebClient feedClient = new WebClient();
feedClient.DownloadStringCompleted += new DownloadStringCompletedEventHandler(feed_DownloadStringCompleted);
feedClient.DownloadStringAsync(new Uri(rssFeed.categoryFeedURI));
}
catch (Exception ex)
{
Trace(ex.Message);
DisplayError("Error adding feed item");
}
}
The DownloadStringCompletedEventHandler basically idebntifies the channel/category title for the returned data and calls FillNewsPane.
void feed_DownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
{
string listBoxName = string.Empty, channelString=string.Empty;
if (e.Error != null)
{
Trace(e.Error.Message);
DisplayError("A news feed download error has occurred");
return;
}
try
{
XElement xmlNews = XElement.Parse(e.Result);
//first id the channel name
if (xmlNews.Descendants("channel").Count() != 0)
{
channelString = xmlNews.Descendants("channel").First().Value;
FillNewsPane(channelString, xmlNews);
}
}
catch (Exception ex)
{
Trace(ex.Message);
DisplayError("Error parsing feed item");
}
}
private void FillNewsPane(string channelName, XElement xmlNews)
{
if (string.IsNullOrEmpty(channelName))
{
Trace("Failed to identify downloaded channel");
DisplayError("ERROR - Channel identification failure");
return;
}
NewsFeedControl newsFeedPane = (NewsFeedControl)panoramaControlMain.Items.FirstOrDefault(i => ((NewsFeedControl)i).panoramaFeedList.Header.ToString().ToLower().Equals(channelName.ToLower()));
if (newsFeedPane == null)
{
Trace("Failed to find Panel for news feed");
DisplayError("ERROR - Panel id failure");
return;
}
try
{
foreach (var item in xmlNews.Descendants("item"))
{
RssItem rssItem = new RssItem();
rssItem.Title = (string)item.Element("title").Value;
rssItem.Content = (string)item.Element("description").Value;
rssItem.Link = (string)item.Element("link").Value;
rssItem.PubString = ((DateTime)item.Element("pubDate")).ToShortTimeString();
foreach (var mediaItem in item.Descendants("enclosure"))
{
if (mediaItem.HasAttributes && mediaItem.Attribute("type").Value == "image/jpeg")
rssItem.Media = (string)mediaItem.Attribute("url").Value;
}
newsFeedPane.NewsList.Items.Add(rssItem);
}
}
catch (Exception ex)
{
Trace(ex.Message);
DisplayError("Error filling news panel");
}
}