コメント中の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
すみません。明らかにおかしいバグがありました。
返信削除TextComponentsConverterは
最新のソースコードを参照してください。
http://wtwitter.codeplex.com/SourceControl/changeset/view/41801
=>の中で、その外の変数を参照すると、
『実行時に評価される』ので、参照先のアイテムが変化しています。
foreachの3巡目に位置するアイテムがClickされても、
Clickされた時にはforeachが回りきっているので、
itemは異なるオブジェクトを参照していると言うことです。