Iain Holder
Iain Holder

Reputation: 14282

Scrolling a WPF TextBox in error into view with MVVM

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?

Upvotes: 1

Views: 562

Answers (1)

Bas
Bas

Reputation: 27105

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)
    {
        //...
    }
}

Upvotes: 1

Related Questions