KatsuYuzuのブログ

.NET の開発をメインとした日記です。

ファイルのドラッグ&ドロップをビヘイビアーで。

このエントリは、Silverlight Advent Calendar 2011 : ATNDの 20日目のエントリです。前日のエントリは、@tanaka_733さんの12月 2011 Archives - 銀の光と碧い空です。

ファイルのドラッグ&ドロップをビヘイビアーで定義して簡単に扱えるようにしてみます。作成したサンプルプロジェクトは記事の最後に公開しています。

02:37 指摘を頂き修正いたしました。ビヘイビアーからVMへの通知をプロパティを通して行っていましたがCommandを使うようにしました。揮発性のオブジェクトをVMが保持しているのはおかしいとの指摘でした。@ugaya40さん、ありがとうございます!

はじめに

要件は下記のような感じです。

  • 分離を意識(MVVM)
  • 説明用のTextを表示
  • ドラッグ時にハイライト表示
  • ドロップ時にViewModelに通知

完成図

起動直後の画面です。

ドラッグするとハイライトします。

ドロップすると画像が表示されます。

ビヘイビアーの作成

まず、要件の「説明用Text」と「ハイライト」を満たすために、Backgroundプロパティのある背景コントロールとForegroundプロパティのある前景コントロールとして、それぞれBorderとTextBlockを採用することにしました。Borderはビヘイビアーの親オブジェクト(AssociatedObject)、TextBlockは依存関係プロパティとして扱える形(Border.Contentよりはデザインがしやすいと思い)でビヘイビアーを作成しています。そのほか、ハイライトのときの色を設定するブラシとViewModelへ通知するためのコマンドを依存関係プロパティとして定義しました。

using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;
using System.Windows.Media;
using System.Windows.Input;
using System;

namespace FileDropBehaviorSample.Infrastructures
{
    public class FileDropBehavior : Behavior<Border>
    {

        private Brush _defaultBackground;
        private Brush _defaultForeground;

        #region "DependencyProperty"
        /// <summary>
        /// 親オブジェクトの背景をハイライトするときの Brush を取得または設定します。
        /// </summary>
        public Brush HighlightBackground
        {
            get { return (Brush)GetValue(HighlightBackgroundProperty); }
            set { SetValue(HighlightBackgroundProperty, value); }
        }
        public static readonly DependencyProperty HighlightBackgroundProperty =
            DependencyProperty.Register("HighlightBackground", typeof(Brush), typeof(FileDropBehavior), new PropertyMetadata(null));

        /// <summary>
        /// DescriptionTextBlock の前景をハイライトするときの Brush を取得または設定します。
        /// </summary>
        public Brush HighlightForeground
        {
            get { return (Brush)GetValue(HighlightForegroundProperty); }
            set { SetValue(HighlightForegroundProperty, value); }
        }
        public static readonly DependencyProperty HighlightForegroundProperty =
            DependencyProperty.Register("HighlightForeground", typeof(Brush), typeof(FileDropBehavior), new PropertyMetadata(null));

        /// <summary>
        /// ドロップ領域の説明用のテキストブロックを取得または設定します。
        /// </summary>
        public TextBlock DescriptionTextBlock
        {
            get { return (TextBlock)GetValue(DescriptionTextBlockProperty); }
            set { SetValue(DescriptionTextBlockProperty, value); }
        }
        public static readonly DependencyProperty DescriptionTextBlockProperty =
            DependencyProperty.Register("DescriptionTextBlock", typeof(TextBlock), typeof(FileDropBehavior), new PropertyMetadata(null));

        /// <summary>
        /// ドロップされたときに実行するコマンドを取得または設定します。
        /// </summary>
        public ICommand TargetCommand
        {
            get { return (ICommand)GetValue(TargetCommandProperty); }
            set { SetValue(TargetCommandProperty, value); }
        }
        public static readonly DependencyProperty TargetCommandProperty =
            DependencyProperty.Register("TargetCommand", typeof(ICommand), typeof(FileDropBehavior), new PropertyMetadata(null));
        #endregion

        #region "OnAttached / OnDetaching"
        protected override void OnAttached()
        {
            base.OnAttached();
            this.AssociatedObject.DragEnter += OnDragEnter;
            this.AssociatedObject.DragLeave += OnDragLeave;
            this.AssociatedObject.Drop += OnDrop;
        }
        protected override void OnDetaching()
        {
            this.AssociatedObject.DragEnter -= OnDragEnter;
            this.AssociatedObject.DragLeave -= OnDragLeave;
            this.AssociatedObject.Drop -= OnDrop;
            base.OnDetaching();
        }
        #endregion

        #region "OnEvent Methods"
        private void OnDragEnter(object sender, DragEventArgs e)
        {
            var backgroundBorder = sender as Border;
            ChangeBrush(backgroundBorder, DescriptionTextBlock);
        }

        private void OnDragLeave(object sender, DragEventArgs e)
        {
            var backgroundBorder = sender as Border;
            RestoreBrush(backgroundBorder, DescriptionTextBlock);
        }

        private void OnDrop(object sender, DragEventArgs e)
        {
            var backgroundBorder = sender as Border;
            RestoreBrush(backgroundBorder, DescriptionTextBlock);

            NotifyDrop(e.Data);
        }
        #endregion

        #region "Behavior Methods"
        /// <summary>
        /// 指定のコントロールの Brush をハイライト色に変更します。
        /// </summary>
        /// <param name="backgroundBorder">背景コントロール</param>
        /// <param name="foregroundTextBlock">前景コントロール</param>
        private void ChangeBrush(Border backgroundBorder, TextBlock foregroundTextBlock)
        {
            if (foregroundTextBlock != null)
            {
                // 設定値が無い場合にコントロールの反転色を設定する
                if (HighlightBackground == null) HighlightBackground = foregroundTextBlock.Foreground;
                if (HighlightForeground == null) HighlightForeground = backgroundBorder.Background;

                // 現在の値を保持させてからハイライト色を設定する
                if (_defaultForeground == null) _defaultForeground = foregroundTextBlock.Foreground;
                foregroundTextBlock.Foreground = HighlightForeground;
            }
            if (_defaultBackground == null) _defaultBackground = backgroundBorder.Background;
            backgroundBorder.Background = HighlightBackground;
        }

        /// <summary>
        /// 指定のコントロールの Brush を元の色に戻します。
        /// </summary>
        /// <param name="backgroundBorder">背景コントロール</param>
        /// <param name="foregroundTextBlock">前景コントロール</param>
        private void RestoreBrush(Border backgroundBorder, TextBlock foregroundTextBlock)
        {
            if (foregroundTextBlock != null) foregroundTextBlock.Foreground = _defaultForeground;
            backgroundBorder.Background = _defaultBackground;
        }

        /// <summary>
        /// ドロップされたファイルを通知します。
        /// </summary>
        /// <param name="data">ドロップイベントのデータ</param>
        private void NotifyDrop(IDataObject data)
        {
            if (TargetCommand == null) throw new InvalidOperationException("TargetCommand is null.");
            var files = data.GetData(DataFormats.FileDrop) as FileInfo[];
            if (files != null) TargetCommand.Execute(files);
        }
        #endregion
    }
}

TextBlockがない場合やハイライトのブラシがない場合でも背景色の反転などで動作するようにしてみました。

コマンドの作成

VM通知用のコマンドになるICommandの実装クラスを定義しておきます。

using System;
using System.Windows.Input;

namespace FileDropBehaviorSample.Infrastructures
{
    /// <summary>
    /// ICommand の必要最低限の実装。
    /// </summary>
    public class RelayCommand : ICommand
    {
        private Action<object> _execute;
        private Func<bool> _canExecute;

        #region "Constructor"
        /// <summary>
        /// 実行するメソッドと実行判断のメソッドを指定してコマンドを初期化します。
        /// </summary>
        /// <param name="execute">コマンドの起動時に呼び出されるメソッド</param>
        /// <param name="canExecute">現在の状態でこのコマンドを実行できるかどうかを判断するメソッド</param>
        public RelayCommand(Action<object> execute, Func<bool> canExecute)
        {
            _execute = execute;
            _canExecute = canExecute;
        }
        /// <summary>
        /// 実行するメソッドを指定して常に起動できる状態でコマンドを初期化します。
        /// </summary>
        /// <param name="execute">コマンドの起動時に呼び出されるメソッド</param>
        public RelayCommand(Action<object> execute)
            : this(execute, () => true)
        {
        }
        #endregion

        #region "ICommand"
        public event EventHandler CanExecuteChanged;

        public bool CanExecute(object parameter)
        {
            return _canExecute();
        }

        public void Execute(object parameter)
        {
            if (_canExecute()) _execute(parameter);
        }
        #endregion

        /// <summary>
        /// CanExecuteChanged イベントを発生させます。
        /// </summary>
        public void RaiseCanExecuteChanged()
        {
            var handler = CanExecuteChanged;
            if (handler != null)
            {
                handler(this, EventArgs.Empty);
            }
        }
    }
}

ViewModelの作成

次にViewModelです。

using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using FileDropBehaviorSample.Infrastructures;

namespace FileDropBehaviorSample.ViewModels
{
    public class MainPageViewModel
    {
        public MainPageViewModel()
        {
            ImageFileCollection = new ObservableCollection<ImageFileViewModel>();
        }

        public ObservableCollection<ImageFileViewModel> ImageFileCollection { get; private set; }

        private RelayCommand _remakeImageFileCollectionCommand;
        public RelayCommand RemakeImageFileCollectionCommand
        {
            get
            {
                if (_remakeImageFileCollectionCommand == null)
                {
                    _remakeImageFileCollectionCommand = new RelayCommand(RemakeImageFileCollection);
                }
                return _remakeImageFileCollectionCommand;
            }
        }

        private void RemakeImageFileCollection(object parameter)
        {
            var files = parameter as FileInfo[];
            if (files != null)
            {
                ImageFileCollection.Clear();

                var imagefiles = files.Where(x => x.Extension.Equals(".jpg") || x.Extension.Equals(".png"));
                foreach (var imagefile in imagefiles)
                {
                    ImageFileCollection.Add(new ImageFileViewModel(imagefile));
                }
            }
        }

    }
}

画像を表示するほうのViewModelはこんな感じに。

using System;
using System.IO;
using System.Windows.Media.Imaging;

namespace FileDropBehaviorSample.ViewModels
{
    public class ImageFileViewModel
    {
        public ImageFileViewModel(FileInfo file)
        {
            if (file == null) throw new ArgumentNullException("file");

            Name = file.Name;
            using (var filestream = file.OpenRead())
            {
                var bmp = new BitmapImage();
                bmp.SetSource(filestream);
                ImageSource = bmp;
            }
        }

        public string Name { get; private set; }
        public BitmapImage ImageSource { get; private set; }
    }
}

XAMLの定義

最後にXAMLです。BorderのAllowDropプロパティを必ず設定します。また、TextBlockはBorderのContentである必要がないようにビヘイビアーを作成しましたので割と自由に配置できるとおもいます。

<UserControl
    x:Class="FileDropBehaviorSample.Views.MainPage"
    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:i="http://schemas.microsoft.com/expression/2010/interactivity"
    xmlns:infra="clr-namespace:FileDropBehaviorSample.Infrastructures"
    xmlns:vm="clr-namespace:FileDropBehaviorSample.ViewModels"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400">

    <UserControl.DataContext>
        <vm:MainPageViewModel />
    </UserControl.DataContext>

    <Grid x:Name="LayoutRoot">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>
        <Border AllowDrop="True" Width="300" Background="Black" Grid.Column="0">
            <i:Interaction.Behaviors>
                <infra:FileDropBehavior
                    TargetCommand="{Binding RemakeImageFileCollectionCommand}"
                    HighlightBackground="Aqua"
                    HighlightForeground="White"
                    DescriptionTextBlock="{Binding ElementName=DescriptionTextBlock}"/>
            </i:Interaction.Behaviors>
            <TextBlock x:Name="DescriptionTextBlock" Text="画像ファイル(jpg, png)をドロップしてください。" Foreground="Red" HorizontalAlignment="Center" VerticalAlignment="Center"/>
        </Border>
        <ListBox ItemsSource="{Binding ImageFileCollection}" Grid.Column="1">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Image Source="{Binding ImageSource}" Stretch="Uniform" ToolTipService.ToolTip="{Binding Name}" Width="300" Height="200"/>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</UserControl>

ちなみに、BorderのBackgroundプロパティを指定しなかった場合にDragEnterイベントがContentの部分でしか発生しません(この場合はTextBlockの上)。回避策としてはBackgorundプロパティにTransparentを指定してください。この方法はScrollViewer上でマウスホイールが利かない場合などにも有効です。

おわりに

分離にあたって考え方を深めることも重要ですが、こういった小物をビヘイビアーやアクションで揃えていくこともかなり重要になります。小物でも何でも、是非、切り出してみてください。
ちなみに、ここで紹介した方法以外にはMVVM Light Toolkit - HomeのEventToCommandなどでも実現できると思います。

この記事を書くにあたって@okazukiさんのBehaviorの使い方についてコードサンプルを書きました - かずきのBlog@hatenaSilverlight 4で少し変った画像ビューワ?を作ってみた - かずきのBlog@hatenaを参考にさせていただきました。ありがとうございます!