Archive for December 2011
Long List Selector (Jump List) Example
I am developing an App that will require the Long List Selector (LLS) functionality that is demonstrated in the WP7 People selector. The LLS also provides a good UI for breaking up even modest sized lists in to easy on the eye groups.
The Windows Phone Toolkit on Codeplex (http://silverlight.codeplex.com/releases/view/75888) provides a LLS but there is no documentation to explain how to use it.
The LLS is not a simple control – there is a fair amount of setup and code required before it will work, however I’ve learnt a lot and am quite pleased with the result.
I have replaced the standard List control in my Master Key application with a LLS but have created a cut down version of the App just to focus on using LLS. I’ve kept the Transitions code in place as well from the previous post.
Before going into the details of LLS I should explain some of the other code that is in the example I’m going to use.
I make extensive use of XML in my applications when working with data, I find the ease with which I can create a robust and arbitrarily complex data model using XML documents far outweighs any disadvantages. Linq for XML is brilliant to work with and over the years I’ve created a set of simple helper extensions which simplify the extraction of values from XML Elements and Attributes.
I always add a copy of XElementHelpers.cs to my projects when I’m using Linq as it removes some of the pain involved when the XML is missing elements. It is a helper class which adds some useful methods for retrieving the values of XML Elements and Attributes. The most commonly used being .ToStringNull() which will return either the Element value or an empty string, however it also returns an empty string even if the Element does not exist (is null).
I’ve also learnt that using a Data Access Layer (DAL) even for small projects brings long term benefits in terms of maintainability and extensibility so my code examples will be slightly more complex as a result.
I’ve put the source for the project here: www.metadev.co.uk/Downloads/PhoneAppLLS.zip please feel free to download and use it.
I have included enough code in the example, which is a cut down version of my Master Key App, to demonstrate the full lifecycle for list management allowing entries to be displayed, selected and edited as well as being able to add and delete entries.
The idea behind the Master Key App is pretty simple – it allows the user to keep a list of keys, passwords and other info in an encrypted form on the phone. A single ‘master key’ decrypts the information giving access to the entries. I’ve called the list a Vault which is made up of individual Vault Entries so you will see plenty of references to Vaults in the code!
For this sample I have removed the encryption and much of the other persistence code as it distracts from the LLS.
The LLS groups items under a title. The title can be arbitrary but for this example I have stuck to grouping the entries based on the first character of the caption. The classification is however completely open and any parent can be defined.
The Vault is maintained in the DAL as a sorted Dictionary of <Caption, VaultEntry> pairs. In the previous version these entries where then simply dropped into a standard ListBox. Sorting is not something that comes for free with either the ListBox or the LLS (or even the Dictionary object for that matter) so maintaining the sorted list is part of the DAL.
public void SortEntries() { try { // sort the keys - Silverlight Dictionary does not have a sort option string[] array = new string[VaultEntries.Keys.Count]; VaultEntries.Keys.CopyTo(array, 0); List<string> list = new List<string>(array); list.Sort(); // now recreate the dictioanry Dictionary<string, VaultEntry> sortedEntries = new Dictionary<string, VaultEntry>(); VaultEntry entry = new VaultEntry(); foreach (string key in list) { VaultEntries.TryGetValue(key, out entry); sortedEntries.Add(key, entry); } VaultEntries = sortedEntries; } catch { throw; } }
The Group class has two principle properties, a Title which provides the group heading and a List of entries which provide the rest of the group content.
/// <summary> /// Generic Group class which has a string Title and a generic list of items /// that are related to the title - for example the Title could be 'k' and then /// all the items could have entry such as a persons surname that being with 'k' /// </summary> /// <typeparam name="T"></typeparam> public class Group<T> : IEnumerable<T> { /// <summary> /// Group title - this provides the jump list titles /// </summary> public string Title { get; set; } /// <summary> /// List of items in the group /// </summary> public IList<T> Items { get; set; } /// <summary> /// True when there are items - it's used primarily to create the tiles in the jump list /// to indicate visibly where items exist /// </summary> public bool HasItems { get { return Items.Count > 0; } } /// <summary> /// This is used to colour the tiles - greying out those that have no entries /// </summary> public Brush GroupBackgroundBrush { get { return (SolidColorBrush)Application.Current.Resources[(HasItems) ?
"PhoneAccentBrush" : "PhoneChromeBrush"]; } } /// <summary> /// Group constructor /// </summary> /// <param name="name"></param> /// <param name="items"></param> public Group(string name, IEnumerable<T> items) { this.Title = name; this.Items = new List<T>(items); } /// <summary> /// Test for Title equality (and that object exists) /// </summary> /// <param name="obj"></param> /// <returns></returns> public override bool Equals(object obj) { Group<T> that = obj as Group<T>; return (that != null) && (this.Title.Equals(that.Title)); } /// <summary> /// When overriding Equals the GetHashCode has to also be overridden /// </summary> /// <returns></returns> public override int GetHashCode() { return this.Title.GetHashCode(); } /// <summary> /// Item collection enumerator /// </summary> /// <returns></returns> public IEnumerator<T> GetEnumerator() { return this.Items.GetEnumerator(); } /// <summary> /// Item collection enumerator /// </summary> /// <returns></returns> System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return this.Items.GetEnumerator(); } }
The properties HasItems and GroupBackgroundBrush are primarily used when displaying the jump list:
A requirement for the LLS list is for the list elements to be IEnumerable and this is provided with the methods at the bottom of the class.
As the Group class is generic it needs to be solidified to provide the specific functionality needed for this application and this is achieved with this class:
/// <summary> /// This class is responsible for building the specific a-z jump list. If grouping is required around /// a different classification then another implementation has to be written. /// This implementation is efficient in that there are only two loops through the data, one to create the /// Group list with the specific titles and then a second pass through the data itself. /// </summary> public class AGroupedVaultEntry : List<Group<VaultEntry>> { private static readonly string Groups = "#abcdefghijklmnopqrstuvwxyz"; /// <summary> /// Build and return a Grouped list based on the current VaultEntry Dictionary /// </summary> public AGroupedVaultEntry() { DataAccessLayer DAL = (Application.Current as App).DAL; // create the groups by simply iterating over the group string foreach (char c in Groups) { Group<VaultEntry> g = new Group<VaultEntry>(c.ToString(), new List<VaultEntry>()); this.Add(g); } List<Group<VaultEntry>> list = this; // now iterate over the VaultEntries and add them to the appropriate group foreach (VaultEntry vaultEntry in DAL.VaultEntries.Values) { // get the first char (lower case) of the entry string c = VaultEntry.GetCaptionGroup(vaultEntry); // calculate the index into the list int index = (c == "#") ? 0 : c[0] - 'a' + 1; // and add the entry list[index].Items.Add(vaultEntry); } } }
This class is designed to create and return a List of Group VaultEntries with each Group header being a letter of the alphabet (plus # for all others). It will then populate the Group Items with the actual VaultEntries based on their group Title (a-z) – this association is made using the GetCaptionGroup which has been added specifically to the VaultEntry class for this purpose:
/// <summary>
/// Get the first letter of the caption or #
/// </summary>
public static string GetCaptionGroup(VaultEntry Current)
{
char key = char.ToLower(Current.Caption[0]);
if (key < 'a' || key > 'z')
{
key = '#';
}
return key.ToString();
}
Because the Vault Dictionary is already sorted by the Caption then the code for adding the appropriate
entries is simplified and each VaultEntry can be added to the list in a single pass.
The LLS can be used as a standard list quite simply, this XAML will display the Vault Entries without any header decoration:
<toolkit:LongListSelector VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Margin="6,6" Name="longListSelectorVaultEntries" IsFlatList="False" IsBouncy="True" Loaded="OnLoaded" SelectionChanged="OnSelectionChanged"> <toolkit:LongListSelector.ItemTemplate> <DataTemplate> <Grid Background="Transparent"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Image Width="55" Source="/Toolkit.Content/LOCKED.png"
VerticalAlignment="Top"/> <StackPanel Grid.Column="1" VerticalAlignment="Top"> <TextBlock Text="{Binding Caption}" Style="{StaticResource PhoneTextLargeStyle}" Margin="0,8,0,0"/> </StackPanel> </Grid> </DataTemplate> </toolkit:LongListSelector.ItemTemplate> </toolkit:LongListSelector>
The TextBlock.Text is bound to the Caption in the Vault Entry it is currently iterating over. I’ll show later that multiple items can be displayed here.
There are two events defined – OnLoaded which is used to fetch and display the current Vault Entry list and OnSelectionChanged which is used to capture the user selecting an entry for display/editing.
The OnLoaded event looks like this:
private void OnLoaded(object sender, RoutedEventArgs e) { DataAccessLayer DAL = (Application.Current as App).DAL; // when the page is being resumed this event will fire but it's not // appropriate to display the info as we actually want to hide it. if (DAL.ResumeFromDeactivation) { return; } // if the key has changed then reload the value entries if (DAL.ListReloadRequired || longListSelectorVaultEntries.ItemsSource == null) { ShowVault(); DAL.ListReloadRequired = false; } }
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) { // edit the selected item VaultEntry entry = (VaultEntry)longListSelectorVaultEntries.SelectedItem; if (entry == null) return; longListSelectorVaultEntries.SelectedItem = null; // pass the get to NewItem so that it can be looked up this.NavigationService.Navigate(new Uri(string.Format("/EditEntry.xaml?type=edit&key={0}", entry.Caption),
UriKind.Relative)); }
<toolkit:LongListSelector VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Margin="6,6" Name="longListSelectorVaultEntries" IsFlatList="False" IsBouncy="True" Loaded="OnLoaded" SelectionChanged="OnSelectionChanged"> <toolkit:LongListSelector.GroupItemsPanel> <ItemsPanelTemplate> <toolkit:WrapPanel Orientation="Horizontal"/> </ItemsPanelTemplate> </toolkit:LongListSelector.GroupItemsPanel> <toolkit:LongListSelector.GroupItemTemplate> <DataTemplate> <Border Background="{Binding GroupBackgroundBrush}" Width="99" Height="99" Margin="6" IsHitTestVisible="{Binding HasItems}"> <TextBlock Text="{Binding Title}" Style="{StaticResource PhoneTextExtraLargeStyle}" Margin="{StaticResource PhoneTouchTargetOverhang}" Foreground="{StaticResource PhoneForegroundBrush}" VerticalAlignment="Bottom"/> </Border> </DataTemplate> </toolkit:LongListSelector.GroupItemTemplate> <toolkit:LongListSelector.GroupHeaderTemplate> <DataTemplate> <Border Background="Transparent"> <Border Background="{StaticResource PhoneAccentBrush}" Width="55" Height="55" HorizontalAlignment="Left"> <TextBlock Text="{Binding Title}" Foreground="{StaticResource PhoneForegroundBrush}" Style="{StaticResource PhoneTextLargeStyle}" VerticalAlignment="Bottom"/> </Border> </Border> </DataTemplate> </toolkit:LongListSelector.GroupHeaderTemplate> <toolkit:LongListSelector.ItemTemplate> <DataTemplate> <Grid Background="Transparent"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Image Width="55" Source="/Toolkit.Content/LOCKED.png" VerticalAlignment="Top"/> <StackPanel Grid.Column="1" VerticalAlignment="Top"> <TextBlock Text="{Binding Caption}" Style="{StaticResource PhoneTextLargeStyle}" Margin="0,8,0,0"/> <TextBlock Text="{Binding Identity}" Style="{StaticResource PhoneTextNormalStyle}" Foreground="DarkGray"/> <TextBlock Text="{Binding OtherInfo}" Style="{StaticResource PhoneTextNormalStyle}" Foreground="DarkGray"/> </StackPanel> </Grid> </DataTemplate> </toolkit:LongListSelector.ItemTemplate> <!--<toolkit:LongListSelector.ListFooterTemplate> <DataTemplate> <Border Background="Transparent" Height="159"> <TextBlock Text="End..." /> </Border> </DataTemplate> </toolkit:LongListSelector.ListFooterTemplate>--> </toolkit:LongListSelector>
<toolkit:LongListSelector.ListFooterTemplate> <DataTemplate> <Border Background="Transparent" Height="159"> <TextBlock Text="End..." /> </Border> </DataTemplate> </toolkit:LongListSelector.ListFooterTemplate>
This still isn’t perfect but at least it does seem to hide the problem somewhat.
All in all the Long List Selector is a great control that adds a lot of functionality to the mundane list control.
Oh – I forgot to show that the LLS can show more than lists – you will have seen this already in the LLS in the Toolkit example. All you need to do is add the extra info to the StackPanel:
<StackPanel Grid.Column="1" VerticalAlignment="Top"> <TextBlock Text="{Binding Caption}" Style="{StaticResource PhoneTextLargeStyle}" Margin="0,8,0,0"/> <TextBlock Text="{Binding Identity}" Style="{StaticResource PhoneTextNormalStyle}" Foreground="DarkGray"/> <TextBlock Text="{Binding OtherInfo}" Style="{StaticResource PhoneTextNormalStyle}" Foreground="DarkGray"/> </StackPanel>
Page Transitions
This is a quick note on getting page transitions to work. Animation is a subtle but key feature of Windows Phone apps and I wanted to experiment with the process. It turned out to be pretty simple…
The Windows Phone Toolkit on Codeplex (http://silverlight.codeplex.com/releases/view/75888) provides some really useful components, one of which is called Transitions.
The toolkit is easy to install and is referenced in pages by adding this to the header:
xmlns:toolkit="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone.Controls.Toolkit"
In order to use it this XAML is added to the start of each page (just before the page layout)…
<toolkit:TransitionService.NavigationInTransition> <toolkit:NavigationInTransition> <toolkit:NavigationInTransition.Backward> <toolkit:TurnstileTransition Mode="BackwardIn"/> </toolkit:NavigationInTransition.Backward> <toolkit:NavigationInTransition.Forward> <toolkit:TurnstileTransition Mode="ForwardIn"/> </toolkit:NavigationInTransition.Forward> </toolkit:NavigationInTransition> </toolkit:TransitionService.NavigationInTransition> <toolkit:TransitionService.NavigationOutTransition> <toolkit:NavigationOutTransition> <toolkit:NavigationOutTransition.Backward> <toolkit:TurnstileTransition Mode="BackwardOut"/> </toolkit:NavigationOutTransition.Backward> <toolkit:NavigationOutTransition.Forward> <toolkit:TurnstileTransition Mode="ForwardOut"/> </toolkit:NavigationOutTransition.Forward> </toolkit:NavigationOutTransition> </toolkit:TransitionService.NavigationOutTransition>
The XAML manages the animation of the page as it is entered and exited. The Mode attribute controls the animation style – in this case its called ‘Turnstyle’.
In order to make the process work the RootFrame style has to be changed in App.xmal.cs:
private void InitializePhoneApplication() { if (phoneApplicationInitialized) return; // Create the frame but don't set it as RootVisual yet; this allows the splash // screen to remain active until the application is ready to render. //RootFrame = new PhoneApplicationFrame(); RootFrame = new TransitionFrame(); RootFrame.Navigated += CompleteInitializePhoneApplication; // Handle navigation failures RootFrame.NavigationFailed += RootFrame_NavigationFailed; // Ensure we don't initialize again phoneApplicationInitialized = true; }
That’s it!