Designers tend to come up ‘crazy’ (not standard!) ideas about the looks and the behavior of UI elements. Especially when you are building a UWP app and those designers have no experience in this platform (no offence).
- not scrollable with touch or mouse
- Up/Down buttons to ‘step through’ the list (disabled when respectively at begin or at end of list)
- the size of a step should be the height of the list (when possible)
- The selected item should always be aligned in the center of the control (when possible)
So, we started to build this ‘Step-Through ListView’ control.
The XAML part
<local:StepThroughListView Height="300" Width="400" HorizontalAlignment="Center" VerticalAlignment="Stretch" ItemsSource="{Binding Items, Mode=OneWay, ElementName=root}" SelectedItem="{Binding SelectedString, Mode=TwoWay, ElementName=root}" UpButtonText="{Binding UpText, ElementName=root}" DownButtonText="{Binding DownText, ElementName=root}" />
We only need 2 Buttons (in this case, we used HyperLink buttons as they are the closest to our final design) and 1 ListView. We place these controls in a Grid. The rows for the buttons get an auto height (the row will be just as heigh as the height of the content inside it) as where the ListView gets a * height so it can fill up all the remaining space.
<Grid Height="{Binding Height, ElementName=root}" Width="{Binding Width, ElementName=root}"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <VisualStateManager.VisualStateGroups> <!-- --> </VisualStateManager.VisualStateGroups> <HyperlinkButton x:Name="UpButton" Tapped="Up_Tapped" Content="{x:Bind UpButtonText, Mode=OneWay}" /> <ListView x:Name="ListView" Grid.Row="1" HorizontalAlignment="Stretch" ItemsSource="{Binding ItemsSource, ElementName=root}" SelectedItem="{x:Bind SelectedItem, Mode=TwoWay}" ScrollViewer.VerticalScrollBarVisibility="Hidden" ScrollViewer.VerticalScrollMode="Disabled" Loaded="ListView_Loaded" Unloaded="ListView_Unloaded" SelectionChanged="ListView_SelectionChanged"/> <HyperlinkButton x:Name="DownButton" Tapped="Down_Tapped" Grid.Row="2" Content="{x:Bind DownButtonText, Mode=OneWay}" /> </Grid>
<VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="UpButtonState"> <VisualState x:Name="UpButtonDisabled"> <VisualState.Setters> <Setter Target="UpButton.IsEnabled" Value="false" /> </VisualState.Setters> </VisualState> <VisualState x:Name="UpButtonEnabled"> <VisualState.Setters> <Setter Target="UpButton.IsEnabled" Value="true" /> </VisualState.Setters> </VisualState> </VisualStateGroup> <VisualStateGroup x:Name="DownButtonState"> <VisualState x:Name="DownButtonDisabled"> <VisualState.Setters> <Setter Target="DownButton.IsEnabled" Value="false" /> </VisualState.Setters> </VisualState> <VisualState x:Name="DownButtonEnabled"> <VisualState.Setters> <Setter Target="DownButton.IsEnabled" Value="true" /> </VisualState.Setters> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups>
That’s everything about our Buttons in XAML, let’s take a closer look at the ListView:
<ListView x:Name="ListView" Grid.Row="1" HorizontalAlignment="Stretch" ItemsSource="{Binding ItemsSource, ElementName=root}" SelectedItem="{x:Bind SelectedItem, Mode=TwoWay}" ScrollViewer.VerticalScrollBarVisibility="Hidden" ScrollViewer.VerticalScrollMode="Disabled" Loaded="ListView_Loaded" Unloaded="ListView_Unloaded" SelectionChanged="ListView_SelectionChanged"/>
The SelectedItem property is also bound to a property on the control: SelectedItem of type object. Again, a Dependency property. Mind that we bind this ‘TwoWay’. This means that not only when the value of the SelectedItem property on the UserControl changes, it is reflected to the SelectedItem of the ListView. But also when the SelectedItem changes on the ListView (through user interaction), the value of the SelectedItem property on our UserControl changes as well. This means that both SelectedItem properties are always in sync. And that is exactly what we want.
The C#part
public IEnumerable ItemsSource { get { return (IEnumerable)GetValue(ItemsSourceProperty); } set { SetValue(ItemsSourceProperty, value); RaisePropertyChanged(); } } public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(nameof(ItemsSource), typeof(IEnumerable), typeof(StepThroughListView), new PropertyMetadata(DependencyProperty.UnsetValue)); public object SelectedItem { get { return GetValue(SelectedItemProperty); } set { if (SelectedItem != value) { SetValue(SelectedItemProperty, value); RaisePropertyChanged(); } } } public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.Register(nameof(SelectedItem), typeof(object), typeof(StepThroughListView), new PropertyMetadata(DependencyProperty.UnsetValue)); public string UpButtonText { get { return (string)GetValue(UpButtonTextProperty); } set { SetValue(UpButtonTextProperty, value); RaisePropertyChanged(); } } public static readonly DependencyProperty UpButtonTextProperty = DependencyProperty.Register(nameof(UpButtonText), typeof(string), typeof(StepThroughListView), new PropertyMetadata("Up")); public string DownButtonText { get { return (string)GetValue(DownButtonTextProperty); } set { SetValue(DownButtonTextProperty, value); RaisePropertyChanged(); } } public static readonly DependencyProperty DownButtonTextProperty = DependencyProperty.Register(nameof(DownButtonText), typeof(string), typeof(StepThroughListView), new PropertyMetadata("Down"));
Much logic of this UserControl relies on the scrolling offset of the ScrollViewer inside our ListView. So we need to get a hold on that ScrollViewer. I’ve got this static method ‘GetVisualChild’ that I use in many UWP projects.
static T GetVisualChild<T>(DependencyObject parent) where T : DependencyObject { T child = default(T); int numVisuals = VisualTreeHelper.GetChildrenCount(parent); for (int i = 0; i < numVisuals; i++) { DependencyObject v = VisualTreeHelper.GetChild(parent, i); child = v as T; if (child == null) child = GetVisualChild<T>(v); if (child != null) break; } return child; }
private void ListView_Loaded(object sender, RoutedEventArgs e) { _InternalListScrollViewer = GetVisualChild<ScrollViewer>((DependencyObject)sender); // }
private void ListView_Loaded(object sender, RoutedEventArgs e) { _InternalListScrollViewer = GetVisualChild<ScrollViewer>((DependencyObject)sender); VerticalOffsetPropertyCallbackToken = _InternalListScrollViewer.RegisterPropertyChangedCallback( ScrollViewer.VerticalOffsetProperty, OnVerticalOffsetChanged); ScrollableHeightPropertyCallbackToken = _InternalListScrollViewer.RegisterPropertyChangedCallback( ScrollViewer.ScrollableHeightProperty, OnVerticalOffsetChanged); } private void ListView_Unloaded(object sender, RoutedEventArgs e) { if(_InternalListScrollViewer != null) { _InternalListScrollViewer.UnregisterPropertyChangedCallback( ScrollViewer.VerticalOffsetProperty, VerticalOffsetPropertyCallbackToken); _InternalListScrollViewer.UnregisterPropertyChangedCallback( ScrollViewer.ScrollableHeightProperty, ScrollableHeightPropertyCallbackToken); } }
void SetUpAndDownButtons() { if (_InternalListScrollViewer.VerticalOffset == 0) //vertical offset 0 => at the top of the scrollviewer VisualStateManager.GoToState(this, nameof(UpButtonDisabled), false); else VisualStateManager.GoToState(this, nameof(UpButtonEnabled), false); if (_InternalListScrollViewer.VerticalOffset + _InternalListScrollViewer.ActualHeight < _InternalListScrollViewer.ExtentHeight) VisualStateManager.GoToState(this, nameof(DownButtonEnabled), false); else VisualStateManager.GoToState(this, nameof(DownButtonDisabled), false); }
private void Down_Tapped(object sender, TappedRoutedEventArgs e) { ScrollListVertical(shouldScrollDow: true); } private void Up_Tapped(object sender, TappedRoutedEventArgs e) { ScrollListVertical(shouldScrollDow: false); } private void ScrollListVertical(bool shouldScrollDow) { var height = ListView.ActualHeight; if (!shouldScrollDow) height *= -1; _InternalListScrollViewer.ScrollToVerticalOffset( _InternalListScrollViewer.VerticalOffset + height); }
My AlignSelectedItem method takes care of this:
private void AlignSelectedItem() { var index = ListView.SelectedIndex; double? itemHeight = ((FrameworkElement)ListView.ContainerFromIndex(index))?.ActualHeight; if (itemHeight.HasValue) { var itemsInView = _InternalListScrollViewer.ActualHeight / itemHeight.Value; var topOffset = (itemsInView - 1) * itemHeight.Value / 2; _InternalListScrollViewer.ScrollToVerticalOffset( (index * itemHeight.Value) - topOffset); } else if(index != -1) { //There is an item selected in the ListView, but it isn't rendered yet. //Hook up to ChoosingItemContainer event to try again... ListView.ChoosingItemContainer += ListView_ChoosingItemContainer; } }
This method calculates to the vertical offset to scroll to so that the selected item in right in the center.
In this method I look up the height of the element that is selected. I need this height to do my calculation. Now, it is perfectly possible that the SelectedItem is set, but the items in the ListView aren’t rendered yet. In that case, I hook up to the ChoosingItemContainer event and try again.
This AlignSelectedItem method gets called whenever the user selects an item in the ListView, so on the SelectionChanged event, and whenever the size of my control changes.
And with this, we have our entire Step-Through ListView control!
Thoughts
Ideally this controls should be a TemplatedControl so that you can change the ControlTemplate easily to fit your scenario. That’s maybe something for a next blogpost …
Links
May 22, 2016 at 8:27 am
Thank you for sharing this I need the Align selected item functionality. But the link is not working
and I’m missing some information could you please reactivate it.
May 23, 2016 at 9:27 am
Hi Marijn,
Thanks for your comment. I’ll update the link to the code in just a minute.
If you have any further questions, you can always drop a comment.
May 24, 2016 at 9:23 pm
Thanks is working now!
March 9, 2017 at 7:12 am
Great sample – thanks. I am trying to create my first UWP user control so this has helped a lot, especially around Dependency Properties where I struggle. What I don’t understand is when the value is changed in the control how do you know it’s changed in the main page? There is no SelectionChanged type event raised anywhere (that I can see anwyay) so if your main page has to do some action after the value has been selected or changed in the control, how would it know?
My background is WPF (and previously WinForms) where I would just expose a public Event in the control and subscribe to it from the main page – is this still a valid way of doing it or am I missing something with the – i.e: does the RaiseChangedEvent do this?
March 19, 2019 at 8:55 am
what a fantastic blog and informative posts, thank you