This is part 5 of a series of blog posts on the design and implementation of the location-aware Windows Store app "Location Data Logger". Download the source code to Location Data Logger here.
The Data Grid View
As useful as the map display is sometimes you just want to be able to look at your raw data, and Location Data Logger makes this possible via its data grid view. Here you can see your logged data points and scroll through their history, in addition to displaying attributes such as speed and heading that just aren't easily visualized on the map. This blog post discusses the design of the table view, and the data bindings used to display the data in it.
Creating the table
The first step in coding this data grid view, however, was coming to terms with the fact that the Windows* Runtime does not have a widget for this purpose.
Windows 8 can display collections of items, of course, but that interface is not oriented like a traditional table: it doesn't have data columns in discreet rows. The grid view is designed to display tiles-- basically, summary content-- which you click or touch to either expand or open as a new page. While it is arguably useful for rolling up multiple pieces of independant, unqiue content into a concise display, it is not at all useful for an actual grid of data like a table or spreadsheet, and it is not appropriate for displaying our logged points in Location Data Logger. At least, not if you want to be able to quickly skim the data and see what changes from track point to track point.
There are third-party libraries in the wild that actually do provide a traditional table view, but I wanted to limit the number of add-ons and libraries that were required for Location Data Logger in order to simplify the build process as well as the legalities around its distribution, and that meant rolling my own solution using Grid and ListView elements.
The XAML for the table structure is shown below:
<Grid x:Name="gridData" Grid.Row="1" Opacity="0" Visibility="Collapsed"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <Grid Grid.Row="0" Margin="0,15,0,0" Background="#FF999999"> <Grid.ColumnDefinitions> <ColumnDefinition Width="200"/> <ColumnDefinition Width="100"/> <ColumnDefinition Width="100"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" Text="Timestamp (UTC)" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="1" Text="Latitude" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="2" Text="Longitude" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="3" Text="Accuracy" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="4" Text="Altitude" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="5" Text="Altitude Accuracy" TextWrapping="Wrap" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="6" Text="Speed" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="7" Text="Heading" Style="{StaticResource ColumnHeader}"/> <TextBlock Grid.Column="8" Text="Precision" Style="{StaticResource ColumnHeader}"/> </Grid> <Border Grid.Row="1" BorderThickness="1" BorderBrush="#FF999999"> <ListView x:Name="listPoints" ScrollViewer.VerticalScrollMode="Enabled" ScrollViewer.VerticalScrollBarVisibility="Visible" ItemContainerStyle="{StaticResource DataGridStyle}" ItemsSource="{Binding}" SelectionMode="None" IsItemClickEnabled="False" IsDoubleTapEnabled="False"> <ListView.ItemTemplate> <DataTemplate> <Grid VerticalAlignment="Top"> <Grid.ColumnDefinitions> <ColumnDefinition Width="200"/> <ColumnDefinition Width="100"/> <ColumnDefinition Width="100"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" Text="{Binding Timestamp}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="1" Text="{Binding Latitude, Converter={StaticResource fs}, ConverterParameter={0:F6}}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="2" Text="{Binding Longitude, Converter={StaticResource fs}, ConverterParameter={0:F6}}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="3" Text="{Binding Accuracy}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="4" Text="{Binding Altitude}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="5" Text="{Binding AltitudeAccuracy}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="6" Text="{Binding Speed}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="7" Text="{Binding Heading}" Style="{StaticResource DataCell}"/> <TextBlock Grid.Column="8" Text="{Binding Precision}" Style="{StaticResource DataCell}"/> </Grid> </DataTemplate> </ListView.ItemTemplate> </ListView> </Border> <ToggleButton x:Name="toggleAutoscroll" Grid.Row="2" Content="Autoscroll" IsChecked="False"/> </Grid>
That's a lot of code, so let's break it down into chunks.
The general approach I chose was to create the data table using the Grid element. Each data cell in the table is a cell in the Grid. The table is itself inside of an enclosing grid with three rows: the header for the table goes in the first row, and the body of the table goes in the second row. There is a third row in this grid, too, which holds a toggle button for turning the autoscroll feature on and off. The height of the first and last rows are set to "Auto" so that the grid rows will automatically size to the height of their content. The middle row of the enclosing grid is set to a hieght of "*", which means that the data rows of the table it will fill up the remaining vertical space on the screen.
By placing the table header inside of its own Grid I can fix the header at the top of the table so that it is always visible even as the user scrolls through the data. A side effect of this, however, is that the widths of each cell have to be explicitly sized: because the header is separate from the rows of data, a width of "Auto" is just not feasible. The layout engine can only autosize based on the content of the headers, and can't account for differences in the column widths of the actual data rows.
There are two ways of solving this. The first is to dynamically size the Grid columns in the program as data points are added, and the second is to fix the cell widths using static values. The former is actually easier than it sounds, but for the purposes of simplicity I went with the latter. While it's certainly less flexible, it's not a huge issue since I have control over the font sizes, and what is displayed in the data cells. Thus I can choose column widths that are guaranteed to be large enough to display the data without wasting a great deal of screen space.
The body of the table is enclosed within a ListView element with vertical scrolling enabled. Use of ListView lets me bind a data object to a template via ListView.ItemTemplate and DataTemplate, but has yet another side effect: each table row has to be its own Grid element, because there can be only one child object inside of the template. Since the table header is already its own grid with fixed-size columns, though, I've already paid that price.
The data bindings
The use of a template inside the ListView element allows me to create static XAML that expands to a dynamic list of elements during execution. The DataTemplate is what maps variables in MainPage to the TextBlock elements inside the Grid. The first step for this sort of binding is to associate the ListView with the source object (for clarity, I have only listed the attributes necessary for the data binding below):
<ListView x:Name="listPoints" ItemsSource="{Binding}">
This creates an object named listPoints that is visible to the MainPage object, and binds its values to the ListView items. The listPoints object has a property called DataContext which defines the data elements. For this application, DataContext is set to an object of type ObservableCollection<Trackpoint>:
points = new ObservableCollection<Trackpoint>();
listPoints.DataContext = points;
This configures listPoints to hold a collection of Trackpoint objects. When the template is expanded in the UI, each item in the ListView will have a trackpoint associated with it. The expansion of the Trackpoint object to the template is defined in the DataTemplate element. Again, for clarity, I'll reduce that to just the TextBlock elements inside the Grid:
<TextBlock Grid.Column="0" Text="{Binding Timestamp}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="1" Text="{Binding Latitude, Converter={StaticResource fs}, ConverterParameter={0:F6}}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="2" Text="{Binding Longitude, Converter={StaticResource fs}, ConverterParameter={0:F6}}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="3" Text="{Binding Accuracy}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="4" Text="{Binding Altitude}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="5" Text="{Binding AltitudeAccuracy}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="6" Text="{Binding Speed}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="7" Text="{Binding Heading}" Style="{StaticResource DataCell}"/>
<TextBlock Grid.Column="8" Text="{Binding Precision}" Style="{StaticResource DataCell}"/>
For most of these items, the data binding is just a simple binding to a property of the Trackpoint object, such as the timestamp, speed, heading, and so on. For the Latitude and Longitude properties, however, I employ a converter.
The purpose of the converter is to change the defaul display behavior of the data that is printed. The Latitude and Longitude properties are both floating point values, and by default they get displayed in exponential notation which can be difficult to read at-a-glance. The converter is used to change the default formatting using the "F6" converter to String.Format, which prints the number followed by six decimal places which is sufficient for this purpose (a degree of longitude at the equator is about 111 km, so .0000001 degrees corresponds to just over 0.1 meter which is more precision than a consumer-grade GNSS can prdouce).
The converter function is defined by linking a namespace key to a class definition:
<local:FormatString x:Key="fs"/>
And the convert function itself is in FormatString.cs.
public object Convert(object value, Type type, object param, String lang) { String fmt = param as String; // use "as String" so fmt= NULL if param is not a string CultureInfo culture; if ( String.IsNullOrEmpty(fmt) ) return value.ToString(); culture = new CultureInfo(lang); if (culture != null) return String.Format(culture, fmt, value); return String.Format(fmt, value); }
The format string is passed as the parameter object to the function, and the function uses that to call String.Format.
All that's left now is to add items to the listPoints object as track points are logged. This takes place inside of the update_position callback. Remember, the DataContext for listPoints is set to the points collection, so we add each trackpoint to points. I also automatically scroll to the bottom of the table if autoscrolling has been turned out.
if (logged)
{
// Add the point to our table
points.Add(t);
...
if ((Boolean)toggleAutoscroll.IsChecked)
{
listPoints.UpdateLayout();
listPoints.ScrollIntoView(listPoints.Items.Last());
}
}
← Part 4: Bing Maps Integration | Part 6: The Export Class → |