Monday, April 14, 2014

Динамическая фокусировка элементов в WPF

Здравствуйте, уважаемые читатели моего блога. Давненько не писал тем о любимом WPF, пришло время опять к нему вернуться. На одном форуме по разработке программного обеспечения (ПО) прозвучал вопрос о том, как с помощью выбора значения со списка сделать фокусировку на нужный контрол. Основная проблема, которая возникала в данном контексте, – как это сделать, не нарушая паттерн MVVM, то есть чтобы в коде представления (View) было по минимуму логики. Вся логика должна, по возможности, быть в xaml-коде, и по минимуму – в файлах с расширением cs этих самых представлений. Давайте рассмотрим, как можно этого добиться. Для примера создадим WPF-приложение и назовем его FocusBehaviourExample.
Далее для самого просто примера создадим три текстовых поля для ввода данных, а также группу контролов RadioButton, которые будут переключать фокус на нужный контрол.
Xaml-код, который будет реализовать нашу логику, приведен ниже.
<Window x:Class="FocusBehaviourExample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <StackPanel Grid.Row="0" Orientation="Vertical">
                <TextBlock>Test1</TextBlock>
                <TextBox x:Name="oneControl" />
                <TextBlock>Test2</TextBlock>
                <TextBox x:Name="twoControl" />
                <TextBlock>Test3</TextBlock>
                <TextBox x:Name="threeControl" />
            </StackPanel>
            <StackPanel Grid.Row="1" Orientation="Vertical">
                <RadioButton GroupName="Test" Content="Test1" />
                <RadioButton GroupName="Test" Content="Test2" />
                <RadioButton GroupName="Test" Content="Test3" />
            </StackPanel>
        </Grid>
    </Grid>
</Window>
Внешний вид нашего окна имеет следующий вид:
Самая простая реализация основана на использовании события Checked. Для этого добавим для всех наших RadioButton событие Test_OnChecked и свойство Tag.
<StackPanel Grid.Row="1" Orientation="Vertical">
    <RadioButton GroupName="Test" Content="Test1" Tag="1" Checked="Test_OnChecked" />
    <RadioButton GroupName="Test" Content="Test2" Tag="2" Checked="Test_OnChecked" />
    <RadioButton GroupName="Test" Content="Test3" Tag="3" Checked="Test_OnChecked" />
</StackPanel>
Логикой, которую нам необходимо добавить в наш файл Window.cs, может быть что-то в этом духе:
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void Test_OnChecked(object sender, RoutedEventArgs e)
    {
        var rb = sender as RadioButton;
        if(rb == null)
            return;

        var tag = rb.Tag as string;
        switch (tag)
        {
            case "1":
                oneControl.Focus();
                break;
            case "2":
                twoControl.Focus();
                break;
            case "3":
                threeControl.Focus();
                break;
        }
    }
}
Это неудачный пример. Во-первых, из-за что жесткой привязки. Во-вторых, из-за использования свойства Tag, что является своего рода "хаком". Его использование рано или поздно приводит к несоответствию работы кода с ожидаемым результатом разработчика, и такие ошибки поведения бывает очень сложно найти. Можно использовать свойство несколько иначе, тогда наш код немного упростится.
<StackPanel Grid.Row="1" Orientation="Vertical">
    <RadioButton GroupName="Test" Content="Test1" Tag="oneControl" Checked="Test_OnChecked" />
    <RadioButton GroupName="Test" Content="Test2" Tag="twoControl" Checked="Test_OnChecked" />
    <RadioButton GroupName="Test" Content="Test3" Tag="threeControl" Checked="Test_OnChecked" />
</StackPanel>
Если внимательно посмотреть на код выше, то можно заметить, что в Tag мы добавили такое же название, как и контрол, на который мы хотим фокусироваться. Типичный "хак" и пример его использования приведен ниже.
private void Test_OnChecked(object sender, RoutedEventArgs e)
{
    var rb = sender as RadioButton;
    if(rb == null)
        return;

    var tag = rb.Tag as string;
    if(string.IsNullOrEmpty(tag))
        return;

    var parent = VisualTreeHelper.GetParent(rb);
    while (!(parent is Window))
    {
        parent = VisualTreeHelper.GetParent(parent);
    }

    var window = parent as Window;
    var control = (Control)window.FindName(tag);
    Keyboard.Focus(control);
}
В коде уже не фигурируют явно строковые значения; он стал компактнее и понятнее, но, все равно, неудачный из-за использованного свойства Tag. Эту проблему можно решить через Attached Properties. Это один из самых популярных способов, я очень часто к нему прибегаю. Для начала зарегистрируем новое Attached Property ElementToFocus. Если вы не знакомы с понятием Dependency Property, рекомендую посмотреть статью "Behaviors in WPF introduction".
public static class FocusAttachment
{
    public static Control GetElementToFocus(Control button)
    {
        return (Control)button.GetValue(ElementToFocusProperty);
    }

    public static void SetElementToFocus(Control target, Control value)
    {
        target.SetValue(ElementToFocusProperty, value);
    }

    public static readonly DependencyProperty ElementToFocusProperty =
        DependencyProperty.RegisterAttached("ElementToFocus", typeof(Control),
        typeof(FocusAttachment), new UIPropertyMetadata(null, ElementToFocusPropertyChanged));

    public static void ElementToFocusPropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var rb = sender as RadioButton;
        if (rb != null)
        {
            rb.Checked += (s, args) =>
            {
                var control = GetElementToFocus(rb);
                if (control != null)
                {
                    control.Focus();
                }
            };
        }
    }
}
Теперь перепишем наш xaml-код, чтобы он работал с этим Attached Property.
<StackPanel Grid.Row="1" Orientation="Vertical">
    <RadioButton GroupName="Test" local:FocusAttachment.ElementToFocus="{Binding ElementName=oneControl}"  Content="Test1" />
    <RadioButton GroupName="Test" local:FocusAttachment.ElementToFocus="{Binding ElementName=twoControl}" Content="Test2" />
    <RadioButton GroupName="Test" local:FocusAttachment.ElementToFocus="{Binding ElementName=threeControl}" Content="Test3" />
</StackPanel>
Минус этого подхода заключается только в том что мы подписываемся на события с помощью лямбда-выражения. Это неплохо, но если мы посмотрим, что ниже свойство объявлено как static, GC коллектор сборкой мусора займётся только когда на эту форму никто не будет ссылаться. Это может послужить одной из причин утечки памяти (Leak Memory). В противовес этому подходу я предпочитаю использовать Behaviours с библиотеки Interactivity. Как использовать эту библиотеку, мы сейчас и рассмотрим. Добавим сборку на необходимую библиотеку (Если у вас нет данной библиотеки, вероятнее всего, вам необходимо поставить Microsoft Expression Blend Software Development Kit (SDK) for .NET 4, так как это одна из необходимых и используемых библиотек для Expression Blend).

После того, как добавили ссылку на сборку System.Windows.Interactivity, создадим новый класс который назовем FocusElementBehavior.

public class FocusElementBehavior : Behavior<RadioButton>
{
    protected override void OnAttached()
    {
        AssociatedObject.Checked += AssociatedObjectOnChecked;
    }

    private void AssociatedObjectOnChecked(object sender, RoutedEventArgs routedEventArgs)
    {
        Keyboard.Focus(FocusElement);
    }

    protected override void OnDetaching()
    {
        AssociatedObject.Checked += AssociatedObjectOnChecked;
    }

    public Control FocusElement
    {
        get { return (Control)GetValue(FocusElementProperty); }
        set { SetValue(FocusElementProperty, value); }
    }

    public static readonly DependencyProperty FocusElementProperty =
        DependencyProperty.Register("FocusElement", typeof(Control), typeof(FocusElementBehavior), new UIPropertyMetadata());
}
Одно из преимуществ данного подхода заключается в том, что мы используем привязку к нашему объекту, в роли которого выступает контрол RadioButton с помощью OnAttached() метода, а отписаться от необходимых событий необходимо в методе OnDetaching(). Поэтому я отдаю такому подходу предпочтение перед классическим подходом с Attached Properties. Использовать это можно так:
<RadioButton GroupName="Test" Content="Test1" >
    <i:Interaction.Behaviors>
        <local:FocusElementBehavior FocusElement="{Binding ElementName=oneControl, Mode=OneWay}"/>
    </i:Interaction.Behaviors>
</RadioButton>
<RadioButton GroupName="Test" Content="Test2" >
    <i:Interaction.Behaviors>
        <local:FocusElementBehavior FocusElement="{Binding ElementName=twoControl, Mode=OneWay}"/>
    </i:Interaction.Behaviors>
</RadioButton>
<RadioButton GroupName="Test" Content="Test3">
    <i:Interaction.Behaviors>
        <local:FocusElementBehavior FocusElement="{Binding ElementName=threeControl, Mode=OneWay}"/>
    </i:Interaction.Behaviors>
</RadioButton>
Нужно будет добавить только ссылку на сборку Interactivity.
xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
        xmlns:local="clr-namespace:FocusBehaviourExample"
Мы с вами рассмотрели 4 способа, как можно разрулить варианты с фокусировкой по нажатию на копку. На этом варианты не исчерпываются. Как альтернативу, можно использовать DataTrigger + ViewModel. Для начала создадим enum, с помощью которого будем задавать порядок фокусировки, по аналогии с примером на stackoverflow.
public enum Focuses
{
    None = 0,
    First,
    Second,
    Third
}
Далее создадим нашу базовую модель.
public class MainViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private void OnPropertyChanged(string propertyName)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

    private Focuses _focusedElement;

    public Focuses FocusedElement
    {
        get { return _focusedElement; }
        set
        {
            _focusedElement = value;
            OnPropertyChanged("FocusedElement");
        }
    }

    public MainViewModel()
    {
        FocusedElement = Focuses.First;
    }
}
Осталось связать наш DataContext класса Window.xaml с нашей моделью представления – MainViewModel. Это делается так:
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        var vm = new MainViewModel();
        DataContext = vm;
    }
}
Теперь осталось подправить саму xaml-разметку. Но перед этим нужно добавить конвертор со строки в enum, так как RadioButton не дружит нормально с enum  (источник). С источника выше и взят нужный конвертор.
public class EnumBooleanConverter : IValueConverter
{
    #region IValueConverter Members
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        var parameterString = parameter as string;
        if (parameterString == null)
            return DependencyProperty.UnsetValue;

        if (Enum.IsDefined(value.GetType(), value) == false)
            return DependencyProperty.UnsetValue;

        object parameterValue = Enum.Parse(value.GetType(), parameterString);

        return parameterValue.Equals(value);
    }

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        string parameterString = parameter as string;
        if (parameterString == null)
            return DependencyProperty.UnsetValue;

        return Enum.Parse(targetType, parameterString);
    }
    #endregion
}
Теперь вернемся к нашей xaml-разметке.
<Window x:Class="FocusBehaviourExample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
        xmlns:local="clr-namespace:FocusBehaviourExample"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.Resources>
            <local:EnumBooleanConverter x:Key="enumBooleanConverter" />
        </Grid.Resources>
        <Grid.Style>
            <Style>
                <Style.Triggers>
                    <DataTrigger Binding="{Binding FocusedElement}" Value="First">
                        <Setter Property="FocusManager.FocusedElement" Value="{Binding ElementName=oneControl}"/>
                    </DataTrigger>
                    <DataTrigger Binding="{Binding FocusedElement}" Value="Second">
                        <Setter Property="FocusManager.FocusedElement" Value="{Binding ElementName=twoControl}"/>
                    </DataTrigger>
                    <DataTrigger Binding="{Binding FocusedElement}" Value="Third">
                        <Setter Property="FocusManager.FocusedElement" Value="{Binding ElementName=threeControl}"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </Grid.Style>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <StackPanel Grid.Row="0" Orientation="Vertical">
                <TextBlock>Test1</TextBlock>
                <TextBox x:Name="oneControl" />
                <TextBlock>Test2</TextBlock>
                <TextBox x:Name="twoControl" />
                <TextBlock>Test3</TextBlock>
                <TextBox x:Name="threeControl" />
            </StackPanel>
            <StackPanel Grid.Row="1" Orientation="Vertical">
                <RadioButton GroupName="Test" Content="Test1" IsChecked="{Binding Path=FocusedElement, Converter={StaticResource enumBooleanConverter}, ConverterParameter=First}" />
                <RadioButton GroupName="Test" Content="Test2" IsChecked="{Binding Path=FocusedElement, Converter={StaticResource enumBooleanConverter}, ConverterParameter=Second}"/>
                <RadioButton GroupName="Test" Content="Test3" IsChecked="{Binding Path=FocusedElement, Converter={StaticResource enumBooleanConverter}, ConverterParameter=Third}"/>
            </StackPanel>
        </Grid>
    </Grid>
</Window>
Фокусировку на этот раз мы задаем с помощью FocusManager.FocusedElement. Как с этим связаны наши RadioButton кнопки, можно посмотреть выше. После того, как мы запустим наш пример, можем убедиться в том, что у нас все заработало. На этой позитивной ноте закончу данную статью. Надеюсь, вы сможете применить полученные знания с этого материала в ваших приложениях. Буду рад услышать ваши варианты решения этой проблемы.

No comments:

Post a Comment