2010年3月30日火曜日

番外編 変更点+ComboBoxに画像を表示する

http://wtwitter.codeplex.com/SourceControl/changeset/view/44148真新しいことはあまりやっていないんですけど、
ソースコードはだいぶいじりました。

・アイコンの位置を修正
・タイムラインの一番上のアイテムにスクロールするボタンを追加
・タイムラインの発言のユーザー名による絞り込み
・ソースコード上にユーザーを表すModel、ViewModelを追加
・オプション画面でアカウント情報(API制限残り等)を
・オプション画面でタイムラインの新規作成のインターフェイスを変更
ソースコード上ではTimelineSettingTemplateクラスに設定の生成方法を集める
などなど。

ユーザーの絞り込みにComboBoxを使って、
ComboBoxの中に画像を表示するようにしたので、
それについてひとことだけ書きます。

といっても、タイムラインの発言を表示するときに、
ListBoxのアイテムに画像を載せているのとほぼ同じですが。

TimelineViewModelがユーザーを表すUserViewModelのリストを持っています。
あとは、ViewでComboBoxのItemsSourceに指定して、


    <ComboBox ItemsSource="{Binding Path=AllUsers}" SelectedItem="{Binding Path=SelectedFilterUser, Mode=TwoWay}"
        MinWidth="70" ToolTip="ユーザーで絞り込み"/>



ResourceにDataTemplateをおいて表示方法を指定するだけです。



  <DataTemplate DataType="{x:Type vm:UserViewModel}">
   <StackPanel Orientation="Horizontal">
    <Image Source="{Binding Path=ProfileImageUrl}" Width="16" Height="16"/>
    <TextBlock Text="{Binding Path=ScreenName}"/>
   </StackPanel>
  </DataTemplate>


ComboBoxのそれぞれのアイテムがImageとTextBlockを持ったStatckPanelになります。

2010年3月26日金曜日

番外編 Todoアプリを作ってみた

ちょっと自分好みのTodoアプリがなかったので、
WPF/MVVMで作ってみました。

超シンプルで、とにかくいっぱいTODOを登録していって
Done(やった)かLater(あとまわし)をすぐ選択できるようにしただけです。

ソースコードもシンプルなので解説無しです。
もし興味あれば眺めてみてください。
ただ、これまでの連載を全部読んでいる人は
とりわけ新しい発見はないかもしれません。

http://minitodo.codeplex.com/

2010年3月25日木曜日

第30回 エラーをアイコンで表示する

今回の話題の前に。
なぜかオプションのタイムライン更新間隔を反映させるのを忘れて
2分(固定値)間隔で更新していました。
修正しました。

本題ですが、まずタイムライン取得結果の通知の方法を変えました。
今までは1新規アイテムごとに取得報告をViewModelにあげていましたが、
今回は1回のUpdateリクエストごとに1回の結果に関するすべての情報をレポートします。
以下のクラスを通じてイベントを発行します。


 public enum TimelineReadResult {
  /// <summary>
  /// 新しいアイテムの取得
  /// </summary>
  ReadNewItems,

  /// <summary>
  /// アクセスに成功したが新しいアイテムがなかった
  /// </summary>
  NoNewItems,

  /// <summary>
  /// サーバーやネットワークのエラーなど、(ある程度予期できる)エラーの発生
  /// </summary>
  WebErrorOccurred,

  /// <summary>
  /// 上記以外のエラー発生
  /// </summary>
  UnexpectedErrorOccurred
 }

 /// <summary>
 /// タイムラインにアクセスした結果
 /// </summary>
 public class ReadTimelineEventArgs : EventArgs {
  /// <summary>
  /// 取得に成功した場合のコンストラクタ
  /// </summary>
  /// <param name="newItems"></param>
  public ReadTimelineEventArgs(List<ITimelineItem> newItems) {
   if (newItems.Count > 0) {
    ResultType = TimelineReadResult.ReadNewItems;
   } else {
    ResultType = TimelineReadResult.NoNewItems;
   }
   NewItems = newItems;
  }

  /// <summary>
  /// 例外が発生した場合のコンストラクタ
  /// </summary>
  /// <param name="e">読み込み時に発生した例外(をそのまま渡す)</param>
  public ReadTimelineEventArgs(Exception e) {
   if (e is WebException) {
    ResultType = TimelineReadResult.WebErrorOccurred;
   } else {
    ResultType = TimelineReadResult.UnexpectedErrorOccurred;
   }
   Error = e;
  }

  public TimelineReadResult ResultType { private set; get; }

  /// <summary>
  ///
  /// 例外が発生した場合はnull
  /// </summary>
  public List<ITimelineItem> NewItems { private set; get; }

  /// <summary>
  ///
  /// </summary>
  public Exception Error { private set; get; }
 }

それを生成する部分が以下です。


  /// <summary>
  /// BackgroundWorkerのDoWork
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  protected virtual void getItemsAsync(object sender, DoWorkEventArgs e) {
   //タイムラインからアイテムを取得する
   //※別スレッドで実行される

   try {
    using (var response = WebUtility.GetResponse(_url, "GET", new RequestParameter[] { }, _userInfo))
    using (var stream = response.GetResponseStream()) {
     e.Result = _itemsReader.Read(stream).ToList<ITimelineItem>();
    }
   } catch (Exception exception) {
    e.Result = exception;
   }
  }

  /// <summary>
  /// BackgroundWorkerのCompleted
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  protected virtual void getItemsCompleted(object sender, RunWorkerCompletedEventArgs e) {
   //取得したアイテムを通知する
   //※UIスレッドで実行される

   var exception = e.Result as Exception;
   if (exception != null) {
    OnReadItems(new ReadTimelineEventArgs(exception));
    return;
   }

   var result = e.Result as List<ITimelineItem>;
   var newItems = new List<ITimelineItem>();
   foreach (var item in result) {
    if (!AllItems.Contains(item)) {
     newItems.Add(item);
    }
   }
   OnReadItems(new ReadTimelineEventArgs(newItems));
  }
  #endregion

  #region protected method
  protected void OnReadItems(ReadTimelineEventArgs e) {
   if (e.ResultType == TimelineReadResult.ReadNewItems) {
    AllItems.AddRange(e.NewItems);
   }
   TimelineRead(this, e);
  }
  #endregion

なぜかマニュアル通りに例外がとれないので、getItemsAsyncで例外をすべて取って
getItemsCompletedの方で例外をRunWorkerCompletedEventArgs に入れて発行しています。
例外がなかった場合も同じEventArgsを利用して報告します。

TimelineViewModelでは以下のようにイベントを分解しています


  private void OnItemsAdded(object snder, ReadTimelineEventArgs e) {
   switch (e.ResultType) {
    case TimelineReadResult.ReadNewItems:
     foreach (var item in e.NewItems) {
      if (CheckFilter(item) == true) {
       AllItems.Add(new TimelineItemViewModel(item, _timeline.UserInfo));
      }
     }
     Message.Add(EventType.Progress,
      string.Format("タイムライン({0})の取得完了<新規{1}アイテム>", DisplayName, e.NewItems.Count) );
     IsLatestUpdateFailed = false;
     break;
    case TimelineReadResult.NoNewItems:
     Message.Add(EventType.Progress,
      string.Format("タイムライン({0})の取得完了<新規無し>", DisplayName));
     IsLatestUpdateFailed = false;
     break;
    case TimelineReadResult.WebErrorOccurred:
     Message.Add(EventType.Progress,
      string.Format("タイムライン({0})の取得失敗", DisplayName));
     IsLatestUpdateFailed = true;
     break;
    case TimelineReadResult.UnexpectedErrorOccurred:
     Message.Add(EventType.CriticalError,
      string.Format("タイムライン({0})の取得中に予期しない例外発生", DisplayName),
      e.Error.Message + Environment.NewLine + e.Error.StackTrace);
     IsLatestUpdateFailed = true;
     break;
    default:
     throw new InvalidOperationException();
   }
  }


失敗したかどうかをIsLatestUpdateFailed に入れています。

失敗したときは、タイムライン名が表示されているタブにWarningアイコンを表示しましょう。
これまでどおりあらかじめxamlでアイコンを指定しておき、
MainWindowにテンプレートを作ります。


  <!--タブ部分のテンプレート-->
  <DataTemplate x:Key="HeaderTemplate">
   <StackPanel Orientation="Horizontal">
    <!--取得失敗時のイメージ-->
    <!--エラーがないときは非表示にしている-->
    <Image Source="{DynamicResource WarningImage}" Height="14" Width="14">
     <Image.Style>
      <Style TargetType="{x:Type Image}">
       <Style.Triggers>
        <DataTrigger Binding="{Binding Path=IsLatestUpdateFailed}" Value="False">
         <Setter Property="Visibility" Value="Collapsed"/>
        </DataTrigger>
       </Style.Triggers>
      </Style>
     </Image.Style>
    </Image>
    <!--タイムライン名-->
    <TextBlock Text="{Binding Path=DisplayName}"/>
   </StackPanel>
  </DataTemplate>

表示のON、OFFを先程のプロパティで決めています。
TabControlのItemTemplateを、先程のテンプレートを読むように設定します。


  <!--メインのタイムライン表示部分-->
  <TabControl x:Name="timelines" ItemsSource="{Binding Path=TimelinesViewSource.View}"
     ItemTemplate="{StaticResource HeaderTemplate}" IsSynchronizedWithCurrentItem="True">
  </TabControl>

これでエラーが出たときはアイコンが表示されるはずです。

これまで使ったいた1アイテムごとに取得通知を出していた構造はソースからばっさり削除しました。

それから、ちょっと下機能ですが、タイムラインを下のほうまでいっていた時に、
1ボタンで一番上まで戻れるボタンを作りました。
今まで作ってきたCommandの作り方と違うのが気持ち悪いですが、
TimelineViewこの機能はビューの表示領域を変更するだけなのでVMが関わらなくてもいいかなと思い、
まぁいいかなということで。
TimelineViewにて


  <ToolBarTray DockPanel.Dock="Top">
   <ToolBar>
    <!--一番上までスクロールするボタン-->
    <Button Command="{StaticResource MoveToTopItemCommand}">
     <Image Source="{DynamicResource UpToViewTopImage}"/>
     <Button.ToolTip>一番上へスクロール</Button.ToolTip>
    </Button>
   </ToolBar>
  </ToolBarTray>

StaticResourceにコマンドを作って、CommandBindingではそのコマンドが発生したときに実行するメソッドを指定しています。



  <RoutedCommand x:Key="MoveToTopItemCommand"/>

 </UserControl.Resources>
 
 <UserControl.CommandBindings>
  <CommandBinding Command="{StaticResource MoveToTopItemCommand}" Executed="MoveToTopItemExecuted"/>
 </UserControl.CommandBindings>

コードは簡単です。


  private void MoveToTopItemExecuted(object sender, ExecutedRoutedEventArgs e) {
   if (_timeline.Items.Count > 0) {
    _timeline.ScrollIntoView(_timeline.Items[0]);
   }
  }

これがもともとMSで推奨されているやり方に近いかなと思います。

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

2010年3月22日月曜日

第29回 ステータスバーにメッセージを表示する

前回のスレッド化に伴い例外処理を追加しないといけないのですが、
どうせならエラーの内容を表示したいなと思って、
先にステータスバーを作ることにしました。

単純に作るんならすごく簡単なんですが、
一応MVVM式(と思っている)で作ろうと思いました。
なのでModelから。

単純にメッセージのイベントを飛ばすことと、
ある程度の数のログを持っておくことをやるクラスです。
アクセスしやすいようにstaticクラスにしています。
本格的にイベントログを取りたいのならちゃんとした.netの流儀でやるべきかもしれませんが、
そこまでやんなくてもいいかなと思っているので適当に作っています。


 enum EventType {
  /// <summary>
  /// 単純な進捗メッセージ
  /// </summary>
  Progress,
  /// <summary>
  /// エラー
  /// ※ある程度頻繁に起こりえるもの(リクエストのタイムアウトとか)
  /// </summary>
  Error,
  /// <summary>
  /// 致命的なエラー
  /// ※これはずっと残るようにする予定
  /// </summary>
  CriticalError
 }

 class MessageInfo {
  public EventType Type { set; get; }

  /// <summary>
  /// メッセージの要約(1行に収まる程度)
  /// </summary>
  public string Message { set; get; }

  /// <summary>
  /// メッセージの詳細
  /// (必要に応じて)
  /// </summary>
  public string Detail { set; get; }

  /// <summary>
  /// メッセージが発行された時間
  /// </summary>
  public DateTime Time { set; get; }
 }

 class MessageAddedEventArgs : EventArgs {
  public MessageAddedEventArgs(MessageInfo newItem) {
   Item = newItem;
  }

  /// <summary>
  /// 新しく追加されたメッセージ
  /// </summary>
  public MessageInfo Item { private set; get; }
 }

 /// <summary>
 /// イベントやエラーなどの情報を登録する
 /// 登録された情報はViewModelで拾ってWindowに表示されたり、ログに出力されたりする(予定)
 /// </summary>
 static class Message {
  #region private member
  private static object lockObj = new object();
  private static List<MessageInfo> _messageLog = new List<MessageInfo>();
  private const int _logNum = 200;
  private static List<MessageInfo> _criticalErrorLog = new List<MessageInfo>();
  #endregion

  /// <summary>
  /// メッセージが追加された通知イベント
  /// </summary>
  static public event EventHandler<MessageAddedEventArgs> MessageAdded;

  static Message() {
   MessageAdded += (sender, e) => {};
  }

  /// <summary>
  /// メッセージを追加する
  /// </summary>
  /// <param name="type"></param>
  /// <param name="message">メッセージの簡略表示(1行で収まる程度)</param>
  /// <param name="detail">詳細</param>
  static public void Add(EventType type, string message, string detail) {

   var item = new MessageInfo() {
    Type = type,
    Message = message,
    Detail = detail,
    Time = DateTime.Now
   };

   //複数のスレッドから同時にメッセージが飛んでくるかもしれないのでlockする
   lock (lockObj) {
    _messageLog.Add(item);
    if (_messageLog.Count > _logNum) {
     _messageLog.RemoveAt(0);
    }

    if (type == EventType.CriticalError) {
     _criticalErrorLog.Add(item);
    }
   }

   var handler = MessageAdded;
   handler(null, new MessageAddedEventArgs(item));
  }

  /// <summary>
  /// メッセージを追加する
  /// </summary>
  /// <param name="type"></param>
  /// <param name="message">メッセージの簡略表示(1行で収まる程度)</param>
  static public void Add(EventType type, string message) {
   Add(type, message, "");
  }
 }

どこから呼ばれるかわからないのでlockで排他処理しています。

StatusBarを表すViewModelを作ります。
簡単ですね。イベントが来るたびに文字列を更新するだけです。


 public class StatusBarViewModel : ViewModelBase {
  #region private member
  private string _text;
  private string _recentMessages;
  private List<string> _resentMessagesList = new List<string>();
  private const int _recentNumMax = 10;
  #endregion

  public StatusBarViewModel()
   : base("") {
   Message.MessageAdded += this.OnMessageAdded;
  }

  /// <summary>
  /// 一番最新のメッセージを表す文字列
  /// </summary>
  public string Text {
   set {
    if (_text != value) {
     _text = value;
     OnPropertyChanged("Text");
    }
   }

   get {
    return _text;
   }
  }

  /// <summary>
  /// 最近のメッセージを表す文字列(改行で区切られた1つの文字列)
  /// </summary>
  public string RecentMassages {
   set {
    if (_recentMessages != value) {
     _recentMessages = value;
     OnPropertyChanged("RecentMassages");
    }

   }
   get {
    return _recentMessages;
   }
  }

  private void OnMessageAdded(object sender, MessageAddedEventArgs e) {
   string text = string.Format("[{0}] {1}", e.Item.Time.ToLocalTime().ToString("HH:mm"), e.Item.Message);
   Text = text;

   _resentMessagesList.Add(text);
   if (_resentMessagesList.Count > _recentNumMax) {
    _resentMessagesList.RemoveAt(0);
   }
   RecentMassages = string.Join(Environment.NewLine, _resentMessagesList.ToArray());
  }
 }


Viewも簡単です。
UserControlを作って


<UserControl x:Class="WTwitter.View.StatusBarView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
 <StatusBar>
  <StatusBarItem>
   <TextBlock Text="{Binding Path=Text}"/>
   <StatusBarItem.ToolTip>
    <TextBlock Text="{Binding Path=RecentMassages}"/>
   </StatusBarItem.ToolTip>
  </StatusBarItem>
 </StatusBar>
</UserControl>



MainWindowにDataTemplateを追加します。


  <DataTemplate DataType="{x:Type vm:StatusBarViewModel}">
   <vw:StatusBarView/>
  </DataTemplate>


あとはContentPresenterを追加して、
MainWindowViewModelにStatusBarViewModelにアクセスするプロパティを持たせています。

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

関係ないけど、追加する機能どうしようかなーと悩みはじめたので、
他でも宣伝していますが、アンケートをはじめました。
簡単な2問だけなので、よかったらご協力ください。

・ついったークライアントに必須の機能
http://poll.fm/1qn9k
・ついったークライアントに求めるもの
http://poll.fm/1qna2

第28回 マルチスレッド処理する

UIの処理とタイムライン取得の処理が重なったら重くなるのでマルチスレッド化します。
今回は先にソースコードを紹介して、あとで説明します。

TimelineクラスのUpdate()の代わりに
非同期版の以下のメソッドを呼び出すようにします。



  /// <summary>
  /// Update()の非同期版
  /// </summary>
  public virtual void UpdateAsync() {
   _backgroundWorker = new BackgroundWorker();
   _backgroundWorker.DoWork += this.getItemsAsync;
   _backgroundWorker.RunWorkerCompleted += this.getItemsCompleted;
   _backgroundWorker.RunWorkerAsync();
  }


  /// <summary>
  /// BackgroundWorkerのDoWork
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  protected virtual void getItemsAsync(object sender, DoWorkEventArgs e) {
   //タイムラインからアイテムを取得する
   //※別スレッドで実行される
   using (var response = WebUtility.GetResponse(_url, "GET", new RequestParameter[] { }, _userInfo))
   using (var stream = response.GetResponseStream()) {
    e.Result = _itemsReader.Read(stream).ToList<ITimelineItem>();
   }
  }

  /// <summary>
  /// BackgroundWorkerのCompleted
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  protected virtual void getItemsCompleted(object sender, RunWorkerCompletedEventArgs e) {
   //取得したアイテムを通知する
   //※UIスレッドで実行される
   var result = e.Result as List<ITimelineItem>;
   foreach (var item in result) {
    if (!AllItems.Contains(item)) {
     OnReadNewItem(item);
    }
   }
  }


今回の変更はほぼそれだけです。
マルチスレッドの場合はそれに応じた例外処理も必要ですが、
それはまだしていません。

さて、コードは簡単ですが、起こっていることを理解するのは難しいかもしれませんので
少々詳しく説明します。
BackgroundWorkerをすでに知っていてコードだけでわかった人は以下不要です。

ちなみに、私自身もマルチスレッドはあまり書いたことがないので、
正直以下の説明に自信がありません。
信頼性50%以下くらいだと思って読んでください(汗

読んでいる人がスレッドくらいは知っているものとして進めます。
でもすごく簡単に説明すると、スレッドは「処理の流れ」です。
スレッドを2つ作ると2つの処理を同時に走らせることができます。
実際には2つの処理をかなり短い単位に区切って交互に実行して
平行動作しているように見せる場合もあれば、
マルチコアのCPUでは本当に2つの処理が同時に走っているかもしれません。

WPFはプログラム側でスレッドを作らない限り、
2つのスレッドが動いています。
UIスレッドとレンダリングスレッドです。(用語は正しくないかもしれません)
レンダリングスレッドは描画の高速化のため使われているらしく、
あんまり気にしなくて良さそうです。
つまり今まで書いてきたコードは全部UIスレッド上で動いています。
1つのスレッドで動いているということは
1つの処理が滞れば全部滞るということです。

UIスレッドはDispatcherで処理の順番が管理されていて、
例えばClickイベント処理や描画処理などがキューに登録されて、
優先度を考慮して順番に処理されていきます。
その中にTimerイベント呼び出される、タイムラインをtwitterAPIで取ってくる処理も登録されるので、
twitterが重くてAPIが反応を返さない(返すのが遅い)ときは、
UIの更新やClickの反応の処理の順番がなかなか回ってこないことになり、
アプリケーションの反応が鈍かったりフリーズしたりしているように見えることになります。

ということで、twitter APIの反応を待つところを別スレッドにします。

別スレッドで処理するときは主に3つのことを考えないといけません。
まず、一般的なマルチスレッド処理で問題になる
共有リソースへのアクセスの排他処理です。
あるスレッドがタイムラインのアイテムのリストを読み込んでいる最中に
別のスレッドがリストを追加・削除したりするとおかしなことになるかもしれません。
2つめは、WindorsFormアプリケーションやWPFアプリケーションでは、
UIのControl(Window自体やその上のテキストボックスやラベルなど)を作ったスレッドしか
そのControlを更新できません。
つまり、別スレッドを作ってそこでtwitterのデータを読み込んでいる時に、
直接Windowのラベルに進捗状況やエラーメッセージを書くようなことはできません。
3つめは、別スレッドでの例外は元スレッドで直接catchできません。

で、スレッドを使用するにはいろいろな方法がありますが、
今回はBackgroundWorkerを使います。
More Effective C#でもスレッドプールやBackgroundWorkerを使えと書いてありますので、
興味のある方は読んでみてください。

このクラスを使うと、上に書いた3つの問題のうち最初の2つは現時点では
ほとんど何もしないでもあまり問題が起こらなそうです。

ちょっとためしてみたところ、
BackgroundWorkerのDoWorkイベントは新しく作られた別スレッドで実行され、
RunWorkerCompletedは元スレッドで実行されるようです。

最初のコードに戻って、getItemsAsync()は自分のTimelineのデータにしかアクセスしないので、
他に同時に実行されているタイムラインのデータに干渉しません。
getItemsCompleted()の方はアイテム追加のイベントをViewModelのクラスが受け取るので、
MultiSourceTimelineとかで複数のタイムラインからイベントがあがってくると
問題が起こるかなと思いましたが、
元のUIスレッドで実行されるっていうことは、
(タイムラインに要求を出した順番とは違うかもしれませんが)
同時にではなくおそらく反応が戻ってきた順にDispatcherのキューに登録され順次実行されるので、
同時アクセスの問題は起こらないかなと思います。

タイムラインの読込待ちがすごく長くなって、
同じタイムラインの次のTimerでの呼び出しが起こると同時アクセスでおかしなことになるかもしれませんが、
そこまで長引くことはあまりないかとおもい、今回はやっていません。

ちなみにvirtualにして、TwitterTimelineクラスでoverrideしたものも書いています。
また、もともと1つのUpdate()を2つのメソッドに分けたため、
ToList()を使っています。(そうしなければUsingの外でstreamを読んでおかしなことになります)

問題の3つめの例外の件は今回対処していませんので、
処理を書いたらそのうちまたブログで書きます。

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

2010年3月21日日曜日

第27回 OnOff(トグル)できる画像付きボタンを作る

その前に、気がついたら前回作ったBindingGroupのところが動かなくなってしまいました。
今回うまくいかなくていろいろいぢったので、それがいけないのか、
前回のコードの時点でまずかったのか未調査です。
今回のコードでは元に戻しています(→Cancelボタンで抜けてもタイムラインへの変更は反映される)
修正したらまた記事載せます。
すみません。すみません。

今回は、お気に入り機能に対応します。
お気に入りボタンは、お気に入りにしていないときは☆、
お気に入りにしたら★の画像が表示されるボタンにします。
このボタンを押すことでON、OFFを切り替えます。

まず、画像のソースをSkinsフォルダのxamlに追加します。


 <BitmapImage x:Key="FavoriteImage" UriSource="icons/16x16/star_full.png"/>
 <BitmapImage x:Key="FavoriteEmptyImage" UriSource="icons/16x16/star_empty.png"/>



次に、新しくボタン用のクラスを作ります。
これは以前作った画像表示ボタンImageButtonの派生クラスとして作ります。

 /// <summary>
 /// On,Offの状態を保持して、状態にあわせた画像を保持するクラス
 /// </summary>
 class ToggleImageButton : ImageButton {
  private bool _isOn = false;
  private BitmapImage _alternativeImage;

  /// <summary>
  /// コンストラクタ
  /// </summary>
  /// <param name="description">ボタンの機能の簡潔な説明</param>
  /// <param name="command">ボタン押下時のコマンド</param>
  /// <param name="_onImage">Onの画像。Imageプロパティで取得する</param>
  /// <param name="_offImage">Offの画像。AlternativeImageプロパティで取得する</param>
  public ToggleImageButton(string description, ICommand command, BitmapImage _onImage, BitmapImage _offImage)
   : base(description, command, _onImage) {
   _alternativeImage = _offImage;
  }

  /// <summary>
  /// Offの時の画像
  /// </summary>
  public BitmapImage AlternativeImage {
   get { return _alternativeImage; }
  }

  public bool IsOn {
   get {
    return _isOn;
   }
   set {
    if (_isOn != value) {
     _isOn = value;
     OnPropertyChanged("IsOn");
    }
   }
  }
 }


簡単ですね。OnOffの状態と、Offの時の画像を持たせただけです。
このクラス自体はそこまで能動的に動きません。
あとIsOnの変更を伝えるために、もとのImageButtonクラスをViewModelBaseから派生させました。
(OnPropertyChangedを使うため)

TimelineItemViewModelにコマンドを持たせて、
FavoriteのAPIを叩く部分はModelに置きました。
これまでどおりのやり方でAPIを叩くだけなので、特に説明はしません。
実際のAPIのやりとりはFavoritesMethodクラスあたりを観てください。

あとはTimelineViewにDataTemplateを置きます。
通常時のImageをStyleのsetterで指定して、
IsOnがFalseの時のImageをTriggerの中のSetterで指定しています。


  <DataTemplate DataType="{x:Type util:ToggleImageButton}">
   <Button Command="{Binding Path=Command}" ToolTip="{Binding Path=Description}"
     Background="Transparent" BorderBrush="Transparent">
    <Image Width="16" Height="16">
     <Image.Style>
      <Style TargetType="{x:Type Image}">
       <Setter Property="Source" Value="{Binding Path=Image}"/>
       <Style.Triggers>
        <DataTrigger Binding="{Binding Path=IsOn}" Value="False">
         <Setter Property="Source" Value="{Binding Path=AlternativeImage}"/>
        </DataTrigger>
       </Style.Triggers>
      </Style>
     </Image.Style>
    </Image>
   </Button>
  </DataTemplate>


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

2010年3月18日木曜日

第26回 タイムラインの削除に対応する

これまで手を抜いて
タイムラインの追加ができても削除ができない状態だったので、
削除をできるようにします。

半分は以前やったリストの選択の回と同じ内容ですが。。

まずOptionDialog.xamlにて、
アイテムを選択できるように、ItemsControlにしていたところをListBoxに変更します。



     <ListBox x:Name="timelines" ItemsSource="{Binding Path=TimelineSettings}"
        IsSynchronizedWithCurrentItem="True"
        ItemContainerStyle="{StaticResource TimelineSettingItemContainer}"
        HorizontalContentAlignment="Stretch" ScrollViewer.HorizontalScrollBarVisibility="Disabled"/>

リソースで見た目を変更するようにStyleを追加します。
選択時のエフェクトも追加します。


  <Style x:Key="TimelineSettingItemContainer" TargetType="{x:Type ListBoxItem}">
   <Setter Property="Template">
    <Setter.Value>
     <ControlTemplate TargetType="{x:Type ListBoxItem}">
      <GroupBox x:Name="ItemContainer" Header="{Binding Path=TimelineTypeName}" Padding="5">
       <ContentPresenter/>
      </GroupBox>
      <ControlTemplate.Triggers>
       <!--選択アイテムのエフェクト-->
       <Trigger Property="IsSelected" Value="True">
        <Setter TargetName="ItemContainer" Property="BorderThickness" Value="3"/>
        <Setter TargetName="ItemContainer" Property="BorderBrush" Value="DarkGray"/>
       </Trigger>
      </ControlTemplate.Triggers>
     </ControlTemplate>
    </Setter.Value>
   </Setter>
  </Style>

見た目がわかりにくい気もしますが、とりあえずおいておきます。
ControlTemplateに枠を描くようにしたので、
その中に表示される各アイテムのDataTemplateからは枠を削除しています。
あと見た目の関係でちょこちょこいぢっています。

削除コマンドも追加します。


<Button Command="{Binding Path=RemoveTimelineCommand}" CommandParameter="{Binding ElementName=timelines, Path=SelectedItem}">タイムライン削除</Button>

CommandParameterで削除のターゲットを指定しています。
ElementNameで最初に作ったListBoxを指すようにし、
ListBoxのSelectedItemプロパティの値がParameterとして渡されます。

各DataTemplateの中にはBindingGroupが追加されていることに注意してください。


    <StackPanel.BindingGroup>
     <BindingGroup Name="OptionBinding"/>
    </StackPanel.BindingGroup>

BindingGroupにより設定変更の確定、変更のキャンセルを制御していますが、
Listの先までには自動的に伝搬してくれないみたいなので、
(↑つまり以前まではタイムラインのURL書き換えてキャンセルボタンで閉じても反映されていた)
各情報にNameプロパティを追加したBindingGroupを追加することで
同時にCancelなどが伝搬するようにしています。

あとはリストへの追加・削除をキャンセルする方法がわからなかったので、
自前で無理矢理作りました。

OptionViewModelで変更前のリストを持っていて
キャンセルされたら戻しています。


  public void CommitEdit() {
   //特に何もしない
  }

  public void BeginEdit() {
   _timelineSettingsBeforeEdit = new List<TimelineSetting>(TimelineSettings);
  }

  public void CancelEdit() {
   if (TimelineSettings.SequenceEqual(_timelineSettingsBeforeEdit)) {
    return;
   }
   _option.TimelineSettings = new ObservableCollection<TimelineSetting>(_timelineSettingsBeforeEdit);
  }


あとはOptionDialogのイベントから呼び出しています。



  private void Window_Loaded(object sender, RoutedEventArgs e) {
   _target.BeginEdit();
   BindingGroup.BeginEdit();
   PasswordBox.Password = _target.UserInfo.Password;
  }

  private void OkButton_Click(object sender, RoutedEventArgs e) {
   _target.CommitEdit();
   BindingGroup.CommitEdit();
   _target.UserInfo.Password = PasswordBox.Password;
   (this.DataContext as OptionViewModel).Save();
   this.DialogResult = true;
   this.Close();
  }

  private void CancelButton_Click(object sender, RoutedEventArgs e) {
   _target.CancelEdit();
   BindingGroup.CancelEdit();
   this.Close();
  }


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

2010年3月15日月曜日

番外編 細かいいろいろ

今回は大きなトピックはないものの、以下の3点を追加修正しました。
1.名前をクリックしたときに、Webページではなく、ローカルに持っているその人のつぶやきを集めて表示
2.アイコンの指定を外部Xamlファイルに移動
3.オプションダイアログにバージョン情報のページを追加

まず1の変更から。
正直、変更前とどちらが使いやすいかは、自分でもしばらく使ってみないとわからないのですが。。

変更はすごく簡単です。
キャッシュをすでに持っている(TimelineItemVMCacheくらす)ので、
そこに、特定のユーザーのつぶやき一覧を取得するメソッドを追加します。


  /// <summary>
  /// 指定したユーザーのすべてのアイテム(発言)を取得
  /// </summary>
  /// <param name="userName">取得したいユーザーの名前</param>
  /// <returns></returns>
  public IEnumerable<TimelineItemViewModel> GetAllItemsOf(string userName) {
   foreach (var item in _items) {
    if (item.Name == userName) {
     yield return item;
    }
   }
  }



あとはTimelineItemViewModelにShowUsersAllItemCommandコマンドを作って
リンククリック時のコマンドをこちらに変更しています。
あたらしいWindowで開くのは既存の仕組みを使っています。

ちなみにこれまでの「ブラウザで開く」は右クリックメニューに追加しています。

2の変更について。
まず注意として、アイコンのフォルダをSkinsフォルダの下に移動しています。
これはxamlファイルからの相対位置指定になるみたいだからです。

以下のようなxamlファイルを作ります。


<ResourceDictionary
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
 <BitmapImage x:Key="AppImage" UriSource="icons/32x32/chess.png"/>
 <BitmapImage x:Key="SubmitImage" UriSource="icons/32x32/comment.png"/>
 <BitmapImage x:Key="SettingImage" UriSource="icons/32x32/tools.png"/>
 <BitmapImage x:Key="CommentsImage" UriSource="icons/16x16/comments.png"/>
 <BitmapImage x:Key="ReTweetImage" UriSource="icons/16x16/megaphone.png"/>
 <BitmapImage x:Key="ReturnMessageImage" UriSource="icons/16x16/mail_send.png"/>
</ResourceDictionary>

前回SkinsフォルダのxamlファイルはすべてResourceとして読み込まれるようにしていたので、
上記ファイルも自動で読み込まれます。

各BitmapImageを使っていた場所は以下のように
アプリケーションのResourceから取得するように変更しています。

  #region static member
  private static BitmapImage _replyImage = App.Current.FindResource("CommentsImage") as BitmapImage;
  private static BitmapImage _reTweetImage = App.Current.FindResource("ReTweetImage") as BitmapImage;
  private static BitmapImage _replyToMailImage = App.Current.FindResource("ReturnMessageImage") as BitmapImage;
  #endregion


Windowにもアイコンが付くようにしました。
しかしWindowのアイコンは上記方法では指定できなかったので、
AppクラスのOnStartUpに直接以下のように書いています


   mainWindow.Icon = BitmapFrame.Create(new Uri(baseUri + @"\Skins\icons\32x32\chess.png"));


これでユーザーがアイコンを変更しやすくなると思います。

3の変更について
バージョン情報とかはAssemblyInfo.csで管理すべきなのかもしれませんが、
ぶっちゃけここで指定するとXAMLやコードで読み込むのがめんどくさそうだったので、
(本当は簡単なやりかたがあるのかもしれません)

というわけで、アプリケーション情報をもつクラスを作って、


 /// <summary>
 /// 使用しているライブラリ等の情報
 /// </summary>
 class RefLib {
  /// <summary>
  ///
  /// </summary>
  /// <param name="name">ライブラリ名</param>
  /// <param name="url">参照URL</param>
  /// <param name="useFor">このアプリでの使用目的</param>
  public RefLib(string name, string url, string useFor) {
   Name = name;
   Url = url;
   UseFor = useFor;
  }
  public string Name { get; set; }
  public string Url { get; set; }
  public string UseFor { get; set; }
 }

 /// <summary>
 /// アプリケーション情報
 /// </summary>
 class AppInfo {
  public const string AppName = "すとれおじさん";
  public const string Version = "0.01";
  public const string Copyright = "Copyright ©  2010 yuki";
  public const string Url = "http://wtwitter.codeplex.com/";
  public static RefLib[] RefLibs = new RefLib[] {
   new RefLib("OAuthLib", "http://oauthlib.codeplex.com/", "OAuth認証"),
   new RefLib("DryIcons", "http://dryicons.com/", "アイコン"),
  };
 }

一応AppInfo.csに渡します。


[assembly: AssemblyProduct(AppInfo.AppName)]
[assembly: AssemblyCopyright(AppInfo.Copyright)]

実際のバージョン情報の表示はUserControlを1個作って、


<UserControl x:Class="WTwitter.View.AboutView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:app="clr-namespace:WTwitter"
 xmlns:util="clr-namespace:WTwitter.ViewModel.Utility">
 <StackPanel>
  <GroupBox Header="バージョン情報" Margin="3" Padding="3">
   <StackPanel>
    <StackPanel Orientation="Horizontal">
     <TextBlock Text="{x:Static app:AppInfo.AppName}" FontWeight="Bold" />
     <TextBlock> Version </TextBlock>
     <TextBlock Text="{x:Static app:AppInfo.Version}"/>
    </StackPanel>
    <TextBlock>
     <Run>URL: </Run>
     <Hyperlink Command="{x:Static util:CommonCommands.OpenByBrowser}"
          CommandParameter="{x:Static app:AppInfo.Url}">
      <TextBlock Text="{x:Static app:AppInfo.Url}"/>
     </Hyperlink>
    </TextBlock>
   </StackPanel>
  </GroupBox>
  <GroupBox Header="このアプリケーションで利用しているライブラリ" Margin="3" Padding="3">
   <ItemsControl ItemsSource="{x:Static app:AppInfo.RefLibs}">
    <ItemsControl.ItemTemplate>
     <DataTemplate>
      <StackPanel Margin="3">
       <TextBlock Text="{Binding Path=UseFor}"/>
       <StackPanel Margin="10,0,0,0">
        <TextBlock Text="{Binding Path=Name}" FontWeight="Bold"/>
        <TextBlock>
         <Hyperlink Command="{x:Static util:CommonCommands.OpenByBrowser}"
              CommandParameter="{Binding Path=Url}">
          <TextBlock Text="{Binding Path=Url}"/>
         </Hyperlink>
        </TextBlock>
       </StackPanel>
      </StackPanel>
     </DataTemplate>
    </ItemsControl.ItemTemplate>
   </ItemsControl>
  </GroupBox>
 </StackPanel>
</UserControl>


OptionDialogではそれをおいているだけです。


   <TabItem Header="バージョン情報">
    <view:AboutView/>
   </TabItem>



ブラウザで開くコマンドはよく使うので
CommonCommandsというクラスを作って、staticメンバとして入れました。
XAMLの表示がやや悩みましたが、x:Staticを使えばできました。

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

2010年3月14日日曜日

第25回 選択アイテムのスタイルを変更する

WPFでListBoxを使うと、デフォルトの配色は選択アイテムが青くなると思います。
(Windowsの画面の配色設定次第で変わるかもしれません)
今作っているクライアントも選択(クリック)したつぶやきは背景が青くなりますね。
これは結構見づらいと思うので、選択アイテムは枠に影が付くように変更したいと思います。

TimelineView.xamlだけをいじります。

正直に言うと、前回まで
DataTemplateとControlTemplateの違いがよくわかっていませんでした。
今でも正しく理解できたかどうか怪しいので、
以降の説明ももしかしたら違っているかもしれないと思いながら読んでください。

なので、最初に私の理解を説明します。
間違っていることに気づいた人は指摘してください。

まずControlTemplateというのはControlの見た目などをカスタマイズするための
テンプレートですね。そのまま。
Controlはボタンとかリストボックスとか、見た目を提供するとともに
Clickイベントなどの制御(=Control)も提供する、あるまとまった単位の部品。
Controlとしては、ほとんどの場合、.net環境で用意されているものを使う。

DataTemplateもそのまま、データのテンプレート。
データというのは文字列だったり、クラスのインスタンスだったり、XMLのデータだったり。
今作っているものだと、TimelineItemViewModelとかですね。
データを変更するロジックを持っていても、UIの制御はほとんどの場合持たない。

例えば、タイムラインを表示する場合、ListBox(=Control)があって、
ListBoxはListBoxItem(=Control)をアイテムの数だけ持っていて、
ListBoxItemの上に、つぶやき(=Data)が載っているイメージ。
ここでいうつぶやき(=Data)にはアイコンから名前、テキストまで含まれる。
ListBoxItemが入れ物でつぶやきが中身、みたいな感じですね。

で、これまではすべてDataTemplateで表示していました。
ですが、ControlTemplateでも同じようなことができます。

これまでのDataTemplateでやるやりかたは、
ListBoxとListBoxItemがあって、
その上に載っているTimelineViewModelのインスタンス(=データ)を
DataTemplateを使ってどう表示するかを指定していたような感じです。
ですが、DataTemplateなので入れ物はカスタマイズできません。
(できるかもしれませんが、めんどくさいと思われます)
選択アイテム(選択行)の色を青色にするのはListBoxItemの役目なので、
その上に載っているデータのテンプレートをいじっても変更が難しいというわけですね。

対して、全部ControlTemplateでやろうとすると、
ListBoxItemを、
画像、名前やつぶやきのテキスト、コマンド用のボタンなどに対して
どの位置にどのような形で表示するかのフォーマットを決めるControlとしてカスタマイズにして、
そのカスタマイズされたListBoxItemが要求する画像URLやテキストのデータとして
TimelineItemViewModelの各プロパティをおいていくイメージ。

で、どっちを使うかは設計者の判断しだいのような気もしますが、
今回はどちらを使うべきか考えてみます。

私の考えだと、各つぶやきの外側の灰色の枠(Border)までが入れ物で、
それより中のもの(画像、名前、ボタン、本文テキスト)がデータです。
例えば、タイムラインにtwitpicの画像などフォーマットが異なるデータを表示するように
拡張するときのことをことを考えます。
そうすると、灰色の枠までが共通で、それより中身のデータが変わるでしょう。

※もちろん、アイコン、名前、~分前、ボタンまでは共通フォーマットだ、という考えもできると思います。

とりあえず、以上の考えで書き直してみたものが以下です。
選択されているアイテムがDropShadowのエフェクトがかかるようになっています。



<!--タイムライン表示部分のView-->
 
<UserControl x:Class="WTwitter.View.TimelineView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:compModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
 xmlns:view="clr-namespace:WTwitter.View"
 xmlns:vm="clr-namespace:WTwitter.ViewModel">
 <UserControl.Resources>
  <view:TextComponentsConverter x:Key="TextCompConverter"/>

  <!--アイコン付きボタンのテンプレート-->
  <DataTemplate DataType="{x:Type vm:ImageButton}">
   <Button Command="{Binding Path=Command}" ToolTip="{Binding Path=Description}"
     Background="Transparent" BorderBrush="Transparent">
    <Image Source="{Binding Path=Image}" Width="16" Height="16"/>
   </Button>
  </DataTemplate>

  <DataTemplate x:Key="TimelineItemDataTemplate">
   <DockPanel>
    <!--アイコン-->
    <Image Source="{Binding Path=ProfileImageUrl}" Width="32" Height="32"
        DockPanel.Dock="Left" VerticalAlignment="Top" Margin="5">
     <Image.ToolTip>
      <StackPanel>
       <TextBlock Text="{Binding Path=DetailDescription}"/>
      </StackPanel>
     </Image.ToolTip>
    </Image>
    <DockPanel DockPanel.Dock="Top">
     <!--名前-->
     <TextBlock DockPanel.Dock="Left">
      <Hyperlink Command="{Binding Path=OpenByBrowserCommand}" CommandParameter="{Binding Path=UserUrl}"
           TextDecorations=""
           Style="{DynamicResource TimelineItemNameStyle}">
       <TextBlock Text="{Binding Path=Name}"/>
      </Hyperlink>
      <TextBlock.ContextMenu>
       <ContextMenu>
        <MenuItem Header="このユーザーを_NGに登録" Command="{Binding Path=SetNgUserCommand}"/>
       </ContextMenu>
      </TextBlock.ContextMenu>
     </TextBlock>
     <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
      <!--時刻-->
      <TextBlock Text="{Binding Path=RelativeTimeString}"/>
      <!--ボタン-->
      <ItemsControl ItemsSource="{Binding Path=Commands}">
       <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
         <StackPanel Orientation="Horizontal"/>
        </ItemsPanelTemplate>
       </ItemsControl.ItemsPanel>
      </ItemsControl>
     </StackPanel>
    </DockPanel>
    <!--本文-->
    <ContentPresenter Style="{DynamicResource TimelineItemTextStyle}"
       Content="{Binding Path=TextComponents, Converter={StaticResource TextCompConverter}}"/>
   </DockPanel>
  </DataTemplate>
  
  <Style TargetType="{x:Type ListBoxItem}" x:Key="{x:Type ListBoxItem}">
   <Setter Property="Template">
    <Setter.Value>
     <ControlTemplate TargetType="{x:Type ListBoxItem}">
      <!--アイテムの枠-->
      <Border BorderBrush="LightGray" BorderThickness="2" Background="White"
        CornerRadius="3" Margin="3" Padding="2"
        x:Name="ItemContainer">
       <ContentPresenter ContentTemplate="{StaticResource TimelineItemDataTemplate}"/>
      </Border>
      <ControlTemplate.Triggers>
       <!--選択アイテムのエフェクト-->
       <Trigger Property="IsSelected" Value="True">
        <Setter TargetName="ItemContainer" Property="BitmapEffect">
         <Setter.Value>
          <OuterGlowBitmapEffect GlowSize="5" GlowColor="Black" Noise="0" Opacity="1"/>
         </Setter.Value>
        </Setter>
       </Trigger>
      </ControlTemplate.Triggers>
     </ControlTemplate>
    </Setter.Value>
   </Setter>
  </Style>

 </UserControl.Resources>
 
 <ListBox ItemsSource="{Binding Path=ViewSource.View}"
    ScrollViewer.HorizontalScrollBarVisibility="Disabled"
    HorizontalContentAlignment="Stretch">
 </ListBox>
</UserControl>



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

2010年3月13日土曜日

第24回 スキンに対応する

見た目を簡単に変更できるように、
Styleなどの見た目に関する部分を
外部ファイルに移していきます。

dllなどにしても置き換えるだけで変更できますが、
これだと開発者しか作れないので、
テキストのxamlファイルを動的に読み込むようにします。
テキストなので手軽に編集できますが、
起動時に読み込む分、動作は若干遅くなると思います。

ちなみにWPFのテーマ、スキンを扱う方法に関しては
エッセンシャルWPFという本に結構詳しく書いてあります。

まずスキンは起動ディレクトリのSkinフォルダに入っているxamlすべてということに決めます。
App.xaml.csで、すべての.xamlファイルを読み込み、
ResourceDictionaryに統合します。
AppクラスのResourcesに入れたStyleなどは
アプリケーションのすべての場所に適用されます。
(個々の場所で上書きされた場合を除く)



   //スキンの読込
   var baseUri = Directory.GetParent(
    System.Reflection.Assembly.GetExecutingAssembly().Location).FullName;
   ResourceDictionary rd = null;
   foreach (var file in Directory.GetFiles(baseUri + @"\Skins", @"*.xaml")) {
    var theme = XamlReader.Load(XmlReader.Create(file)) as ResourceDictionary;
    if (rd == null) {
     rd = new ResourceDictionary();
     foreach (DictionaryEntry entry in this.Resources) {//既存のリソースを読込
      rd[entry.Key] = entry.Value;
     }
    }
    rd.MergedDictionaries.Add(theme);
   }
   if (rd != null) {
    this.Resources = rd;
   }



あとは、以下のような内容が入ったxamlファイルを用意します。


<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
   
    <Style x:Key="{x:Type Button}" TargetType="{x:Type Button}">
      <Setter Property="FontFamily" Value="Meiryo UI"/>
    </Style>
   
    <Style x:Key="{x:Type TextBlock}" TargetType="{x:Type TextBlock}">
      <Setter Property="FontFamily" Value="Meiryo UI"/>
    </Style>
</ResourceDictionary>

この例では、アプリケーション中のTextBlockとButtonのすべてのフォントが
Meiryo UIになるように変更しています。

特定の場所にだけ適用したい場合はスキンファイルで


<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Style x:Key="TimelineItemNameStyle" TargetType="{x:Type Hyperlink}">
      <Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="TextDecorations" Value="None"/>
    </Style>
   
    <Style x:Key="TimelineItemTextStyle" TargetType="{x:Type ContentPresenter}">
    <Setter Property="Margin" Value="3"/>
    </Style>
</ResourceDictionary>

のようにKeyを設定して、
使う場所(View)でStyleを指定します。


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


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

Skin関係のファイルはbinフォルダを降りたところにあるので注意してください。

2010年3月12日金曜日

第23回 フィルタを作る

普通のタイムラインであれば、ストレスになる発言があればフォローを外せばいいのですが、
Search APIを使ったタイムラインだとそうもできないので、
IDでフィルタリングする機能を作ります。


将来的には本文中のNGワードとかタイムラインごとの個別のフィルタにも対応するかもしれませんが、
今回はIDだけで判断します。


まずフィルタの設定情報を保持するクラス。Optionが持ちます。




 public class FilterSetting {
  public FilterTargetType Target { get; set; }
  public List<string> ngList { get; set; }
 }

Optionクラスの中にもいくつか初期化を入れています。

フィルタは、「引っかかったらタイムラインに追加しない」だけなら簡単なんですが、
すでに表示されてしまったものを「このユーザーは非表示」などにして後から消すのはちょっと面倒です。

今回は、フィルタが変更されたことを通知する仕組みをフィルタ自身に入れて、
タイムラインがそれを監視する仕組みにします。


フィルタを行うクラスの抽象クラス。


 /// <summary>
 /// 1種類のフィルタを表すクラス
 /// </summary>
 public abstract class Filter {
  public Filter() {
   FilterAdded += (sender, e) => { };
   FilterRemoved += (sender, e) => { };
  }

  /// <summary>
  /// フィルタ処理をする
  /// </summary>
  /// <param name="target">判定するアイテム</param>
  /// <returns>OKならtrue,NGならfalse</returns>
  abstract public bool Judge(ITimelineItem target);

  /// <summary>
  /// フィルタにNGワードなどが追加がされたことを知らせるイベント
  /// ※実際に何が追加されたかは通知しないが、このイベント後はフィルタされるべきアイテムが増えることを意味する。
  /// ※各タイムラインでフィルタをやり直すなど
  /// </summary>
  public event EventHandler FilterAdded;

  /// <summary>
  /// フィルタからNGワードなど削除がされたことを知らせるイベント
  /// ※実際に何が追加されたかは通知しないが、このイベント後はフィルタされるべきアイテムが減ったことを意味する。
  /// </summary>
  public event EventHandler FilterRemoved;

  protected void OnFilterAdded() {
   FilterAdded(this, EventArgs.Empty);
  }

  protected void OnFilterRemoved() {
   FilterRemoved(this, EventArgs.Empty);
  }
 }



その実装。





 /// <summary>
 /// アイテムのIDに対してフィルタリングするクラス
 /// </summary>
 class NgFilterById : Filter {
  #region static member
  /// <summary>
  /// 共通フィルタのインスタンス
  /// ※共通のフィルタはこのプロパティを通じて設定、取得する
  /// ※シングルトンではないので、タイムラインごとに固有のIDフィルタを持つ場合は別途インスタンス化できる
  /// </summary>
  public static NgFilterById Common { get; private set; }

  static NgFilterById() {
   Common = new NgFilterById(new string[]{});
  }
  #endregion

  #region private member
  private List<string> _ngList;
  #endregion

  /// <summary>
  /// コンストラクタ
  /// </summary>
  /// <param name="ngList">インスタンス化時に登録するNGワードのリスト</param>
  public NgFilterById(IEnumerable<string> ngList) {
   _ngList = new List<string>(ngList);
  }

  #region Public Member
  public void AddNg(string ngWord) {
   _ngList.Add(ngWord);
   OnFilterAdded();
  }

  public void RemoveNg(string ngWord) {
   _ngList.Remove(ngWord);
   OnFilterRemoved();
  }
  #endregion

  #region INgFilter メンバ

  override public bool Judge(ITimelineItem target) {
   if (_ngList.Contains(target.ScreenName)) {
    return false;
   }
   return true;
  }

  #endregion
 }


で、どこにフィルタの処理を入れるかですが、
今回はTimelineViewModelで処理します。
ここは悩んだ点なんですが、今の作りでユーザーに見えるタイムラインに1対1に対応しているのは
ModelではなくてViewModelなので、
タイムラインごとの個別フィルタというのちのちの拡張を考えた場合、
ユーザーに見える単位でフィルタを制御した方がわかりやすいかなと思いました。

というわけで、MainWindowViewModelでフィルタを生成して、
各TimelineViewModelにコンストラクタ経由で渡しています。
(コード略)

TimelineViewModelの処理は以下のようになります。
・アイテムが追加されたときにフィルタを通すように変更
・フィルタが増減したときに、差分のフィルタに引っかかるアイテムを消したり、の処理


  /// <summary>
  /// ソースタイムラインにアイテムが追加されたときの処理
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  private void OnItemAdded(object sender, ItemAddedEventArgs e) {
   if (CheckFilter(e.Item) == false) {
    return;
   }
   AllItems.Add(new TimelineItemViewModel(e.Item, _timeline.UserInfo));
  }

  /// <summary>
  /// フィルタに変更があった時の処理(フィルタが増えたとき)
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  private void OnFilterAdded(object sender , EventArgs e) {
   var filter = sender as Filter;

   //新しいフィルタでNGになるアイテムを抽出
   var removeTargets = from item in _allItems
          where filter.Judge(item.Item) == false
          select item;

   //メモ:ToList()で新しいリストにしないと
   //次のforeachがIEnumerableのままになり例外発生
   var targets = removeTargets.ToList<TimelineItemViewModel>();

   foreach (var item in targets) {
    _allItems.Remove(item);
   }
  }

  /// <summary>
  /// フィルタに変更があったときの処理(フィルタが減ったとき)
  /// </summary>
  /// <param name="sender"></param>
  /// <param name="e"></param>
  private void OnFilterRemoved(object sender, EventArgs e) {
   //メモ:とりあえずAddの対称として作ったけど、まだ使っていないので動作未確認

   //既存のフィルタで引っかかっているアイテムを、ModelのアイテムをViewModelのアイテムを比較して抽出
   var filtered = from source in _timeline.AllItems
         where _allItems.Any(item => source.Equals(item.Item)) == false
         select source;
   //フィルタ済みのもので、新しいフィルタリストではNGならないものを抽出
   var addTargets = from item in filtered
        where CheckFilter(item) == true
        select item;
   var targets = addTargets.ToList<ITimelineItem>();

   foreach (var item in targets) {
    _allItems.Add(new TimelineItemViewModel(item, _timeline.UserInfo));
   }
  }

  /// <summary>
  /// すべてのフィルタをチェックする
  /// </summary>
  /// <param name="target">フィルタチェック対象のアイテム</param>
  /// <returns>表示OKなアイテムならtrue、フィルタに引っかかるならfalse</returns>
  private bool CheckFilter(ITimelineItem target) {
   if (_filters.Any(filter => filter.Judge(target) == false)) {
    return false;
   } else {
    return true;
   }
  }

コメントを書いたのでたぶんわかりますよね。。。
from .. where ... selectは前に一度出ていたと思いますが、わからなければLINQというキーワードを調べてください。

あとはTimelineItemViewModelにコマンドSetNgUserCommandを追加。(略)

最後に、Viewに追加。
どこでもいいんですが、とりあえず名前を右クリックしたら出るようにしました。


       <TextBlock DockPanel.Dock="Left">
        <Hyperlink Command="{Binding Path=OpenByBrowserCommand}" CommandParameter="{Binding Path=UserUrl}" >
         <TextBlock Text="{Binding Path=Name}"/>
        </Hyperlink>
        <TextBlock.ContextMenu>
         <ContextMenu>
          <MenuItem Header="このユーザーを_NGに登録" Command="{Binding Path=SetNgUserCommand}"/>
         </ContextMenu>
        </TextBlock.ContextMenu>
       </TextBlock>


オプション画面で管理するUIとか作っていないけど、今回はここまで。
いったん付けたフィルタを削除するには設定のXMLから直接消すしかないです。今んところ。

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