Blograby

Groceries (Panorama)

Groceries is a flexible shopping list app that enables you to set up custom aisle-by-aisle lists. Name and arrange as many isles as you want to match the layout of your favorite store! This app has a lot of features to make adding items easy, such as adding in bulk, selecting favorite items, and selecting recent items.

The Groceries app showcases the panorama control, which enables the other signature Windows Phone user interface paradigm—the one used by every “hub” on the phone (People, Pictures, and so on). Roughly speaking, a panorama acts very similarly to a pivot: It enables horizontal swiping between multiple sections on the same page. What makes it distinct is its appearance and complex animations.

The idea of a panorama is that the user is looking at one piece of a long, horizontal canvas. The user is given several visual hints to swipe horizontally. For example, the application title is larger than what fits on the screen at a single time (unless the title is really short) and each section is a little narrower than the screen, so the left edge of the next section is visible even when not actively panning the page. A panorama wraps around, so panning forward from the last section goes to the first section, and panning backward from the first section goes to the last section.

Figure 27.1 demonstrates how Groceries leverages the panorama control. The first section contains the entire shopping list and the last section contains the cart (items that the user has already grabbed). In between are a dynamic number of sections based on the user-defined aisles and whether there are any items still left to grab in each aisle.

FIGURE 27.1 The grocery panorama, shown the way panoramas are typically shown in marketing materials.

Although the viewport-on-a-long-canvas presentation in Figure 27.1 is the way panoramas are usually shown, that image does not consist of five concatenated screenshots. The reality is much more complex. A panorama consists of three separate layers that each pan at a different speeds, producing a parallax effect. The background pans at the slowest rate, followed by the title, followed by the rest of the content, which moves at the typical scrolling/swiping speed. Figure 27.2 shows what the screen really looks like when visiting each of the five sections in Figure 27.1.

FIGURE 27.2 Real screenshots when visiting each of the five panorama sections from Figure 27.1.

How should I choose between using a panorama versus using a pivot in my app?

The main consideration is your desired visual appearance. A panorama with a good background can provide a more attractive and interesting user interface than a pivot.This is true even if you use the same background for a pivot, thanks to panorama’s parallax panning. A panorama also has better support for horizontal scrolling within a single section, making it easier to have variable- width sections. In just about every other way, a pivot has advantages over a panorama. A pivot gives you more screen real estate for each section. A pivot can perform better for a large number of items and/or content for three reasons: its layout and animations are simpler, it delay-loads its items, and it provides APIs for advanced delay-loading or unloading. It’s also okay to use an application bar (and status bar) with a pivot, whereas it’s considered bad form to use one with a panorama. So if you want to expose several page-level actions, a pivot with an application bar is probably the best choice. The Groceries app is actually a more natural fit for a pivot rather than a panorama, as each section is nothing more than a filtered view of the same list. A typical panorama has sections that are more varied and visually interesting than what is used in Groceries, with plenty of thumbnails (like what you see in the phone’s Marketplace app). However, by using a panorama, Groceries leaves more of an impression with users and is more fun to use.

The Panorama Control

After reading about the Pivot control in the preceding chapter, the Panorama control should look familiar. Panorama, in the Microsoft.Phone.Controls namespace and Microsoft.Phone.Controls assembly, is an items control designed to work with content controls called PanoramaItem.

Although the behavior exhibited by a panorama is more complex than the behavior exhibited by a pivot, it exposes fewer APIs. Like Pivot, Panorama has Title and TitleTemplate properties and a HeaderTemplate property for customizing the headers of its children. Under normal circumstances, there’s no need to use these template properties because the control does a good job of providing the correct look and feel.

PanoramaItem has a Header property, but unlike PivotItem, it also exposes a HeaderTemplate property for customizing an individual header’s appearance. (Of course, you could always directly set Header to a custom UI element without the need for HeaderTemplate.) PanoramaItem has also exposes an Orientation property that indicates the intended direction of scrolling when content doesn’t fit. This property is Vertical by default, but setting it to Horizontal enables a single panorama item to extend wider than the screen. Note that you must add your own scroll viewer if you want scrolling in a vertical panorama item. In a horizontal panorama item, you don’t want to use a scroll viewer; the panorama handles it. Each horizontal panorama item has a maximum width of two screens (960 pixels).

Horizontal Panorama Items and Their Headers

In the panoramas used by the built-in apps, the panorama item header scrolls more slowly than the rest of the content when the panorama item is horizontal and wider than the screen. (This ensures that you can see at least part of the item’s header as long as you’re viewing some of that item’s content.) However, the Panorama control does not provide this behavior. Each panorama item’s header always scrolls at the same rate as the rest of the panorama item’s content, no matter how wide it is.

As for the layout of items inside a panorama item, you’re on your own. Although certain arrangements of square images and text are commonly used in a panorama, there are no special controls that automatically give you these specific layouts. You should use the general-purpose panels such as a grid or a wrap panel.

The Main Page

The Groceries app’s main page, shown earlier in Figure 27.2, is the only one that uses a panorama. It provides links to the four other pages in this app: an add-items page, an edit-items page, a settings page, and an instructions page.

The User Interface

Listing 27.1 contains the XAML for the main page.

LISTING 27.1 MainPage.xaml—The Main User Interface for Groceries

[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:controls=”clr-namespace:Microsoft.Phone.Controls;
➥assembly=Microsoft.Phone.Controls”
FontFamily=”{StaticResource PhoneFontFamilyNormal}”
FontSize=”{StaticResource PhoneFontSizeNormal}”
Foreground=”White”
SupportedOrientations=”Portrait” shell:SystemTray.IsVisible=”False”>
<!– Two storyboards for animating items into and out of the cart –>
<phone:PhoneApplicationPage.Resources>
<Storyboard x:Name=”MoveToCartStoryboard”
Completed=”MoveToCartStoryboard_Completed”>
<DoubleAnimation To=”-400” Duration=”0:0:.2”/>
</Storyboard>
<Storyboard x:Name=”MoveFromCartStoryboard”
Completed=”MoveFromCartStoryboard_Completed”>
<DoubleAnimation To=”400” Duration=”0:0:.2”/>
</Storyboard>
</phone:PhoneApplicationPage.Resources>
<controls:Panorama x:Name=”Panorama” Title=”groceries” Foreground=”White”
SelectionChanged=”Panorama_SelectionChanged”>
<controls:Panorama.Background>
<ImageBrush ImageSource=”Images/background.jpg”/>
</controls:Panorama.Background>
<!– The “list” item –>
<controls:PanoramaItem Foreground=”White”>
<!– A complex header that contains buttons –>
<controls:PanoramaItem.Header>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width=”194”/>
<ColumnDefinition Width=”Auto”/>
<ColumnDefinition Width=”Auto”/>
<ColumnDefinition Width=”Auto”/>
</Grid.ColumnDefinitions>
<!– The normal header text –>
<TextBlock Text=”list”/>
<!– add –>
<Button Grid.Column=”1” Margin=”0,20,36,0” Click=”AddButton_Click”
Style=”{StaticResource SimpleButtonStyle}”>
<Image Source=”Shared/Images/normal.add.png”/>
</Button>
<!– settings –>
<Button Grid.Column=”2” Margin=”0,20,36,0” Click=”SettingsButton_Click”
Style=”{StaticResource SimpleButtonStyle}”>
<Image Source=”Shared/Images/normal.settings.png”/>
</Button>
<!– instructions –>
<Button Grid.Column=”3” Margin=”0,20,36,0”
Click=”InstructionsButton_Click”
Style=”{StaticResource SimpleButtonStyle}”>
<Image Source=”Shared/Images/normal.instructions.png”/>
</Button>
</Grid>
</controls:PanoramaItem.Header>
<!– The panorama item’s content is just a list box –>
<ListBox x:Name=”MainListBox” ItemsSource=”{Binding}”>
<!– Give each item a complex template –>
<ListBox.ItemTemplate>
<DataTemplate>
<!– A horizontal stack panel with two buttons –>
<StackPanel Orientation=”Horizontal” Margin=”0,0,0,16”>
<!– The first button sends the item to the cart –>
<Button Style=”{StaticResource SimpleButtonStyle}”
Click=”AddToCartButton_Click”>
<Button.RenderTransform>
<CompositeTransform/>
</Button.RenderTransform>
<StackPanel Orientation=”Horizontal”>
<Image Source=”Shared/Images/normal.done.png”/>
<TextBlock Text=”{Binding Name}” Width=”300” TextWrapping=”Wrap”
Style=”{StaticResource PhoneTextExtraLargeStyle}”
Foreground=”White”/>
</StackPanel>
</Button>
<!– The second button edits the item –>
<Button Style=”{StaticResource SimpleButtonStyle}”
Click=”EditItemButton_Click”>
<Image Source=”Shared/Images/normal.edit.png”/>
</Button>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</controls:PanoramaItem>
<!– The “in cart” item –>
<controls:PanoramaItem Foreground=”White”>
<!– A complex header that contains a button –>
<controls:PanoramaItem.Header>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width=”286”/>
<ColumnDefinition Width=”Auto”/>
</Grid.ColumnDefinitions>
<!– The normal header text –>
<TextBlock Text=”in cart”/>
<!– delete –>
<Button Grid.Column=”1” Margin=”0,20,36,0” Click=”DeleteButton_Click”
Style=”{StaticResource SimpleButtonStyle}”>
<Image Source=”Shared/Images/normal.delete.png”/>
</Button>
</Grid>
</controls:PanoramaItem.Header>
<!– This panorama item’s content is a list box in front of a cart image –>
<Grid>
<Image Source=”Images/cart.png” Opacity=”.3” Stretch=”None”/>
<ListBox x:Name=”InCartListBox” ItemsSource=”{Binding}”>
<!– Give each item a complex template –>
<ListBox.ItemTemplate>
<DataTemplate>
<Button Margin=”0,0,0,16” Style=”{StaticResource SimpleButtonStyle}”
Click=”RemoveFromCartButton_Click”>
<Button.RenderTransform>
<CompositeTransform/>
</Button.RenderTransform>
<StackPanel Orientation=”Horizontal”>
<Image Source=”Images/normal.outOfCart.png”/>
<TextBlock Text=”{Binding Name}” Width=”359” TextWrapping=”Wrap”
Style=”{StaticResource PhoneTextExtraLargeStyle}”
Foreground=”White”/>
</StackPanel>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</controls:PanoramaItem>
</controls:Panorama>
</phone:PhoneApplicationPage>

[/code]

Be sure to test your panorama under both dark and light themes!

This is true for any app, of course, but you’re more likely to make a mistake on a page with a panorama that has a fixed background image. If the background never changes, then you probably need to ensure that the color of your content never changes.

To avoid stretching,make sure your panorama’s background image is 800 pixels tall.To avoid performance problems, the image should not be much wider than about 1024 pixels, and it should be a JPEG. Groceries uses a 1024×800 JPEG. When I decided to build this app, I anxiously went to a local grocery store with my wife’s new camera because it has the ability to take panoramic photos.This was before I realized that the best background image dimensions are not panoramic at all! Figure 27.3 shows this app’s background.jpg file.

FIGURE 27.3 The not-so-panoramic background image used by the Groceries app’s panorama.

The effect of a super-wide background image is an illusion caused by the slow, parallax scrolling of the background. In fact, the amount of background scrolling depends on the number of panorama items, because the panorama ensures that you don’t reach the end of the background image until you reach the end of the panorama. In Groceries, it just so happens that the length of the “groceries” title and the length of the background image cause the title and background to scroll at roughly the same rate.To get a richer parallax effect, you could change the length of either one.

For the best results, your panorama’s background image should be given a Build Action of Resource—not Content! This is one of those rare cases where a resource file is recommended, due to the difference between synchronous and asynchronous loading/decoding. If the image is large and included as a content file, the panorama might appear before its background does.When included as a resource file, the panorama will never appear until the image is ready.The synchronous loading done for resource files, which is normally considered to be a problem, actually gives more desirable behavior in this case.Despite increasing the amount of time before the panorama appears,most people do not want their background image appearing later.

You can actually use live UI elements for your panorama’s background! This involves a hack shared by Microsoft’s Dave Relyea, the author of the Panorama control and technical editor for this book.You can read about it at http://bit.ly/panoramaxaml.

FIGURE 27.4 Shading in the background image makes the seam less jarring when wrapping from the last panorama item to the first one.

Even when using a specially crafted image, a 1-pixel-wide background-color seam can still occasionally be seen while the user scrolls past the wraparound point.You can get rid of this seam by giving Panorama a new control template. It can be a copy of the default one, with a single negative margin added to a border named background as follows:

[code]

<Border x:Name=”background” Background=”{TemplateBinding Background}”
CacheMode=”BitmapCache” Margin=”-1,0”/>

[/code]

FIGURE 27.5 Groceries is filled with buttons that easily detect non-swiping taps, which is obvious when the custom button style is removed.

Avoid using raw mouse events like MouseLeftButtonDown, MouseMove, and MouseLeftButtonUp inside a panorama (or pivot)!

Because the entire control pans in response to these gestures, any extra logic you associate with these events is likely to interfere with the user’s panning expectations. Find other relevant events to use instead that aren’t also triggered by swiping gestures, such as a button’s Click event or a list box’s SelectionChanged event.

The Code-Behind

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

LISTING 27.2 MainPage.xaml.cs—The Code-Behind for the Groceries App’s Main Page

[code]

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using Microsoft.Phone.Controls;
namespace WindowsPhoneApp
{
public partial class MainPage : PhoneApplicationPage
{
Item pendingIntoCartItem;
Item pendingOutOfCartItem;
public MainPage()
{
InitializeComponent();
this.Loaded += MainPage_Loaded;
}
void MainPage_Loaded(object sender, RoutedEventArgs e)
{
// Fill the two list boxes that are always there
this.MainListBox.DataContext = FilteredLists.Need;
this.InCartListBox.DataContext = FilteredLists.InCart;
// Add and fill the other aisles based on the user’s data
RefreshAisles();
}
void RefreshAisles()
{
// Remove all aisles. Leave the list and cart items.
while (this.Panorama.Items.Count > 2)
this.Panorama.Items.RemoveAt(1);
// Get the list of dynamic aisles
string[] aisles = Settings.AislesList.Value;
for (int i = aisles.Length – 1; i >= 0; i–)
{
string aisle = aisles[i];
AislePanoramaItem panoramaItem = new AislePanoramaItem { Header = aisle };
// Fill the aisle with relevant items
panoramaItem.Items = new FilteredObservableCollection<Item>(
Settings.AvailableItems.Value, delegate(Item item)
{
return (item.Status == Status.Need && item.Aisle == aisle);
});
// Only add aisles that contain items we still need to get
if (panoramaItem.Items.Count > 0)
this.Panorama.Items.Insert(1, panoramaItem);
}
}
void Panorama_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Check to see if the item we’re leaving is now empty
if (e.RemovedItems.Count == 1)
{
AislePanoramaItem aisle = e.RemovedItems[0] as AislePanoramaItem;
if (aisle != null && aisle.Items.Count == 0)
{
// It’s empty, so remove it.
// But wait .5 seconds to avoid interfering with the animation!
DispatcherTimer timer = new DispatcherTimer {
Interval = TimeSpan.FromSeconds(.5) };
timer.Tick += delegate(object s, EventArgs args)
{
this.Panorama.Items.Remove(aisle);
timer.Stop();
};
timer.Start();
}
}
}
// The three “list” header button handlers
void SettingsButton_Click(object sender, RoutedEventArgs e)
{
this.NavigationService.Navigate(new Uri(“/SettingsPage.xaml”,
UriKind.Relative));
}
void AddButton_Click(object sender, RoutedEventArgs e)
{
this.NavigationService.Navigate(new Uri(“/AddItemsPage.xaml”,
UriKind.Relative));
}
void InstructionsButton_Click(object sender, RoutedEventArgs e)
{
this.NavigationService.Navigate(new Uri(“/InstructionsPage.xaml”,
UriKind.Relative));
}
// The two button handlers for each item in “list”
void AddToCartButton_Click(object sender, RoutedEventArgs e)
{
if (this.MoveToCartStoryboard.GetCurrentState() != ClockState.Stopped)
return;
this.pendingIntoCartItem = (sender as FrameworkElement).DataContext as Item;
Storyboard.SetTarget(this.MoveToCartStoryboard,
(sender as UIElement).RenderTransform);
Storyboard.SetTargetProperty(this.MoveToCartStoryboard,
new PropertyPath(“TranslateX”));
this.MoveToCartStoryboard.Begin();
}
void EditItemButton_Click(object sender, RoutedEventArgs e)
{
Item item = (sender as FrameworkElement).DataContext as Item;
Settings.EditedItem.Value = item;
this.NavigationService.Navigate(new Uri(“/EditItemPage.xaml”,
UriKind.Relative));
}
// The one “in cart” header button handler
void DeleteButton_Click(object sender, RoutedEventArgs e)
{
if (MessageBox.Show(
“Are you sure you want to remove all the items from the cart?”,
“Clear cart?”, MessageBoxButton.OKCancel) == MessageBoxResult.OK)
{
foreach (Item item in Settings.AvailableItems.Value)
{
// Nothing is actually deleted, just marked Unused
if (item.Status == Status.InCart)
item.Status = Status.Unused;
}
}
}
// The one button handler for each item in the cart
void RemoveFromCartButton_Click(object sender, RoutedEventArgs e)
{
if (this.MoveFromCartStoryboard.GetCurrentState() != ClockState.Stopped)
return;
this.pendingOutOfCartItem =
(sender as FrameworkElement).DataContext as Item;
Storyboard.SetTarget(this.MoveFromCartStoryboard,
(sender as UIElement).RenderTransform);
Storyboard.SetTargetProperty(this.MoveFromCartStoryboard,
new PropertyPath(“TranslateX”));
this.MoveFromCartStoryboard.Begin();
}
// Storyboard-completed handlers
void MoveFromCartStoryboard_Completed(object sender, EventArgs e)
{
this.pendingOutOfCartItem.Status = Status.Need;
// This may have caused the need to add an aisle
RefreshAisles();
this.MoveFromCartStoryboard.Stop();
}
void MoveToCartStoryboard_Completed(object sender, EventArgs e)
{
this.pendingIntoCartItem.Status = Status.InCart;
// This may have caused the need to remove an aisle
RefreshAisles();
this.MoveToCartStoryboard.Stop();
}
}
}

[/code]

Panoramas do not enable programmatic setting of the selected panorama item!

One thing conspicuously missing from Listing 27.2 is a setting that remembers the current panorama item so the page’s state can be restored on the next launch or activation. Strangely, panorama’s SelectedIndex and SelectedItem properties are readonly, so although you can save either value when the selection changes, you cannot restore it later.

Panorama does expose a read/write property called DefaultItem that can instantly change the panorama item on the screen, but not in the way that you’d expect. It shifts the items such that DefaultItem becomes the first section on the virtual canvas, as illustrated in Figure 27.6.This means that the title now aligns with the new default item and the image seam is now immediately to the left of this item! In the Groceries app, having the image seam move anywhere other than between the cart and the list sections would be a confusing experience.Therefore, the DefaultItem property is not suitable for attempting to return a user to where they left off.

FIGURE 27.6 Setting DefaultItem shifts the panorama items, but it does not shift the title or the background image.

The AislePanoramaItem Control

AislePanoramaItem was added to the Visual Studio project as a user control, but then its base class was changed from UserControl to PanoramaItem. This was done to get the same kind of convenient XAML support as a user control, but applied to a PanoramaItem subclass. Listing 27.3 contains this control’s XAML and Listing 27.4 contains its codebehind.

LISTING 27.3 AislePanoramaItem.xaml—The User Interface for the Custom PanoramaItem Subclass

[code]

<controls:PanoramaItem x:Class=”WindowsPhoneApp.AislePanoramaItem”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:controls=”clr-namespace:Microsoft.Phone.Controls;
➥assembly=Microsoft.Phone.Controls”
FontFamily=”{StaticResource PhoneFontFamilyNormal}”
FontSize=”{StaticResource PhoneFontSizeNormal}”
Foreground=”White”>
<!– A storyboards for animating items into the cart –>
<controls:PanoramaItem.Resources>
<Storyboard x:Name=”MoveToCartStoryboard” Completed=”Storyboard_Completed”>
<DoubleAnimation To=”-400” Duration=”0:0:.2”/>
</Storyboard>
</controls:PanoramaItem.Resources>
<!– The panorama item’s content is just a list box –>
<ListBox x:Name=”ListBox” ItemsSource=”{Binding}” >
<ListBox.ItemTemplate>
<DataTemplate>
<Button Margin=”0,0,0,16” Style=”{StaticResource SimpleButtonStyle}”
Click=”ItemButton_Click”>
<Button.RenderTransform>
<CompositeTransform/>
</Button.RenderTransform>
<StackPanel Orientation=”Horizontal”>
<Image Source=”Shared/Images/normal.done.png”/>
<TextBlock Text=”{Binding Name}” Width=”300” TextWrapping=”Wrap”
Style=”{StaticResource PhoneTextExtraLargeStyle}”
Foreground=”White”/>
</StackPanel>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</controls:PanoramaItem>

[/code]

LISTING 27.4 AislePanoramaItem.xaml.cs—The Code-Behind for the Custom PanoramaItem Subclass

[code]

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media.Animation;
using Microsoft.Phone.Controls;
namespace WindowsPhoneApp
{
public partial class AislePanoramaItem : PanoramaItem
{
Item pendingItem;
public AislePanoramaItem()
{
InitializeComponent();
}
public ICollection<Item> Items
{
get { return this.ListBox.DataContext as ICollection<Item>; }
set { this.ListBox.DataContext = value; }
}
void ItemButton_Click(object sender, RoutedEventArgs e)
{
if (this.MoveToCartStoryboard.GetCurrentState() != ClockState.Stopped)
return;
// Animate the item when tapped
this.pendingItem = (sender as FrameworkElement).DataContext as Item;
Storyboard.SetTarget(this.MoveToCartStoryboard,
(sender as UIElement).RenderTransform);
Storyboard.SetTargetProperty(this.MoveToCartStoryboard,
new PropertyPath(“TranslateX”));
this.MoveToCartStoryboard.Begin();
}
void Storyboard_Completed(object sender, EventArgs e)
{
// Now place the item in the cart
this.pendingItem.Status = Status.InCart;
this.MoveToCartStoryboard.Stop();
}
}
}

[/code]

This panorama item is just like the first panorama item on the main page, but with no edit button in the item template. This convenient packaging enables it to be easily reused by main page, as is done in the RefreshAisles method in Listing 27.2.

Supporting Data Types

The Item data type that is used throughout this app is defined in Listing 27.5.

LISTING 27.5 Item.cs—The Type of Every Item in Every List Box

[code]

using System.ComponentModel;
namespace WindowsPhoneApp
{
public class Item : INotifyPropertyChanged
{
// The backing fields
string name;
string aisle;
bool isFavorite;
Status status;
// The properties, which raise change notifications
public string Name {
get { return this.name; }
set { this.name = value; OnPropertyChanged(“Name”); } }
public string Aisle {
get { return this.aisle; }
set { this.aisle = value; OnPropertyChanged(“Aisle”); } }
public bool IsFavorite {
get { return this.isFavorite; }
set { this.isFavorite = value; OnPropertyChanged(“IsFavorite”); } }
public Status Status {
get { return this.status; }
set { this.status = value; OnPropertyChanged(“Status”); } }
void OnPropertyChanged(string propertyName)
{
PropertyChangedEventHandler handler = this.PropertyChanged;
if (handler != null)
handler(this, new PropertyChangedEventArgs(propertyName));
}
// Treat items with the same name as equal
public override bool Equals(object obj)
{
if (!(obj is Item))
return false;
return (this.Name == (obj as Item).Name);
}
// This matches the implementation of Equals
public override int GetHashCode()
{
return this.Name.GetHashCode();
}
public event PropertyChangedEventHandler PropertyChanged;
}
}

[/code]

The AvailableItems setting that persists the list of all items is defined as follows inside the Settings class:

[code]

public static readonly Setting<ObservableCollection<Item>> AvailableItems =
new Setting<ObservableCollection<Item>>(“AvailableItems”,
new ObservableCollection<Item>());

[/code]

The filtered lists used by this app are not persisted but rather initialized from the single persisted list once the app runs. They are defined as follows:

[ocde]

public static class FilteredLists
{
// A list of items in the current shopping list (but not in the cart yet)
public static readonly ReadOnlyObservableCollection<Item> Need =
new ReadOnlyObservableCollection<Item>(
new FilteredObservableCollection<Item>(Settings.AvailableItems.Value,
delegate(Item item) { return item.Status == Status.Need; }));
// A list of items in the cart
public static readonly ReadOnlyObservableCollection<Item> InCart =
new ReadOnlyObservableCollection<Item>(
new FilteredObservableCollection<Item>(Settings.AvailableItems.Value,
delegate(Item item) { return item.Status == Status.InCart; }));
// A list of items marked as favorites
public static readonly ReadOnlyObservableCollection<Item> Favorites =
new ReadOnlyObservableCollection<Item>(
new FilteredObservableCollection<Item>(Settings.AvailableItems.Value,
delegate(Item item) { return item.IsFavorite; }));
}

[/code]

Each FilteredObservableCollection is wrapped in a ReadOnlyObservableCollection to prevent consumers from accidentally attempting to modify the collection directly.

Listing 27.6 contains the implementation of the custom FilteredObservableCollection class.

LISTING 27.6 FilteredObservableCollection.cs—Exposes a Subset of a Separate Observable Collection Based on a Custom Filter

[code]

using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace WindowsPhoneApp
{
public class FilteredObservableCollection<T> : ObservableCollection<T>
where T : INotifyPropertyChanged
{
ObservableCollection<T> sourceCollection;
Predicate<T> belongs;
public FilteredObservableCollection(ObservableCollection<T> sourceCollection,
Predicate<T> filter)
{
this.sourceCollection = sourceCollection;
this.belongs = filter;
// Listen for any changes in the source collection
this.sourceCollection.CollectionChanged +=
SourceCollection_CollectionChanged;
foreach (T item in this.sourceCollection)
{
// We must also listen for changes on each item, because property changes
// are not reported through the CollectionChanged event
item.PropertyChanged += Item_PropertyChanged;
// Add the item to this list if it passes the filter
if (this.belongs(item))
this.Add(item);
}
}
// Handler for each item’s property changes
void Item_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
T item = (T)sender;
if (this.belongs(item))
{
// The item belongs in this list, so add it (if it wasn’t already added)
if (!this.Contains(item))
this.Add(item);
}
else
{
// The item does not belong in this list, so remove it if present.
// Remove simply returns false if the item is not in this list.
this.Remove(item);
}
}
// Handler for collection changes
void SourceCollection_CollectionChanged(object sender,
NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add ||
e.Action == NotifyCollectionChangedAction.Replace)
{
// Insert any relevant item(s) at the end of the list
foreach (T item in e.NewItems)
{
// We must start tracking property changes in this item as well
item.PropertyChanged += Item_PropertyChanged;
if (this.belongs(item))
this.Add(item);
}
}
else if (e.Action == NotifyCollectionChangedAction.Remove ||
e.Action == NotifyCollectionChangedAction.Replace)
{
// Try removing each one
foreach (T item in e.OldItems)
{
// We can stop tracking property changes on this item
item.PropertyChanged -= Item_PropertyChanged;
this.Remove(item);
}
}
else // e.Action == NotifyCollectionChangedAction.Reset
{
throw new NotSupportedException();
}
}
}
}

[/code]

This class is constructed with a source collection and a callback that returns whether an individual item belongs in the filtered list. This enables each instance to use a different filter, as done in the FilteredLists static class. The type of item used with this class must implement INotifyPropertyChanged, as this class tracks item-by-item property changes as well as additions and removals to the source collection. (This is a requirement for Groceries, as changing a property like Status or IsFavorite must instantly impact the filtered lists.)

The Finished Product

Exit mobile version