1

I have a control that at its most basic level is a ScrollViewer with a StackPanel (Orientation=Vertical) with a lot of TextBoxes in it.

<ScrollViewer>
    <StackPanel x:Name="MyStackPanel"
                Orientation="Vertical">
        <TextBox Text="{Binding PropertyA, ValidatesOnDataErrors=True}" />
        <TextBox Text="{Binding PropertyB, ValidatesOnDataErrors=True}" />
        <TextBox Text="{Binding PropertyC, ValidatesOnDataErrors=True}" />
        <!-- ... -->
        <TextBox Text="{Binding PropertyX, ValidatesOnDataErrors=True}" />
        <TextBox Text="{Binding PropertyY, ValidatesOnDataErrors=True}" />
        <TextBox Text="{Binding PropertyZ, ValidatesOnDataErrors=True}" />
    </StackPanel>
</ScrollViewer>

I want to scroll any controls with an error into view when the error occurs. So for example, if the user is at the top of the list and the TextBox bound to PropertyX is in error, then I want the ScrollViewer to scroll to it.

Currently I've inherited from ScrollViewer and added the following methods.

    public void ScrollErrorTextBoxIntoView()
    {
        var controlInError = GetFirstChildControlWithError(this);

        if (controlInError == null)
        {
            return;
        }            
            controlInError.BringIntoView();
        }
    }

    public Control GetFirstChildControlWithError(DependencyObject parent)
    {
        if (parent == null)
        {
            return null;
        }

        Control findChildInError = null;

        var children = LogicalTreeHelper.GetChildren(parent).OfType<DependencyObject>();

        foreach (var child in children)
        {
            var childType = child as Control;
            if (childType == null)
            {
                findChildInError = GetFirstChildControlWithError(child);

                if (findChildInError != null)
                {
                    break;
                }
            }
            else
            {
                var frameworkElement = child as FrameworkElement;

                // If the child is in error
                if (Validation.GetHasError(frameworkElement))
                {
                    findChildInError = (Control)child;
                    break;
                }
            }
        }

        return findChildInError;
    }

I'm having difficulty getting it to work properly. The way I see it, I have two options.

  1. Attempt to get the ViewModel to execute the ScrollErrorTextBoxIntoView method. I'm not sure what the best way of doing that is. I was trying to set a property and acting from that but it didn't seem right (and it didn't work anyway)

  2. Have the control do it in a self-contained way. This would require my ScrollViewer to listen to its children (recursively) and call the method if any of them are in an error state.

So my questions are:

  1. Which one of those two options are better and how would you implement them?

  2. Is there a better way of doing this? (Behaviors etc?) It MUST be MVVM.

NB. The GetFirstChildControlWithError was adapted from this question. How can I find WPF controls by name or type?

Iain Holder
  • 14,172
  • 10
  • 66
  • 86

1 Answers1

1

Working under the following assumptions:

  • Your view model implements INotifyPropertyChanged correctly and IDataErrorInfo
  • The IDataErrorInfo.Error property is not null when at least one property has a validation error.
  • You want to maintain a strict M-VM separation; therefore the ViewModel should not invoke methods that are solely there to adjust the view.

Basically you want to listen to DataContext property changes and find out if a DataError exists.

If you look at behaviors, you can solve this without inheriting from ScrollViewer.

Here is a sample implementation:

using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;

public class ScrollToFirstInvalidElementBehavior : Behavior<ScrollViewer>
{
    protected override void OnAttached()
    {
        ResetEventHandlers(null, AssociatedObject.DataContext);
        AssociatedObject.DataContextChanged += OnDataContextChanged;
    }

    protected override void OnDetaching()
    {
        AssociatedObject.DataContextChanged -= OnDataContextChanged;
    }

    private void OnDataContextChanged(object sender, 
          DependencyPropertyChangedEventArgs e)
    {
        ResetEventHandlers(e.OldValue, e.NewValue);
    }

    private void ResetEventHandlers(object oldValue, object newValue)
    {
        var oldContext = oldValue as INotifyPropertyChanged;
        if (oldContext != null)
        {
            oldContext.PropertyChanged -= OnDataContextPropertyChanged;
        }

        var newContext = newValue as INotifyPropertyChanged;
        if (newContext is IDataErrorInfo)
        {
            newContext.PropertyChanged += OnDataContextPropertyChanged;
        }
    }

    private void OnDataContextPropertyChanged(object sender, 
         PropertyChangedEventArgs e)
    {
        var dataError = (IDataErrorInfo) sender;

        if (!string.IsNullOrEmpty(dataError.Error))
        {
            var controlInError = GetFirstChildControlWithError(AssociatedObject);
            if (controlInError != null)
            {
                controlInError.BringIntoView();
            }

        }
    }

    private Control GetFirstChildControlWithError(ScrollViewer AssociatedObject)
    {
        //...
    }
}
Bas
  • 26,772
  • 8
  • 53
  • 86
  • Thanks! This is great. I haven't managed to get it to work yet, but that could be because of the codebase I'm trying to crowbar it into. I will contintue to look at it though. I can deffo see the point and the behavior way is so much more elegant. – Iain Holder Oct 18 '13 at 15:55