17 maja 2016

Wzbogacanie kontrolek WPF #1

WPF w połączeniu z MVVM to wspaniała sprawa. Co natomiast zrobić, jak interfejs WPF nie daje tego, co daje interfejs WinForms? W tym poście opisuję wzbogacanie kontrolek WPF.

Przykładowa aplikacja w technologii WPF + MVVM

Rozważmy następującą aplikację WPF + MVVM wykorzystującą MVVM Light Toolkit (opens new window).

<Window x:Class="Mvvm.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:ignore="http://www.galasoft.ch/ignore"
        xmlns:extenders="clr-namespace:Mvvm.Extenders"
        mc:Ignorable="d ignore"
        Height="300"
        Width="300"
        Title="Auto scroll demo"
        DataContext="{Binding Main, Source={StaticResource Locator}}">
    <Window.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Skins/MainSkin.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Window.Resources>
    <DockPanel>
        <Button DockPanel.Dock="Top"
                Content="add item"
                Margin="10"
                Command='{Binding AddItemClick}'/>
        <ListBox Margin="10"
                 ItemsSource="{Binding DataForList}" />

    </DockPanel>
</Window>
using System.Collections.ObjectModel;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.Command;

namespace Mvvm.ViewModel
{
    public class MainViewModel : ViewModelBase
    {
        public RelayCommand AddItemClick { get; private set; }
        public ObservableCollection<string> DataForList { get; }

        public MainViewModel()
        {
            AddItemClick = new RelayCommand(AddNewItemToList);
            DataForList =
                new ObservableCollection<string> {
                    "item_1",
                    "item_2"
                    };
        }

        private void AddNewItemToList()
        {
            DataForList.Add($"item_{DataForList.Count+1}");
        }
    }
}

Chcemy, aby kontrolka Listbox automatycznie zawsze pokazywała ostatnio dodany element. Załóżmy, że wyświetlamy logi i chcemy widzieć zawsze najnowsze. Jak to zrobić?

W WinForms jest to trywialne zadanie, ale w WPF niekoniecznie. Interfejs XAML nie zawiera takiej opcji. Możemy napisać ten kawałek logiki prezentacji w code behind. Problemem jest to, że im więcej umieszczamy w code behind, tym bardziej odchodzimy od MVVM w stronę klasycznej WinForms. A my chcemy WPF + MVVM zamiast WinForms.

Rozszerzenie możliwości kontrolki ListBox

Rozszerzenie do WPF, pozwala nam dodać do kontrolek właściwości, jakich nie posiadają.

<ListBox Margin="10" 
         ItemsSource="{Binding DataForList}" 
         extenders:ListBoxExt.ScrollToLast="True"/>
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;

namespace Mvvm.Extenders
{
    public class ListBoxExt : DependencyObject
    {
        public static readonly DependencyProperty ScrollToLastProperty =
            DependencyProperty.RegisterAttached("ScrollToLast",
                typeof(bool),
                typeof(ListBoxExt),
                new UIPropertyMetadata(default(bool), OnScrollToLastChanged));

        private static readonly Dictionary<object, ListBox> ListBoxMap = new Dictionary<object, ListBox>();

        private static void CollectionChanged(object colection, NotifyCollectionChangedEventArgs eventArgs)
        {
            var listBox = ListBoxMap[colection];
            var listBoxItems = listBox.Items;

            if (listBox.Items.Count <= 0) return;
            var lastItem = listBox.Items[listBox.Items.Count - 1];
            listBoxItems.MoveCurrentTo(lastItem);
            listBox.ScrollIntoView(lastItem);
        }

        public static void OnScrollToLastChanged(DependencyObject s, DependencyPropertyChangedEventArgs e)
        {
            Debug.Assert(s is ListBox, "DependencyObject is not of ListBox type");
            var listBox = (ListBox) s;

            var sourceCollection = listBox.Items.SourceCollection as INotifyCollectionChanged;
            Debug.Assert(sourceCollection != null, "sourceCollection problem");

            if ((bool) e.NewValue)
            {
                ListBoxMap.Add(sourceCollection, listBox);
                sourceCollection.CollectionChanged += CollectionChanged;
                return;
            }

            ListBoxMap.Remove(sourceCollection);
            sourceCollection.CollectionChanged -= CollectionChanged;
        }

        public static bool GetScrollToLast(DependencyObject obj)
        {
            return (bool)obj.GetValue(ScrollToLastProperty);
        }

        public static void SetScrollToLast(DependencyObject obj, bool value)
        {
            obj.SetValue(ScrollToLastProperty, value);
        }
    }
}

Co widzimy na listingu powyżej:

  • definiujemy klasę dziedziczącą po DependencyObject.
  • określamy nazwę nowej właściwości, jej typ, typ, do jakiego obiektu możemy go podpiąć oraz metodę, jaka zostanie wywołana, gdy wartość zostanie zmieniona.
  • getter oraz setter dla naszej nowej właściwości. Oczywiście wszystko są to metody statyczne.
  • metoda, która wykona się, gdy nasza właściwość ulegnie zmianie. Sprawdzamy obiekt macierzysty, czy jest typu ListBox i jeśli tak, to dla kolekcji danych rejestrujemy funkcję obsługi zdarzenia zmiany w kolekcji. Ponieważ sama funkcja obsługi nie będzie miała dostępu do obiektu listy, a jedynie do skojarzonych z nią danych, to trzymamy w słowniku mapowanie.
wpf