2010年2月26日金曜日

第17回 全部のタイムラインを表示するタイムラインを作る

今回のタイトルはちょっとわかりにくいかもしれませんが、
自分のタイムラインや検索のタイムラインごとにタブを切り替えるのがめんどうなので、
全部いっぺんに表示するタイムラインを作ります。

やることは簡単です。
複数のTimelineクラスを所持して、それらすべてのアイテムの追加を見張るクラスを作ります。
まず、ViewModelからはほぼ同一として扱うために
ITimelineというインターフェイスクラスを作ります。
これはこれまでのTimelineクラスのインターフェイスを切り出しただけなのでコード略。

新しく作ったMultiSourceTimeline 考え方はシンプルで、
持っているいくつかのTimelineクラスのすべてのItemAddedを拾って、
そのアイテムを自分のアイテムとして持つとともに、
ViewModelには自分のItemAddedイベントとしてそのままイベントを投げます。



 class MultiSourceTimeline : ITimeline {
  #region private member
  private UserInfo _info;
  private List<ITimelineItem> _allItems = new List<ITimelineItem>();
  private List<ITimeline> _sourceTimelines = new List<ITimeline>();
  #endregion

  public MultiSourceTimeline(IEnumerable<ITimeline> sources, UserInfo info) {
   _info = info;
   foreach (var timeline in sources) {
    AddTimeline(timeline);
   }
   ItemAdded += (sender, e) => {};
  }

  public MultiSourceTimeline(UserInfo info)
   : this(Enumerable.Empty<ITimeline>(), info){

  }

  public void AddTimeline(ITimeline target) {
   _sourceTimelines.Add(target);
   target.ItemAdded += this.OnItemAdded;
  }

  public void RemoveTimeline(ITimeline target) {
   _sourceTimelines.Remove(target);
   target.ItemAdded -= this.OnItemAdded;
  }

  private void OnItemAdded(object sender, ItemAddedEventArgs e) {
   _allItems.Add(e.Item);
   var handler = ItemAdded;
   handler(this, e);
  }

あとはMainWindowViewModelのInitialize()をちょっと工夫するだけです。
他のタイムラインを作った時に、MultiSourceTimeline にもそのタイムラインを追加しています。



  public void Initialize() {
・・・略
   //Modelの作成
   var allVM = new TimelineViewModel("All", _allItemTimeline);
   Timelines.Add(allVM);
   foreach (var setting in _option.TimelineSettings) {
    var vm = CreateFrom(setting);
    Timelines.Add(vm);
    if (vm.IsUpdatable) {
     vm.Update();
     vm.StartAutoUpdate();
    }
   }

   _submitPanel = new SubmitPanelViewModel(_userInfo);
   OnPropertyChanged("SubmitPanel");
  }



今回のソースまでにバグフィックスなど細かい点で
ソースコードに手を入れています。
個々は簡単な変更なので直接ソースコードを見てください。

今回のソースコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/41872

2010年2月23日火曜日

第16回 別Windowを表示する

個人的につぶやきの表示領域は大きくとりたいので、
テキスト入力領域は別窓で開くようにしたいと思います。

ここで、Webで情報収集をしていてよく見るのですが、
MVVMに忠実に従って作っていくと、(ViewModelがViewを意識しない)
新しいWindowを表示するのに苦戦している人が多いみたいですね。

というわけで、いきなり洗練された構造を作ろうとすると時間がかかってしかたない気がしますので、
今回は「とりあえず動けばいいや」くらいのスタンスでいきたいと思います。
Windowの表示機能が2,3パターンでてくると、のちのちよりよい構造が見えてくるかもしれません。

今回のポイントは以下
1.新しいWindowの生成は、ViewModelとViewにコネクタとなるクラスを作る
2.いろんなViewModelをWindowとして表示するための
汎用WindowのGeneralWindowクラスをつくる
そのクラスに渡すために、ViewModelの基本クラスClosableViewModelBaseクラスを作る

CloseableViewModelBaseは単純にClose要求を処理できるようにしたViewModelBaseです
本家MVVM文書のWorkspaceViewModelくらいの位置づけです。

 abstract public class ClosableViewModelBase : ViewModelBase {
  /// <summary>
  /// コンストラクタ
  /// </summary>
  /// <param name="displayName">ビューでの表示名</param>
  public ClosableViewModelBase(string displayName)
   : base(displayName) {
  }

  /// <summary>
  /// Close要求
  /// </summary>
  public event EventHandler RequestClose;

  /// <summary>
  /// このViewModelを閉じる
  /// </summary>
  public void Close() {
   if (RequestClose != null) {
    RequestClose(this, EventArgs.Empty);
   }
  }
 }


他のViewModelからClose()が呼ばれると、RequestCloseを通じてViewに閉じるように要求します

GeneralViewはこのViewModelを持ちます


 public partial class GeneralView : Window {
  private ClosableViewModelBase _vm;

  public GeneralView(ClosableViewModelBase target) {
   _vm = target;
   target.RequestClose += this.OnCloseRequested;
   DataContext = target;
   InitializeComponent();
  }

  private void OnCloseRequested(object sender, EventArgs e) {
   _vm.RequestClose -= this.OnCloseRequested;
   this.Close();
  }
 }

ViewModelの通知を受け取るように登録して、
受け取ったらView自身をClose()します。
ただしXAMLの方に、入る可能性のあるViewModelのDataTemplateを
以下のようにあかかじめ登録しておく必要があります。
(ここがなんだかなーと思うところです)


 <Window.Resources>
  <DataTemplate DataType="{x:Type vm:SubmitPanelViewModel}">
   <vw:SubmitView/>
  </DataTemplate>
 </Window.Resources>



ViewModel側のコネクタは以下のようにします(EventArgsのコードは略)


 class ViewConnector {
  static private ViewConnector _viewConnector = new ViewConnector();
  static public ViewConnector Instance {
   get { return _viewConnector; }
  }

  /// <summary>
  /// 表示する
  /// </summary>
  /// <param name="target">表示したいViewModel</param>
  public void Show(ClosableViewModelBase target) {
   Debug.Assert(RequestShowWindow != null, "RequestShowWindowが初期化されていません");

   RequestShowWindow(this, new RequestShowViewModelEventArgs(target));
  }

  /// <summary>
  /// 新しいWindowでの表示要求
  /// </summary>
  public event EventHandler<RequestShowViewModelEventArgs> RequestShowWindow;
 }


Showメソッドを提供するだけです。
View側でこのイベントをキャッチします。
クラス名はなんかふさわしくないかもしれませんが、とりあえずこれで。


 class ViewFactory {
  private static ViewFactory _instance = new ViewFactory();
  public static ViewFactory Instance {
   get { return _instance; }
  }

  public void Initialize() {
   ViewConnector.Instance.RequestShowWindow += OnShowWindowRequested;
  }

  private void OnShowWindowRequested(object sender, RequestShowViewModelEventArgs e) {
   //汎用View(ウィンドウ)でViewModelを表示する
   var window = new GeneralView(e.ViewModel);
   window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
   window.Show();
  }

この仕組みを使うために、コマンドから以下のように使います。


  private void ShowSubmitPanel(object parameter) {
   var vm = new SubmitPanelViewModel(_userInfo);
   Utility.ViewConnector.Instance.Show(vm);
  }


誰かのつぶやきへの返信機能もいれてみます。
これは簡単で、SubmitPanelViewModelのEditingTextに初期値を入れてShowするだけです。
(SubmitPanelViewModelのコンストラクタの違いはまず無視してください)


  private void OnReplyCommandRequested(object parameter) {
   var vm = new SubmitPanelViewModel(_userInfo,
    new TimelineViewModel("", new TimelineItemViewModel[]{this}));
   vm.EditingText = string.Format("@{0} ", Name);
   Utility.ViewConnector.Instance.Show(vm);
  }


もちろんボタン、コマンド等も追加していますが、それはソースコード見てください。
これまでのやり方と一緒です。

返信するときには、返信対象の相手の発言が見えた方が便利ですよね?
ということで、新しく表示するWindowにもタイムライン(の一部)を表示できるようにしてみます。

TimelineViewModelのコンストラクタを増やして、
中身が更新されないタイムラインも作れるようにします。


  /// <summary>
  /// コンストラクタ(このコンストラクタは時間経過で更新されない静的なタイムラインを生成する)
  /// </summary>
  /// <param name="displayName">ビューに表示するときの名前</param>
  /// <param name="initialItems">表示するアイテム</param>
  public TimelineViewModel(string displayName, IEnumerable<TimelineItemViewModel> initialItems)
   : base(displayName) {
   _allItems = new ObservableCollection<TimelineItemViewModel>(initialItems);
   _displayNameOriginal = displayName;
   _timeline = null;
  }


_timeline のnullチェックとかも追加しましたが略。
これが先程「無視してください」と言ったところの変更理由です。

XAMLのデザインは、すでに作ったやつを流用するだけで楽です。



<UserControl x:Class="WTwitter.View.SubmitView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:vm="clr-namespace:WTwitter.ViewModel"
 xmlns:vw="clr-namespace:WTwitter.View"
 xmlns:compModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
 Loaded="UserControl_Loaded">
 <UserControl.Resources>
  <DataTemplate DataType="{x:Type vm:TimelineViewModel}">
   <vw:TimelineView/>
  </DataTemplate>
 </UserControl.Resources>
 <DockPanel>
  <DockPanel DockPanel.Dock="Bottom">
   <Button Content="投稿" Command="{Binding Path=SubmitCommand}"
     HorizontalAlignment="Right" Width="Auto" DockPanel.Dock="Right"/>
   
   <TextBox x:Name="TextArea" Text="{Binding Path=EditingText, UpdateSourceTrigger=PropertyChanged}"
      MinLines="3" TextWrapping="Wrap"/>

  </DockPanel>
  <ContentPresenter Content="{Binding Path=Timeline}"/>
 </DockPanel>
</UserControl>


Resourceの追加と、DockPanel、ContentPresenterの追加だけです。

配色がなんか変になりますけど、今回はここまでです。
ちなみに好みでアイコンファイルを変更しました。
お手数ですが、プロジェクトのreadmeファイルを参照して、取得し直してください。m(__)m

今回もブログで書き忘れていることがあるかもしれませんが、
ソースコードを見てもわからない場合はご質問ください。

今回のソースコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/41642

2010年2月18日木曜日

第15回 テキストを分解する

Twitterからはプレーンテキストで文字を取得しますが、
コメント中のURLなどはリンクにして、クリックでWebを開けるようにしたいですよね。

ということで今回はテキストを解析してリンクにします。

まずMVVM構造のどこでテキスト解析するかの検討です。
UIありきで作るのであれば、TextBoxのInlineに
Run(単純なテキスト)なりHyperLinkなりを追加していけば単純なのですが、
MVVMでModelがViewを意識しないという前提だとちょっと工夫が必要になります。

となるとViewでテキストを受け取って、その場で解析して、
分解したものをTextBoxにつっこんでいく方法が考えられますが、
NGワードが入っていたらフィルタリングして
でも、自分のidが本文中にあったらフィルタリングしないで、
といったことはModelかViewModelレベルでやりたいので、
Modelの部分で解析することにします。

ちなみに私は文字列解析は全然詳しくないので、
もしかしたらもっと簡単なやり方があるかもしれません

まずstringの元テキストを分解した後のデータを入れるクラスを作ります。
派生クラスは略。


 public enum TextComponentType {
  Plain,
  UserName,
  Url,
  Tag
 }

 /// <summary>
 /// テキストを意味のある単位で分解したパーツ
 /// ※例:URL、ID、ハッシュタグ、通常の文章
 /// </summary>
 public abstract class TextComponent {
  abstract public TextComponentType Type { get; }
  public string Text { get; set; }
 }


次に正規表現を利用してテキストを分割します。
以下のようなサポートクラスを作ります。

動作の簡単なイメージとしては
1.最初の文字列
"こんにちは。@yuki1090です。正規表現についてはhttp://www.google.comで検索してください"

2.Idで分割(IDについてSplitStepメソッドを適用)
1の文字列を入力として
"こんにちは。"
"@yuki1090"
"です。正規表現についてはhttp://www.google.comで検索してください"
の3つを出力する

3.URLで分割(URLについてSplitStepメソッドを適用)
2の結果の3つの文字列を入力として

"こんにちは。"
"@yuki1090"
"です。正規表現については"
"http://www.google.com"
"で検索してください"
の5つの文字列を出力する




  /// <summary>
  /// 分割の1ステップを表すクラス(1つの正規表現に対する一致を探して分割)
  /// </summary>
  private class SplitStep {
   private Regex _regex;
   private string _pattern;
   private Func<string, TextComponent> _generator;

   /// <summary>
   /// コンストラクタ
   /// </summary>
   /// <param name="pattern">検出するための正規表現</param>
   /// <param name="generator">検出した文字列から新しいTextComponentを生成する関数</param>
   public SplitStep(string pattern, Func<string, TextComponent> generator) {
    _pattern = pattern;
    _regex = new Regex(pattern, RegexOptions.Compiled);
    _generator = generator;
   }

   /// <summary>
   /// 正規表現に一致する部分を見つけて、分割した結果を返す
   /// IEnumerableの各アイテムごとに
   /// a. 入力がプレーンテキストの場合
   ///   正規表現に一致する部分とその前後の3つに分割して、3つのアイテムを返す
   ///   前後はプレーンテキストとして、一致部分は検出したタイプのクラスとして返す
   ///   ※先頭に一致した場合、2カ所に一致した場合などは返す個数が異なる
   /// b. 入力がプレーンテキスト以外(前段ですでに検出されたタイプ)の場合
   ///   そのまま返す
   /// </summary>
   /// <param name="source"></param>
   /// <returns></returns>
   public IEnumerable<TextComponent> Split(IEnumerable<TextComponent> source) {
    foreach (var item in source) {
     if (item.Type != TextComponentType.Plain) {
      //プレーンテキスト以外はそのまま返す
      yield return item;
     } else {
      var matches = _regex.Matches(item.Text);
      if (matches.Count > 0) {
       var index = 0;
       foreach (Match match in matches) {
        if (index < match.Index) {
         //一致より前の部分
         yield return new PlainText(item.Text.Substring(index, match.Index - index));
        }
        //一致した部分
        yield return _generator(match.Value);
        index = match.Index + match.Length;
       }
       if (index < item.Text.Length) {
        //一致より後ろの部分
        yield return new PlainText(item.Text.Substring(index));
       }
      } else {
       //一致がなかったらそのまま返す
       yield return item;
      }
     }
    }
   }
  }




このクラスを使っているのが以下の部分です。
正規表現の文字列を作って、
それぞれのパターンごとにクラスを作って、
テキストをすべてのクラスに処理させる感じです。



 class Splitter {
  #region Regex Pattern
  /// <summary>
  /// ユーザーID検出用パターン
  /// @xxxの@を除いた部分に一致
  /// </summary>
  const string _userNamePattern = @"(?<=@)([a-zA-Z0-9_]+)";

  /// <summary>
  /// URL検出用パターン
  /// </summary>
  const string _urlPattern = @"(https?://[a-zA-Z0-9$-_.+!*'(),#%]+)";

  /// <summary>
  /// ハッシュタグ検出用パターン
  /// #から空白まで
  /// </summary>
  const string _tagPattern = @"(#\S+)";
  #endregion

  #region private member
  private static SplitStep _userNameSplitter;
  private static SplitStep _urlSplitter;
  private static SplitStep _tagSplitter;
  #endregion

  static Splitter() {
   _userNameSplitter = new SplitStep(_userNamePattern, text => new UserName(text));
   _urlSplitter = new SplitStep(_urlPattern, text => new Url(text));
   _tagSplitter = new SplitStep(_tagPattern, text => new Tag(text));
  }

  /// <summary>
  /// 文字列をURLやIDごとに分割する
  /// </summary>
  /// <param name="text">元となる文字列</param>
  /// <returns>分割後のオブジェクト</returns>
  static public List<TextComponent> Split(string text) {
   //最初は1固まりのテキストから始めて
   //前段の分割結果(リスト)を後段の入力とする
   return
    _tagSplitter.Split(
     _urlSplitter.Split(
      _userNameSplitter.Split(
       new TextComponent[] {new PlainText(text)}
       )))
     .ToList<TextComponent>() ;
  }


この結果はViewModelが持っています。

  public List<TextComponent> TextComponents {
   get { return _item.TextComponents; }
  }


最後にこれをViewに反映する方法ですが、
IValueConverterを使います。
ViewにListを表示するように要求されたら
この変換クラスが呼び出されて、TextBlockに変換した結果が表示されます。



 [ValueConversion(typeof(List<TextComponent>), typeof(TextBlock))]
 class TextComponentsConverter : IValueConverter {
  #region IValueConverter メンバ

  public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {

   var textblock = new TextBlock();
   textblock.TextWrapping = System.Windows.TextWrapping.Wrap;

   List<TextComponent> target = value as List<TextComponent>;
   foreach (var item in target) {
    switch (item.Type) {
     case TextComponentType.Plain:
      textblock.Inlines.Add(item.Text);
      break;
     case TextComponentType.UserName:
      var user = item as UserName;
      var link = new Hyperlink();
      link.Click += (sender, e) => Process.Start(user.WebUrl);
      link.Inlines.Add(item.Text);
      textblock.Inlines.Add(link);
      break;

・・・略
   return textblock;
  }

あとはTimelineViewのリソースにコンバータのインスタンスを作成して


 <UserControl.Resources>
  <CollectionViewSource  x:Key="AllItemsViewSource" Source="{Binding Path=AllItems}">

  <view:TextComponentsConverter x:Key="TextCompConverter"/>
 </UserControl.Resources>



以下のようににConverterを指定する


<ContentPresenter Content="{Binding Path=TextComponents, Converter={StaticResource TextCompConverter}}" />


ただ、今回の作りはModelを直接Viewが見ていることになるのかな?と悩んでいます。
それぞれにViewModel作るのはめんどくさいので
これは今のところこれでもいいかなと思っていますが。

ここまでのソースコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/41418

2010年2月14日日曜日

第14回 Search APIを使ってみる

今回はSearchAPIを使ってみます。
SearchAPIはhttp://search.twitter.com/で検索した結果と同じようなものを取得できます。
今まで書き忘れていましたが、twitterのAPIは以下で公開されています。
http://apiwiki.twitter.com/

SearchAPI使う上で困るのは、戻ってくるJsonデータのフォーマットが違うことです。
どこで違いを吸収しようかなと、ViewModelレベルで分けたり試行錯誤しましたが、
Modelの最小限で違いを表すことにしました。
(ちなみにViewModelレベルまで違いを持ち込むと、
UI表示上の動作の違いは作りやすくなるかわりに
クラスの増加、コードの重複が多くなりそうな気がします。)

今回は小さな変更点が多数の箇所にあるので、
要点だけを書きます。詳細はソースコード参照。

まず、通常APIとSearchAPIの共通データを表現するインターフェイスを作ります。


 public interface ITimelineItem : IEquatable<ITimelineItem> {

  /// <summary>
  /// ID番号
  /// </summary>
  long Id { get; }

・・略
 }

前回までViewModelが直接Statusを参照していましたが
上記クラスを参照するようにします。


 public class TimelineItemViewModel {

  public TimelineItemViewModel(ITimelineItem item) {
   _item = item;
  }

あとはITimelineItemクラスを実装するクラスをそれぞれのAPI用に
2つ(TwitterItemクラスとSearchTwitterItemクラス)作り、
それぞれがAPI専用の実際のデータを持つStatusクラス(※APIごとに名前変えた方がよかったかも)
を作ります。

あとはそれらのデータをURLから読み込む部分が異なりますので、
そこだけクラスに抽出します。

まずインターフェイス。


 public interface IItemsReader {
  /// <summary>
  ///
  /// </summary>
  /// <param name="source">入力ストリーム</param>
  /// <returns>解析結果(タイムラインのアイテム)</returns>
  IEnumerable<ITimelineItem> Read(Stream source);
 }

SearchAPIの方の実装。(もう一方は略)


 class SearchTwitterItemsReader : IItemsReader {
  #region IItemsReader メンバ

  public IEnumerable<ITimelineItem> Read(System.IO.Stream source) {
   var serializer = new DataContractJsonSerializer(typeof(SearchResult));
   var result = serializer.ReadObject(source) as SearchResult;
   foreach (var item in result.Results) {
    yield return new SearchTwitterItem(item);
   }
  }

  #endregion
 }





SearchResultクラスとして読み込んでいますが、
これまでのAPIはStatusの配列を渡してきたのに対して
SearchAPIはStatusの配列を持った1つのオブジェクトを渡してくるため、
その違いを吸収するクラスです。

あとはSettingクラスも派生させて、検索ワードなどの差分を追加しています。
オプションダイアログ上の違い(クラスが異なるのに1つのリストに表示する)は
以下のように2つのDataTemplateを作ることで実現しています。


<Window x:Class="WTwitter.View.OptionDialog"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:opt="clr-namespace:WTwitter.Model.UserData"
    Title="オプション設定" Width="400" Height="400" Loaded="Window_Loaded">
 <Window.BindingGroup>
  <BindingGroup/>
 </Window.BindingGroup>
 
 <Window.Resources>
  <DataTemplate DataType="{x:Type opt:TimelineSetting}">
   <GroupBox Header="タイムライン">
    <StackPanel>
     <DockPanel>
      <TextBlock DockPanel.Dock="Left">表示名</TextBlock>
      <TextBox Text="{Binding Path=Name}" DockPanel.Dock="Left" MinWidth="50"/>
      <CheckBox Content="Auth" IsChecked="{Binding Path=IsAuthRequired}"/>
     </DockPanel>
     <DockPanel>
      <TextBlock DockPanel.Dock="Left">URL</TextBlock>
      <TextBox Text="{Binding Path=Url}"/>
     </DockPanel>
    </StackPanel>
   </GroupBox>
  </DataTemplate>

  <DataTemplate DataType="{x:Type opt:SearchTimelineSetting}">
   <GroupBox Header="検索タイムライン">
    <StackPanel>
     <DockPanel>
      <TextBlock DockPanel.Dock="Left">表示名</TextBlock>
      <TextBox Text="{Binding Path=Name}" DockPanel.Dock="Left" MinWidth="50"/>
      <CheckBox Content="日本語のみ対象" IsChecked="{Binding Path=IsJapaneseOnly}"/>
     </DockPanel>
     <DockPanel>
      <TextBlock DockPanel.Dock="Left">検索ワード</TextBlock>
      <TextBox Text="{Binding Path=SearchText}"/>
     </DockPanel>
    </StackPanel>
   </GroupBox>
  </DataTemplate>
 </Window.Resources>



毎回ですが、説明がうまくできない。。

今回のソースコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/41131

2010年2月9日火曜日

第13回 オプションを保存する

今回はちょっと中途半端な出来で終わるかもしれませんが、
作ってから書くまであんまり期間が空くと自分で忘れちゃうので。

まずオプションとしてこれまでUserInfoクラスくらいしかありませんでしたが、
Optionクラス、TimelineSettingクラス、UserInfoクラスに階層化します。
TimelineSettingクラスはタイムラインのURL、更新間隔などを持ちます。

ファイルへの保存はXmlSerializerを使うとすごく簡単です。
逆に凝ったことはできないかもしれません。
私はここらへんを参照させてもらいました。
http://dobon.net/vb/dotnet/file/xmlserializer.html



 public class Option {
  public Option() {
  }

  #region Property
  public UserInfo UserInfo { get; set; }
  [XmlArrayItem(typeof(TimelineSetting))]
  public List<TimelineSetting> TimelineSettings { get; set; }
  #endregion

  #region Public Method
  /// <summary>
  /// ファイルがないときなどにデフォルト値を読み込む
  /// </summary>
  /// <returns></returns>
  static private Option LoadDefault() {
・・省略
  }

  /// <summary>
  /// Optionの内容をファイルに保存する
  /// </summary>
  /// <param name="filename">保存ファイル名</param>
  public void Save(string filename) {
   var serializer = new XmlSerializer(this.GetType());
   using (var stream = new FileStream(filename, FileMode.Create)) {
    serializer.Serialize(stream, this);
   }
  }

  /// <summary>
  /// Optionの内容をファイルに保存する。ファイル名はLoad()時に使用したもの。
  /// </summary>
  public void Save() {
   Debug.Assert(!string.IsNullOrEmpty(this._filename));
   this.Save(this._filename);
  }

  /// <summary>
  /// ファイルからOptionクラスを読み込む
  /// </summary>
  /// <param name="filename">読み込むファイル名</param>
  /// <returns>読み込んだオブジェクト</returns>
  static public Option Load(string filename) {
   //ファイルがなかったらデフォルト値を読み込む
   if (!File.Exists(filename)) {
    var newInstance = LoadDefault();
    newInstance._filename = filename;
    return newInstance;
   }

   //読み込み
   var serializer = new XmlSerializer(typeof(Option));
   Option instance;
   using (var stream = new FileStream(filename, FileMode.Open)) {
    instance = serializer.Deserialize(stream) as Option;
   }
   //Save()の時のためにファイル名を保存
   instance._filename = filename;
   return instance;
  }
  #endregion Public Method

  #region private member
  private string _filename;
  #endregion
 }

TimelineSettingクラスはフィールドを持つだけです。


 public class TimelineSetting {
  /// <summary>
  /// タイムラインに付ける名前
  /// </summary>
  public string Name { get; set; }
  /// <summary>
  /// タイムラインのURL
  /// </summary>
  public string Url { get; set;}
  /// <summary>
  /// タイムラインのリクエストに認証が必要かどうか
  /// </summary>
  public bool IsAuthRequired { get; set; }
  /// <summary>
  /// 自動更新する間隔
  /// </summary>
  public int UpdateInterval { get; set; }
 }



 public class UserInfo {
  [XmlIgnore]
  public string Password { get; set; }//これは保存しない

  /// <summary>
  /// ユーザーID
  /// </summary>
  public string UserName { get; set; }

  /// <summary>
  /// ファイル保存用にエンコードしたパスワード
  /// </summary>
  public string PasswordEncoded {
   get {
    return Convert.ToBase64String(Encoding.ASCII.GetBytes(Password));
   }
   set {
    Password = Encoding.ASCII.GetString(Convert.FromBase64String(value));
   }
  }

  /// <summary>
  /// ユーザー情報が有効かどうか
  /// </summary>
  [XmlIgnore]
  public bool IsValid {
   get {
    //とりあえずの実装
    if ( string.IsNullOrEmpty(UserName) || string.IsNullOrEmpty(Password)) {
     return false;
    }
    return true;
   }
  }
 }


ローカルといえど、さすがにパスワードをプレーンな状態で保存するのはマズイかなと思い、
簡単にEncode(変換)して保存します。
これはMiniTwitterの実装を参考にさせてもらいました。

そのうちOAuth認証に対応しないといけないので(TwitterのAPIがそうなるらしい)、
そのときにはIDとパスワードをローカルに保存しないようにします。

ローカルにパスワードを保存する場合で、本格的にセキュリティに気をつけなければならない時は
他の暗号化方式を使ったり、
プログラム内でもSecureStringクラスで保持した方がいいかもしれません。


あとIsValidでIDとパスワードを検証するロジックを入れていますが、
データ構造をそのままXMLに保存するクラスは本当にデータだけにして
ロジックは別のところにおいた方がわかりやすいかもしれません。

ダイアログ画面のxamlは以下のようになります。
DataContextにはOptionクラスはコンストラクタで入るようにしています。



<Window x:Class="WTwitter.View.OptionDialog"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="オプション設定" Width="300" Height="300" Loaded="Window_Loaded">
 <Window.BindingGroup>
  <BindingGroup/>
 </Window.BindingGroup>
 <DockPanel>
  <Button IsDefault="True" Click="OkButton_Click"
    DockPanel.Dock="Bottom" HorizontalAlignment="Right">OK(_O)</Button>
  <TabControl>
   <TabItem Header="ID">
    <Grid>
     <Grid.ColumnDefinitions>
      <ColumnDefinition Width="Auto"/>
      <ColumnDefinition Width="Auto"/>
     </Grid.ColumnDefinitions>
     <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
     </Grid.RowDefinitions>
     <TextBlock Text="UserName" Grid.Row="0" Grid.Column="0"/>
     <TextBox Grid.Row="0" Text="{Binding Path=UserInfo.UserName}"
        Grid.Column="1" Width="100"/>
     
     <TextBlock Text="Password" Grid.Row="1" Grid.Column="0"/>
     <TextBox Text="{Binding Path=UserInfo.Password}"
      Grid.Row="1" Grid.Column="1" Width="100"/>

    </Grid>
   </TabItem>
   
   <TabItem Header="Timeline">
    <ItemsControl ItemsSource="{Binding Path=TimelineSettings}" HorizontalContentAlignment="Stretch">
     <ItemsControl.ItemTemplate>
      <DataTemplate>
       <StackPanel>
        <DockPanel>
         <TextBlock DockPanel.Dock="Left">Name</TextBlock>
         <TextBox Text="{Binding Path=Name}" DockPanel.Dock="Left"/>
         <CheckBox Content="Auth" IsChecked="{Binding Path=IsAuthRequired}"/>
        </DockPanel>
        <DockPanel>
         <TextBlock DockPanel.Dock="Left">URL</TextBlock>
         <TextBox Text="{Binding Path=Url}"/>
        </DockPanel>
       </StackPanel>
      </DataTemplate>
     </ItemsControl.ItemTemplate>
    </ItemsControl>
   </TabItem>
  </TabControl>
 </DockPanel>
</Window>



主な変更点は3つ
・全体をTabでページ分け
・設定関係のクラスを階層化したのでBindingのパスが1段階深くなっている
・OKボタンを押したときにOptionクラスのSave()を呼び出すようにしている
例:Binding Path=UserInfo.UserName

ちなみに、タイムラインの追加・削除をまだ作ってないので、
デフォルトである2つのタイムラインのURLなどの変更しかできません。
(1回保存した後でできるXMLファイルを直接編集すれば3つ以上にできると思いますが。)

保存できるようになったので、起動時にファイルから設定を読み出すように変更します。



 public partial class App : Application {

  protected override void OnStartup(StartupEventArgs e) {
   base.OnStartup(e);

   var option = Option.Load("Setting.xml");

   var viewModel = new MainWindowViewModel(option);
   viewModel.Initialize();

   var mainWindow = new MainWindow(viewModel);
   mainWindow.DataContext = viewModel;
   mainWindow.Show();
  }
 }


ちなみにMainWindowをnewするまえにviewModelをInitialize()しないと、
配置がおかしくなるみたいです。
Windowをnewしたときに配置を計算するみたいですね。(show()ではなくて)
なのでnewする時点でViewModelのアイテムを確定させておいたほうがいいみたいです。
showしたあとのアイテムの追加はうまく計算してくれません。
Windowのサイズを変更すると修正されるみたいですが。。

あとは初回起動時はユーザーIDとパスワードが入っていないのでそのままだと必ず失敗するので、
MainWindowViewModelのInitializeでタイムラインが取得できる状態かどうかを調べて
可能な場合のみタイマを作動させています(vm.IsUpdatableの部分)



  public void Initialize() {
   ////Modelの作成

   //ツールバーのボタンの作成
   var baseUri = System.IO.Directory.GetParent(
    System.Reflection.Assembly.GetExecutingAssembly().Location).FullName;
   var commands = new ImageButton[] {
    new ImageButton("設定", OptionDialogCommand,
     baseUri + @"\Icon\spanner_48.png")
   };

   foreach (var comm in commands) {
    _commandButtons.Add(comm);
   }

   //Modelの作成
   foreach (var setting in _option.TimelineSettings) {
    TimelineViewModel vm;
    if (setting.IsAuthRequired) {
     vm = new TimelineViewModel(setting.Name,
       new Timeline(setting.Url, _userInfo));
    } else {
     vm = new TimelineViewModel(setting.Name, new Timeline(setting.Url));
    }
    Timelines.Add(vm);
    if (vm.IsUpdatable) {
     vm.Update();
     vm.StartAutoUpdate();
    }
   }

   _submitPanel = new SubmitPanelViewModel(_userInfo);
   OnPropertyChanged("SubmitPanel");
  }


オプションを変更した場合は再起動しないと反映されませんが。。。今回はここまで。

ソースコードはこちら

http://wtwitter.codeplex.com/SourceControl/changeset/view/40836

2010年2月4日木曜日

第12回 ツールバーのボタンを作る

ツールバーってMVVM的にどう作るんだろ?と悩んでしまいました。
XAMLで直にちょいちょいって書けば早そうですけど、
無駄に(?)MVVMぽく書いてみました。

まず、ツールバーのアイコンとクリック時のコマンドを結びつけるクラス。



 class ImageButton {
  private readonly string _description;
  private readonly ICommand _command;
  private readonly BitmapImage _image;

  public ImageButton(string description, ICommand command, string imageFilePath) {
   _description = description;
   _command = command;
   _image = new BitmapImage();
   _image.BeginInit();
   _image.UriSource = new Uri(imageFilePath, UriKind.RelativeOrAbsolute);
   _image.EndInit();
  }

  #region Property
  /// <summary>
  /// ボタンの機能の説明
  /// </summary>
  public string Description {
   get { return _description; }
  }

  /// <summary>
  /// 押されたときのコマンド
  /// </summary>
  public ICommand Command {
   get { return _command; }
  }

  /// <summary>
  /// ボタンに表示するイメージ
  /// </summary>
  public BitmapImage Image {
   get { return _image; }
  }
  #endregion
 }



前回までついてこれている方なら何も難しくないと思います。
たんにクラス内に各オブジェクトへの関連を持っているだけです。

そしてMainWindowViewModelに実際のボタンのインスタンスを作ります。
今回はとりあえず1個だけ。
オプションダイアログを出します。(今は起動ダイアログとしか役目を果たしていません)
今はこのダイアログは特に意味はないです。



  private RelayCommand _optionDialogCommmand;
  /// <summary>
  /// オプションダイアログを開く
  /// </summary>
  public ICommand OptionDialogCommand {
   get {
    if (_optionDialogCommmand == null) {
     _optionDialogCommmand = new RelayCommand(this.OpenOptionDialog);
    }
    return _optionDialogCommmand;
   }
  }

  private void OpenOptionDialog(object parameter) {
   var dialog = new OptionDialog(_userInfo);
   dialog.ShowDialog();
  }



同じViewModelにそれをObservableCollectionで保持します。



  public ObservableCollection<ImageButton> CommandButtons {
   get { return _commandButtons; }
  }

同Initializeメソッド内


   //ツールバーのボタンの作成
   var baseUri = System.IO.Directory.GetParent(
    System.Reflection.Assembly.GetExecutingAssembly().Location).FullName;
   var commands = new ImageButton[] {
    new ImageButton("設定", OptionDialogCommand,
     baseUri + @"\Icon\spanner_48.png")
   };

   foreach (var comm in commands) {
    _commandButtons.Add(comm);
   }


アイコンは自分で用意してください。(もちろんファイル名はそれに従って変更してください)
http://wefunction.com/2008/07/function-free-icon-set/
こちらのアイコンをダウンロードして使わせていただいています。

最後にMainWindow.xamlに置きます。



 <ToolBar ItemsSource="{Binding Path=CommandButtons}" DockPanel.Dock="Top">
   <ToolBar.ItemTemplate>
    <DataTemplate DataType="{x:Type vm:ImageButton}">
     <Button Command="{Binding Path=Command}">
      <Image Source="{Binding Path=Image}" Height="24" Width="24"/>
     </Button>
    </DataTemplate>
   </ToolBar.ItemTemplate>
  </ToolBar>


やっていることはTabControlの回とほとんど同じですね。
最初に作ったImageButton のプロパティCommandとImageにそれぞれバインドしています。

今回のソースコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/40302

2010年2月1日月曜日

第11回 つぶやきを投稿する

前準備としてSystem.Web.dllへの参照を追加してください。

前回RoutedUICommandを使いましたが、
いきなりですが、これをやめます。

RoutedCommand(またはRoutedUICommand)はあらかじめ用意された実装で、
WPFではRoutedCommandを前提に作られている部分も多そうなのでこっちにしようかと思っていましたが、
MVVMの記事で使われているRelayCommandを素直に使うことにします。
http://msdn.microsoft.com/ja-jp/magazine/dd419663.aspx

作り込んでみないとわかりませんが、RoutedCommandに比べてRelayCommandは
・簡単なコマンドに対してはCommandBindingや
Viewのイベントハンドラなどの記述が不要ですっきりする。
・ViewModelだけでロジックを完結させやすい。
の2点がメリットかなと思っています。
RelayCommandはそれ自体に処理を持たせることができます。
RelayCommandのコードは上記リンクを見てください。

まず前回のURLを開くコマンドを作り直します。
ViewModelにコマンドを作ります。



 class TimelineItemViewModel {
  private Status _item;

  private RelayCommand _openByBrowserCommand;
  /// <summary>
  /// パラメータで与えられたURLをブラウザで開くコマンド
  /// </summary>
  public RelayCommand OpenByBrowserCommand {
   get {
    if (_openByBrowserCommand == null) {
     _openByBrowserCommand = new RelayCommand(this.OnOpenByBrowserRequested);
    }
    return _openByBrowserCommand;
   }
  }

  /// <summary>
  /// ブラウザで開く
  /// </summary>
  /// <param name="parameter">開くURL(string)が入ったobject</param>
  private void OnOpenByBrowserRequested(object parameter) {
   var url = parameter as string;
   if (!string.IsNullOrEmpty(url)) {
    Process.Start(url);
   }
  }



コマンドの処理を実装するメソッドOnOpenByBrowserRequestedを作り、
そのメソッドへ関連づけられたコマンドを表すOpenByBrowserCommandプロパティを公開します。

ViewはコマンドのBind方法が以下のように変わります。
CommandBindingなどの記述がなくなっていることに注意してください。
Commandsクラスもなくなっています。



<Hyperlink Command="{Binding Path=OpenByBrowserCommand}" CommandParameter="{Binding Path=UserUrl}" >

今回の追加機能である投稿機能を作ります。
まずViewModel。



 class SubmitPanelViewModel : ViewModelBase {
  private UserInfo _userInfo;

  private string _editingText = "";
  /// <summary>
  /// 入力中のテキスト
  /// </summary>
  public string EditingText {
   get { return _editingText; }
   set {
    if (_editingText != value) {
     _editingText = value;
     OnPropertyChanged("EditingText");
    }
   }
  }

  private ICommand _submitCommand;
  /// <summary>
  /// 投稿
  /// </summary>
  public ICommand SubmitCommand {
   get {
    if (_submitCommand == null) {
     _submitCommand = new RelayCommand(
      param => this.Submit(), param => this.CanSubmit);
    }
    return _submitCommand;
   }
  }

  /// <summary>
  /// コンストラクタ
  /// </summary>
  /// <param name="info">ユーザー認証情報</param>
  public SubmitPanelViewModel(UserInfo info) {
   _userInfo = info;
  }

  /// <summary>
  /// 投稿する
  /// </summary>
  public void Submit() {
   StatusMethods.Update(_editingText, _userInfo);
   EditingText = "";
  }

  /// <summary>
  /// 投稿できる状態かどうかの判断
  /// </summary>
  public bool CanSubmit {
   get { return _editingText.Length > 0; }
  }

  public override string ToString() {
   return "SubmitPanelViewModel";
  }
 }


編集中のテキストを持っていて、投稿(Submit)のためのコマンドも持っています。
また、文字が入力されている(Length>0)かどうかで
CanExecute(メソッド名はCanSubmit)を判断しています。


そのほかに新しいのはViewModelBaseを作って継承しています。
これは今はまたViewModelに共通の処理OnPropertyChangedを提供するだけです。
ViewModelBaseはMVVMの記事でも紹介されていますが、
INotifyPropertyChangedを実装します。
PropertyChangedイベントを発行することで、プロパティ(今回の例ではEditingText)が
変化したことをViewに伝えることができます。

投稿するメソッドStatusMethods.Upate()は長くなるので最後のリンク先のコードを見てください。
タイムラインを取得するときとやることはあんまり変わりません。

上記のViewModelに対応するViewを作ります。
それぞれBindしてやるだけです。



<UserControl x:Class="WTwitter.View.SubmitView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:vw="clr-namespace:WTwitter.View">
 
 <DockPanel>
  <Button Content="投稿" Command="{Binding Path=SubmitCommand}"
    HorizontalAlignment="Right" Width="Auto" DockPanel.Dock="Right" />
  
  <TextBox Text="{Binding Path=EditingText, UpdateSourceTrigger=PropertyChanged}"
     MinHeight="50"/>

 </DockPanel>
</UserControl>




UpdateSourceTrigger=PropertyChangedを指定してやることで
1文字入力するごとに変更がViewModelに伝わって
CanExecuteが判定されます(それによってボタンが無効になったりします。)

最後に
1.MainWindowViewModelに今回のSubmitPanelViewModelを持たせてやる
2.MainWindowにContentPresenterを使ってデータを表示するように指示する
3.DataTemplateを作って2のデータの表示方法をSubmitViewになるようにする



<Window x:Class="WTwitter.View.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:vm="clr-namespace:WTwitter.ViewModel"
 xmlns:vmt="clr-namespace:WTwitter.ViewModel.Twitter"
 xmlns:vw="clr-namespace:WTwitter.View"
 xmlns:CompModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
    Title="WTwitter" Height="500" Width="400">
 
 <Window.Resources>
  <DataTemplate DataType="{x:Type vmt:TimelineViewModel}">
   <vw:TimelineView/>
  </DataTemplate>
  
  <DataTemplate DataType="{x:Type vm:SubmitPanelViewModel}">
   <vw:SubmitView/>
  </DataTemplate>
 </Window.Resources>
 
 <DockPanel>
  <ContentPresenter Content="{Binding Path=SubmitPanel}"
   DockPanel.Dock="Bottom" />

  <TabControl ItemsSource="{Binding Path=Timelines}">
   <TabControl.ItemTemplate>
    <DataTemplate>
     <TextBlock Text="{Binding Path=DisplayName}"/>
    </DataTemplate>
   </TabControl.ItemTemplate>
  </TabControl>
 
 </DockPanel>
</Window>



長くなったので結構説明はしょりました。すみません。。


今回のコード
http://wtwitter.codeplex.com/SourceControl/changeset/view/39862