Keep a label scrolling inside a list of boxes inside a ScrollViewer for as long as possible

I was presented with recently with a challenge. Here is a simplified version. Let’s say that in a WPF application, there is a ItemsControl inside a ScrollViewer control. The ItemsControl contains a list of boxes. In each, a centered label (TextBlock) must scroll in a particulary way within the contained box. The Scrolling Label must move to stay visible within the bounds of the containing box. This post is a summary of this approach using MVVM techniques with the ReactiveUI framework. Find the finished application code on GitHub at https://github.com/kgday/ScrollingLabel.

Let’s begin. Two screen shots of the finished application:

Scrolling Label Finished Application
The Finished Scrolling Label Application
Another Scrolling Label Application Image With the list scrolled down a little showing the label moved down.
Another Scrolling Label Application Image With the list scrolled down a little showing the label moved down.

The Program Infrastructure in a Nutshell

I am not going to layout everything here because you can go the the source code on GitHub to view there details. Let us give the basics to the application with the Model, View, ViewModel architecture.

The Models

public class Customer
    {
        public string Name { get; set; } = string.Empty;
        public int Level { get; set; }
    }

The ViewModels

The view model hierarchy for Customer:

    public class ViewModelBase : ReactiveObject, IViewModelBase
    {
    }

    //internal because the view uses the interface
    internal class CustomerViewModelBase : ViewModelBase, ICustomerViewModelBase
    {
        private string _name = string.Empty;
        private int _level;
        private readonly ObservableAsPropertyHelper<string> _levelDisplay;

        public CustomerViewModelBase()
        {
            _levelDisplay = this.WhenAnyValue(x => x.Level)
               .Select(level => $"Level {level}")
               .ToProperty(this, x => x.LevelDisplay);
        }

        public string Name { get => _name; set => this.RaiseAndSetIfChanged(ref _name, value); }
        public int Level { get => _level; set => this.RaiseAndSetIfChanged(ref _level, value); }
        public string LevelDisplay => _levelDisplay.Value;

        public void AssignFromModel(Customer model)
        {
            if (model is null)
            {
                throw new System.ArgumentNullException(nameof(model));
            }

            Name = model.Name;
            Level = model.Level;
        }
    }

    internal class CustomerListItemViewModel : CustomerViewModelBase, ICustomerListItemViewModel
    {
        public CustomerListItemViewModel() : base()
        {
        }
    }

And the view model for the customer list:

    public interface ICustomerListViewModel : IRoutableViewModel
    {
        ObservableCollection<ICustomerListItemViewModel> Customers { get; set; }
        ReactiveCommand<Unit, List<Customer>> Load { get; }
    }    
internal class CustomerListViewModel : ViewModelBase, ICustomerListViewModel
    {
        private ObservableCollection<ICustomerListItemViewModel> _customers = new ObservableCollection<ICustomerListItemViewModel>();

        public CustomerListViewModel(ICustomerService customerService, IScreen screen, Func<ICustomerListItemViewModel> customerViewModelFactory)
        {
            HostScreen = screen;
            Customers = new ObservableCollection<ICustomerListItemViewModel>();
            Load = ReactiveCommand.CreateFromObservable(() => customerService.GetCustomers());
            Load
                .Select(customers => customers.Select(customer =>
                {
                    var customerViewModel = customerViewModelFactory();
                    customerViewModel.AssignFromModel(customer);
                    return customerViewModel;
                }))
                .Subscribe(customerViewModels => Customers = new ObservableCollection<ICustomerListItemViewModel>(customerViewModels));
        }

        public ReactiveCommand<Unit, List<Customer>> Load { get; }

        public ObservableCollection<ICustomerListItemViewModel> Customers { get => _customers; set => this.RaiseAndSetIfChanged(ref _customers, value); }
        public string UrlPathSegment { get; } = "CustomerList";
        public IScreen HostScreen { get; }
    }

The Views

The following is the 2 main views that are important, the CustomerView and the CustomerLIstViewModel. First the CustomerView.

<local:CustomerViewBase x:Class="ScrollingLabel.Views.CustomerView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:ScrollingLabel.Views"
             xmlns:rxui="http://reactiveui.net"
             xmlns:designVM="clr-namespace:ScrollingLabel.Design.ViewModels;assembly=ScrollingLabel.Design"
                          d:DataContext="{d:DesignInstance Type=designVM:CustomerListItemViewModelDesign, IsDesignTimeCreatable=True}"
             mc:Ignorable="d" 
             Height="250" d:DesignWidth="800">
    <Border BorderBrush="black" BorderThickness="1" Margin="2">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <TextBlock Text="{Binding LevelDisplay}" Grid.Row="0" Grid.Column="2"  FontWeight="Bold" />
            <TextBlock x:Name="CustomerName" Text="{Binding Name}" Grid.Row="0" Grid.RowSpan="2"   FontSize="16"  VerticalAlignment="Top" Grid.ColumnSpan="3" TextAlignment="Center"/>
        </Grid>
    </Border>
</local:CustomerViewBase>

Then the CustomerListView:

<local:CustomerListViewBase x:Class="ScrollingLabel.Views.CustomerListView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:ScrollingLabel.Views"
             xmlns:rxui="http://reactiveui.net"
             xmlns:designVM="clr-namespace:ScrollingLabel.Design.ViewModels;assembly=ScrollingLabel.Design"
             d:DataContext="{d:DesignInstance Type=designVM:CustomerListViewModelDesign, IsDesignTimeCreatable=True}"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid>
        <ScrollViewer x:Name="CustomersScrollViewer" >
            <ItemsControl x:Name="CustomersListControl" ItemsSource="{Binding Customers}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <rxui:ViewModelViewHost ViewModel="{Binding}"  HorizontalContentAlignment="Stretch"/>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>

            </ItemsControl>
        </ScrollViewer>    
    </Grid>
</local:CustomerListViewBase>

Finally The Code for the Scrolling Label!

Finally we get to the solution. We use the properties of the ScrollViewer called VerticalContentOffset and ViewportHeight. One could put a handler on the ScrollChanged event. However we can do this reactively. ReactiveUI extensions, WhenanyValue() work with ReactiveObject and INotifyPropertyChanged properites. But they also with DependencyProperties. Thus any property that is backed by a DependencyProperty can be used with this.WhenAnyValue(). I use this to send an event to the items of the ItemsControl. The ItemsSource is set to the list of viewmodels, not the views. Thus, the views cannot be accessed directly. So in my CustomerList.xaml.cs backing code I do the following:

MessageBus
                .Current
                .RegisterMessageSource(
                   CustomersScrollViewer.WhenAnyValue(x => x.ContentVerticalOffset, x => x.ViewportHeight,
                       (cvo, vh) => new ScrollViewValues(cvo, vh, CustomersListControl))
                  );

The class ScrollViewValues is a simple class to group the pertinent values together. This includes a reference to the items control. The reference of the items control allows us to transform the coordinates of the items relative to a common control. Then each Customer View does the scrolling as per CustomerView.xaml.cs.

                CustomerName.Margin = CustomerNameMargin(DefaultTopMargin());

                MessageBus.Current.Listen<ScrollViewValues>()
                .Select(scrollViewValues => CalculateCustomerNameMargin(scrollViewValues))
                .Select(topMargin => CustomerNameMargin(topMargin))
                .BindTo(this, x => x.CustomerName.Margin)
                .DisposeWith(d);

To define the CalculateCustomerNameMargin:

private Thickness CustomerNameMargin(double topMargin)
        {
            return new Thickness(CustomerName.Margin.Left, topMargin, CustomerName.Margin.Right, CustomerName.Margin.Bottom);
        }

        //private double Actual

        const double _verticalPadding = 8.0;
        const double _bottomBuffer = 10.0;
        private double CalculateCustomerNameMargin(ScrollViewValues scrollViewValues)
        {
            var newMargin = DefaultTopMargin();

            if (scrollViewValues.ReferenceParent != null)
            {
                try
                {
                    var topOfCustomerBoxInParentControl = scrollViewValues.ReferenceParent.PointFromScreen(PointToScreen(new Point(0, 0))).Y;

                    if ((topOfCustomerBoxInParentControl + newMargin) < (scrollViewValues.ScrollVerticalOffset + _verticalPadding))
                        newMargin = PointFromScreen(scrollViewValues.ReferenceParent.PointToScreen(new Point(0, scrollViewValues.ScrollVerticalOffset + _verticalPadding))).Y;

                    //what if our calculated margin is below the bottom of the viewport
                    var bottomExtent = scrollViewValues.ScrollVerticalOffset + scrollViewValues.ViewPortHeight;
                    if ((topOfCustomerBoxInParentControl + newMargin) > (bottomExtent - CustomerName.ActualHeight - _bottomBuffer))
                        newMargin = PointFromScreen(
                            scrollViewValues.ReferenceParent.PointToScreen(new Point(0, (bottomExtent - CustomerName.ActualHeight - _bottomBuffer)))).Y;


                    //don't let us roll of the end of the folder view
                    if ((newMargin) > (ActualHeight - CustomerName.ActualHeight - _bottomBuffer))
                        newMargin = ActualHeight - CustomerName.ActualHeight - _bottomBuffer;

                    if (newMargin < _verticalPadding)
                        newMargin = _verticalPadding;
                }
                catch
                {

                }
            }

            return newMargin;
        }

        private double DefaultTopMargin()
        {
            return (ActualHeight - CustomerName.ActualHeight) / 2;
        }

And that is it! If you take a look at the source code, you will notice I have separated the code into a couple of projects. I have separated the view code from the core code as well as design view-models. This helps with designing the views. You might also note a .Ava project. I will talk about that in the next post.

Leave a Reply

Your email address will not be published. Required fields are marked *