Loading [MathJax]/extensions/tex2jax.js

2025年5月6日火曜日

UNIX時間と日付時刻を相互変換する

UNIX時間という、1970年1月1日からの経過秒数を使って日付時刻を表示する手法があります。ただ、この大きな数字だけを見てもこれが一体何年何月何日なのかを知ることはできません。困りましたね。

世の中のモダンなフレームワークや処理系には、このUNIX時間とカレンダー形式を変換する機能を備えています。例えば.NET FrameworkではDateTimeOffset.FromUnixTimeSecondsメソッドで簡単に変換できます。C言語には仕様上そういうものはないですが、そもそもUNIX時間とはUNIX用のC言語処理系でtime.hで定義されているtime_t型がそのように時間を表現していたことに由来します。ですので、UNIX含め、他の処理系でもtime_t型の中身がUNIX時間として使える処理系も結構あったりします。

一方で、そのtime_tをUNIX時間として扱っている処理系の中には2038年問題を抱えているものもあります。 time_tを符号付32bit整数として定義してしまっているがために、2038年にオーバーフローしてしまうというものです。私が子供のころにはまだまだ先だなと思っていたものですが、気づいたらもう13年を切ってしまいましたね。時の流れは早いものです。

この2038年問題の回避をするために手っ取り早い方法はtime_tを符号付64bit整数型を使うことです。ただ、処理系として対応していない場合はオレオレ実装をしなければならず、結構厄介です。特に、1970年1月1日からの日数をカレンダーに変換しようとしたときにはうるう年という強敵が立ちはだかります。面倒ですね。

というわけで、C#でオレオレ実装を作ってみました。実際はマイコンで使いたかったので、型名や参照ポインタなどの簡単な置換でC言語でも動かせることを意識して実装しています。

カレンダー構造体の実装

まずは、カレンダー構造体ことstruct tmのオレオレ実装をしておきます。64bitを想定してtm64としました。

1
2
3
4
5
6
7
8
9
10
public struct tm64
{
    public byte tm_sec;      // 秒
    public byte tm_min;      // 分
    public byte tm_hour;     // 時
    public byte tm_mday;     // 日
    public byte tm_mon;      // 月
    public ushort tm_year;   // 年
    public byte tm_wday;     // 曜日 [0:日 1:月 ... 6:土]
}

tm_yday(年始からの経過日数)はめんどいので実装を省いています。また、tm_yearはC言語では1900年からの経過年数となっていますが、今回はわかりやすく西暦そのままで表現することとします。tm_monは、C言語では0~11で表現されます。なぜ現実世界の暦が0月0日からスタートではないのかという話は置いておいて、これもバグの温床であることは歴史が物語っているので、今回は1~12での表現としておきます。

ちなみにtm_yearをushort型とした場合は西暦約6万5千年までしか表現できないから64bitを生かしきれないのでは?と思うかもしれません。実際、time_tを64bitにすることで原理的には西暦3000億年くらいまで正常に時間をカウントできるようになります。それより小さい型を使うなんてナンセンスだ!と思うかもしれませんが、そもそも西暦6万5千年まで僕の作ったソフトを使う人なんていないので考えないことにしておきます(そうやって2038年問題が再生産されるのであった…)。

UNIX時間→カレンダーへの変換

さて、まずはUNIX時間からカレンダーへの変換を実装していきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
public static void Clock_ToTm(ulong time, ref tm64 tm)
{
    Clock_ToTm_Date((uint)(time / 86400), ref tm);
    Clock_ToTm_Time((uint)(time % 86400), ref tm);
}
 
private static void Clock_ToTm_Date(uint dayfrom1970, ref tm64 tm)
{
    tm.tm_wday = (byte)((dayfrom1970 + 4) % 7);   // 1970/1/1は木曜日
 
    uint dayfrom16000301 = dayfrom1970 + 135080;    // 1600/3/1基準で考える
    ushort year_div400 = (ushort)(dayfrom16000301 / 146097);   // 400年単位が何回あったか
    uint day400y = dayfrom16000301 % 146097;   // 400年単位で3/1からの日数
    if(day400y == 146096) { // 最後の日のうるう日
        tm.tm_year = (ushort)(1600 + (year_div400 + 1) * 400);
        tm.tm_mon = 2;      //2月
        tm.tm_mday = 29;    //29日
    } else {
        byte year_div100 = (byte)(day400y / 36524); // 100年単位が何回あったか
        ushort day100y = (ushort)(day400y % 36524); // 100年単位で3/1からの日数
 
        byte year_div4 = (byte)(day100y / 1461);    // 4年単位が何回あったか
        ushort day4y = (ushort)(day100y % 1461);    // 4年単位で3/1からの日数
 
        if(day4y == 1460) { // 最後のうるう日
            tm.tm_year = (ushort)(1600 + year_div400 * 400 + year_div100 * 100 + (year_div4 + 1) * 4);
            tm.tm_mon = 2;      //2月
            tm.tm_mday = 29;    //29日
        } else {
            byte year_div1 = (byte)(day4y / 365);    // 1年単位が何回あったか
            ushort day1y = (ushort)(day4y % 365);    // 1年単位で3/1からの日数
 
            tm.tm_year = (ushort)(1600 + year_div400 * 400 + year_div100 * 100 + year_div4 * 4 + year_div1);
 
            if(day1y < 184) {
                if(day1y < 92) {
                    if(day1y < 31) {
                        tm.tm_mon = 3;  // 3月
                        tm.tm_mday = (byte)(day1y + 1);
                    } else if(day1y < 61) {
                        tm.tm_mon = 4;  // 4月
                        tm.tm_mday = (byte)(day1y - 30);
                    } else {
                        tm.tm_mon = 5;  // 5月
                        tm.tm_mday = (byte)(day1y - 60);
                    }
                } else {
                    if(day1y < 122) {
                        tm.tm_mon = 6;  // 6月
                        tm.tm_mday = (byte)(day1y - 91);
                    } else if(day1y < 153) {
                        tm.tm_mon = 7;  // 7月
                        tm.tm_mday = (byte)(day1y - 121);
                    } else {
                        tm.tm_mon = 8;  // 8月
                        tm.tm_mday = (byte)(day1y - 152);
                    }
                }
            } else {
                if(day1y < 275) {
                    if(day1y < 214) {
                        tm.tm_mon = 9;  // 9月
                        tm.tm_mday = (byte)(day1y - 183);
                    } else if(day1y < 245) {
                        tm.tm_mon = 10;  // 10月
                        tm.tm_mday = (byte)(day1y - 213);
                    } else {
                        tm.tm_mon = 11;  // 11月
                        tm.tm_mday = (byte)(day1y - 244);
                    }
                } else {
                    if(day1y < 306) {
                        tm.tm_mon = 12;  // 12月
                        tm.tm_mday = (byte)(day1y - 274);
                    } else if(day1y < 337) {
                        tm.tm_year++;
                        tm.tm_mon = 1;  // 1月
                        tm.tm_mday = (byte)(day1y - 305);
                    } else {
                        tm.tm_year++;
                        tm.tm_mon = 2;  // 2月
                        tm.tm_mday = (byte)(day1y - 336);
                    }
                }
            }
        }
    }
}
 
private static void Clock_ToTm_Time(uint daysec, ref tm64 tm)
{
    ushort daymin = (ushort)(daysec / 60);
    tm.tm_sec = (byte)(daysec % 60);
    tm.tm_min = (byte)(daymin % 60);
    tm.tm_hour = (byte)(daymin / 60);
}

UNIX時間を86400で割った余り、すなわち時分秒部分は超シンプルなので説明は割愛します。残りの1970年1月1日からの日数をカレンダーに変換する方法についてしっかり説明していきます。

まず、うるう年というのは4年に1回(西暦が4で割り切れる年)にやってきますが、西暦が100で割り切れる年はうるう年になりません。ただし、西暦が400で割り切れる年はうるう年となります。なぜこんなに面倒くさいルールなのかと言えば、地球が太陽の周りを公転するのにかかる時間が365.24219日だからです。うるう年を400年で97回にすることで、この端数に近似しようとしているんですね。

うるう日は2月29日に挿入されるので、400年ごとということも踏まえて、1600年3月1日からの日数を数えることにしましょう。400年は146,097日ですので、その日数を146,097で割って余りが146,096の場合は400年に一度の2月29日です。146,096未満の場合はそうではないので、今度はそれを100年の日数36,524で割ってみましょう。その余りが100年未満部分の日数なので、それを4年の日数1,461日で割ると、余りが1,460の場合は4年に一度の2月29日です。そうでなければうるう日ではありません。

このように、うるう日になりうる日を最終日に置くことで、剰余算でうるう日かどうかの判定を簡単にできるようになります。最後は365日で割った余りを得ることによって、3月1日起算で1年のうちの何日目かを求めることができます。これならば、あとは力業で何月かを振り分ければ終わりです。

振り分ける際は順に比較しても良いですが、こういうのは二分探索していったほうが早いので、まずは184日未満か(=3~8月か)を判断して、その中でさらに92日未満か(=3~5月か)を判断してという形で絞り込んでいっています。

こうすることで、複雑なうるう年判定をできるだけ構造化して実装することができました。

カレンダー→UNIX時間への変換

続いてカレンダーからUNIX時間への変換を実装していきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public static ulong Clock_FromTm(ref tm64 tm)
{
    if(tm.tm_year < 1970)
        return 0;
 
    uint days = Clock_FromTm_Date(ref tm);
    uint time =  Clock_FromTm_Time(ref tm);
 
    return (ulong)days * 86400 + time;
}
 
private static uint Clock_FromTm_Date(ref tm64 tm)
{
    ushort year = tm.tm_year;
    byte month = tm.tm_mon;
    while(month < 1) {
        month += 12;
        year--;
    }
    while(month > 12) {
        month -= 12;
        year++;
    }
 
    ushort yearfrom16000301 = (ushort)(year - ((month >= 3) ? 1600 : 1601));
 
    byte year_div400 = (byte)(yearfrom16000301 / 400);
    ushort year400y = (ushort)(yearfrom16000301 % 400);
 
    byte year_div100 = (byte)(year400y / 100);
    byte year100y = (byte)(year400y % 100);
 
    byte year_div4 = (byte)(year100y / 4);
    byte year4y = (byte)(year100y % 4);
 
    uint day = (uint)((uint)year_div400 * 146097 + year_div100 * 36524 + year_div4 * 1461 + year4y * 365);      // 1600年3月1日からの日数を計算
 
    if(month <= 6) {
        if(month <= 3) {
            if(month == 1)
                day += (uint)tm.tm_mday + 305;
            else if(month == 2)
                day += (uint)tm.tm_mday + 336;
            else
                day += (uint)tm.tm_mday - 1;
        } else {
            if(month == 4)
                day += (uint)tm.tm_mday + 30;
            else if(month == 5)
                day += (uint)tm.tm_mday + 60;
            else
                day += (uint)tm.tm_mday + 91;
        }
    } else {
        if(month <= 9) {
            if(month == 7)
                day += (uint)tm.tm_mday + 121;
            else if(month == 8)
                day += (uint)tm.tm_mday + 152;
            else
                day += (uint)tm.tm_mday + 183;
        } else {
            if(month == 10)
                day += (uint)tm.tm_mday + 213;
            else if(month == 11)
                day += (uint)tm.tm_mday + 244;
            else
                day += (uint)tm.tm_mday + 274;
        }
    }
 
    return day - 135080;    // 1970年1月1日からの日数になるように差し引く
}
 
private static uint Clock_FromTm_Time(ref tm64 tm)
{
    return ((uint)tm.tm_hour * 60 + tm.tm_min) * 60 + tm.tm_sec;
}

冒頭でwhileループを2つ回しているのは、月が1~12の範囲外だった時に適切に年にオーバーフローできるようにするためです。

以降の考え方は同じで、1600年3月1日からの経過年数をまず求め、うるう年の条件に合わせて振り分けてその年数に対応する日数を計算していきます。あとは、現在が何月何日かという情報から、3月1日から経過している日数を足すことで1600年3月1日からの経過日数を正確に求めていきます。そして最後に135,080日を引くことで1970年1月1日からの日数に換算しています。

まとめ

前述の通り、本当にこれで64bitをフルに使えるようになっているのかと言えば「いいえ」ですが、少なくとも当面は困らない実装ができました。

今回はわかりやすさ(=実装のしやすさ)を優先してシンプルな実装にしましたが、 例えばここ数十年の間では2000年~2099年の日付が流れてくるだろうと想定して、この範囲だったらこの範囲で最も早く計算できるような仕掛けを作るなどしても良かったかもしれません。4の割り算をするだけなら、組み込みみたいなパワーの小さいプロセッサでもシフト演算で簡単に実装できますしね。

2025年3月8日土曜日

Twitter(現X)にAPI経由でつぶやく2025年版

Twitter(現X)のAPIが大きく制限されてから久しいですが、久しぶりに最新のAPIを触りましたので記録しておきます。

まず、Twitter APIの認証にはOAuth 1.0aとOAuth 2.0の2種類がサポートされていますが、ユースケースによってそれらを使い分ける必要があります。2024年10月頃まではOAuth 2.0経由のツイートの投稿ができていたようですが、その後使えなくなったようです。

2025年現在の対応表は以下のようになっています。

今回はツイートを投稿しますので、OAuth 1.0a認証を行います。

APIキーの取得

何はともあれAPIキーを取得しなければ始まりません。Twitter Developer Accountにアクセスして、アプリを登録し、キーを確認します。

ここにある「API Key and Secret」と「Access Token and Secret」の計4種類を控えておきます。 API Key and Secretはアプリ特有のもの、Access Token and Secretは投稿するアカウント特有のものですが、そもそも無料アプリでは1アプリ&1アカウントまでしか使用できないので、これをそのまま使えば良いでしょう。

コード

投稿は以下のコードにて行います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class TwitterClient(HttpClient httpClient, string consumerKey, string consumerSecret, string accessToken, string tokenSecret)
{
    public async Task<HttpResponseMessage> PostTweetAsync(string text)
    {
        var timstamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
        var nonce = CreateNonce();
        var body = JsonSerializer.Serialize(new Dictionary<string, string>() { { "text", text } });
        var uri = new Uri("https://api.twitter.com/2/tweets");
 
        var request = new HttpRequestMessage {
            RequestUri = uri,
            Method = HttpMethod.Post,
            Content = new StringContent(body, Encoding.ASCII, "application/json")
        };
 
        var signatureBase64 = CreateSignature(uri.ToString(), "POST", nonce, timstamp);
 
        request.Headers.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("OAuth",
                $@"oauth_consumer_key=""{Uri.EscapeDataString(consumerKey)}""," +
                $@"oauth_token=""{Uri.EscapeDataString(accessToken)}""," +
                $@"oauth_signature_method=""HMAC-SHA1""," +
                $@"oauth_timestamp=""{Uri.EscapeDataString(timstamp)}""," +
                $@"oauth_nonce=""{Uri.EscapeDataString(nonce)}""," +
                $@"oauth_version=""1.0""," +
                $@"oauth_signature=""{Uri.EscapeDataString(signatureBase64)}""");
 
        return await httpClient.SendAsync(request);
    }
 
    private string CreateSignature(string url, string method, string nonce, string timestamp)
    {
        var query = HttpUtility.ParseQueryString("");
        query.Add("oauth_consumer_key", consumerKey);
        query.Add("oauth_nonce", nonce);
        query.Add("oauth_signature_method", "HMAC-SHA1");
        query.Add("oauth_timestamp", timestamp);
        query.Add("oauth_token", accessToken);
        query.Add("oauth_version", "1.0");
 
        var signatureBaseString = $"{method}&{Uri.EscapeDataString(url)}&{Uri.EscapeDataString(query.ToString() ?? "")}";
        var compositeKey = $"{Uri.EscapeDataString(consumerSecret)}&{Uri.EscapeDataString(tokenSecret)}";
 
        using var hasher = new HMACSHA1(Encoding.ASCII.GetBytes(compositeKey));
        return Convert.ToBase64String(hasher.ComputeHash(Encoding.ASCII.GetBytes(signatureBaseString)));
    }
 
    static readonly Random random = new Random();
 
    private static string CreateNonce()
    {
        var nonce = new StringBuilder(32);
 
        for(int i = 0; i < 32; i++) {
            var rnd = random.Next(62);
            if(rnd < 26)
                nonce.Append((char)('A' + rnd));
            else if(rnd < 52)
                nonce.Append((char)('a' + rnd - 26));
            else
                nonce.Append((char)('0' + rnd - 52));
        }
 
        return nonce.ToString();
    }   
}

コンストラクタに引き渡すconsumerKey, consumerSecret, accessToken, tokenSecretは先ほど控えた4つのキーです。そのうえでPostTweetAsyncメソッドに投稿内容を投げてあげればつぶやけます。意外と簡単ですね。Twitter Developer Accountのサイトからキーを全て入手できるので、認証の面倒な手続きを実装する必要もありません。

ちなみに、 無料アカウントでは24時間あたり17件までしかツイートできません。実装したアプリをいろいろ試しているうちにあっという間に返ってくるHTTPステータスコードが429 Too Many Requestsになってしまいます。そうなったら諦めて24時間待ちましょう。HTTP Responseの詳細の中に"x-user-limit-24hour-reset"というものがあり、これをUnix timeとして読むと制限が解除される日時がわかります。

まあこんなんじゃ無料でAPIをいじってみようかとか、ライブラリ作って公開しようかとかいう人が全然現れなくなるのもやむを得ないですよね。まあ、APIを叩いてほしくないからこういうふうにしているのでしょうが。

2024年10月26日土曜日

Prism + DryIoc 入門③(ダイアログ編)

前回から少し時間が空きましたが、Prism+DryIoc入門の第3弾です。一応これで一区切りのつもりです。 

前回はPrism+DryIocでプロジェクトを立ち上げるお作法を紹介しました。これに加えてWPFの基礎知識がある皆さんはそれなりのソフトを作ることができると思います。ですが、ひとつ足りないことがありました。それはダイアログの表示です。今回はそのダイアログの表示の方法について説明していきます。 

ダイアログの実装

さて、通常、ダイアログを開くとなるとウィンドウを作ることになりますが、PrismではUserControlを作ることになります。ちょうどウィンドウ内でRegionを作るのと同じような感じですね。

順に説明していきます。

1. Viewの作成

「追加」→「新しい項目」からPrism UserControl (WPF)を追加します。

出来上がったXAMLファイルに前回の記事に書いたとおりのお作法でIgnorebleなどを設定します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<UserControl x:Class="PrismTest.Views.TestDialog"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >
 
    <Grid />
</UserControl>

2. VideModelの作成

ViewModelにはIDialogAwareを実装する必要があります。これはPrismにこれはダイアログですよと教えるためのものですね。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
    }
 
    public string Title => "Test Dialog";
 
    public event Action<IDialogResult>? RequestClose;
 
    public bool CanCloseDialog() => true;
 
    public void OnDialogClosed()
    {   
    }
 
    public void OnDialogOpened(IDialogParameters parameters)
    {
    }
}

タイトルやいくつかのメソッド、ダイアログを閉じる際に呼ぶイベントなどを実装します。特段説明をしなくても、その名前から何のためのものかはすぐにわかると思います。

3. Viewをダイアログとして登録

App.xaml.csを開き、RegisterTypesメソッド中にて作った型をダイアログとして登録します。これを登録することで、PrismがこのViewがダイアログであることを認識してくれます。

1
2
3
4
5
6
7
8
9
10
11
12
public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }
 
    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterDialog<TestDialog>("TestDialog");
    }
}

これも前回の記事までを読んだ人なら特に困ることは無いと思います。

4. ダイアログを表示

さて、ダイアログの表示も至って簡単です。ライブラリ無しではMVVMのポリシーを保ちつつViewModelから別ウィンドウを表示するのがとても面倒だったので、さすがPrismという感じですね。

1
2
3
4
5
6
7
8
9
public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => dialog.ShowDialog("TestDialog"));
    }
 
    public DelegateCommand ShowDialogCommand { get; }
}

適当なViewModelでIDialogServiceを受け取るようにすると、DryIoc経由でダイアログ関係の機能にアクセスできるようになります。ダイアログを表示するにはShowDialogメソッドを呼ぶだけです。引数はApp.xaml.csで設定した名前ですね。

このコマンドを呼び出すと、無事ダイアログが表示されました。

ダイアログを作りこむ

1. ウィンドウスタイル

さて、ダイアログが表示されましたが、よく見ると最大化/最小化ボタンなどが表示されていますね。Windows11のデザインではわかりにくいですが、ウィンドウもリサイズ可能なものになっています。さらにタスクバーを見るとダイアログが単体のアイコンを持っています。イケてないですね。


端的に、普通のウィンドウが表示されたと言えます。ダイアログはもっとダイアログっぽいスタイルでウィンドウを表示したいですね。

これに関しては、UserControlにprism:Dialog.WindowStyle添付プロパティを設定してやれば良いです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<UserControl x:Class="PrismTest.Views.TestDialog"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >
 
    <prism:Dialog.WindowStyle>
        <Style TargetType="Window">
            <Setter Property="prism:Dialog.WindowStartupLocation" Value="CenterOwner" />
            <Setter Property="ResizeMode" Value="NoResize"/>
            <Setter Property="ShowInTaskbar" Value="False"/>
            <Setter Property="SizeToContent" Value="WidthAndHeight"/>
        </Style>
    </prism:Dialog.WindowStyle>
 
    <Grid />
</UserControl>

Windows11のデザインではわかりにくいですが、ウィンドウ枠が細枠(サイズ変更不可)になっており、最小化/最大化のボタンもなくなっています。もちろんタスクバーにも表示はされていません。

2. データの受け渡しをする

さて、ダイアログを表示するときは何かしらのデータを引き渡したいし、ダイアログを閉じた時には入力されたデータを受け取りたいことがあります。安心してください、Prismではそのような機能もサポートしています。

ContentRegionViewModel.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => {
            var param = new DialogParameters() { { "Text", Text }, };
 
            dialog.ShowDialog("TestDialog", param, result => {
                if(result.Result == ButtonResult.OK)
                    Text = result.Parameters.GetValue<string>("Text");
            });
        });
    }
 
    public DelegateCommand ShowDialogCommand { get; }
 
    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

呼び出し側のViewModelはこんな感じです。ShowDialogの第2引数にDialogParametersを渡します。名前こそDialogParametersですが、単純なディクショナリ型ですので、任意のデータを入れ込むことができます。

第3引数はダイアログから制御が返ってきたときに呼び出されるデリゲートです。下のダイアログ側のViewModelを見てからのほうがわかりやすいと思いますので、ひとまずそちらを見てから解説します。

TestDialogViewModel.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
        OkButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.OK, new DialogParameters() { { "Text", Text } }));
        CancelButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.Cancel));
    }
 
    public string Title => "Test Dialog";
 
    public void OnDialogOpened(IDialogParameters parameters)
    {
        Text = parameters.GetValue<string>("Text");
    }
 
    public void OnDialogClosed()
    {
    }
 
    public bool CanCloseDialog() => true;
 
    protected void RaiseRequestClose(ButtonResult btnres, IDialogParameters param)
    {
        RequestClose?.Invoke(new DialogResult(btnres, param));
    }
    protected void RaiseRequestClose(ButtonResult btnres)
    {
        RequestClose?.Invoke(new DialogResult(btnres));
    }
    public event Action<IDialogResult>? RequestClose;
 
 
    public DelegateCommand OkButtonCommand { get; }
    public DelegateCommand CancelButtonCommand { get; }
 
    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

OnDialogOpenedメソッドはIDialogAwareインターフェースのメンバーの一つですが、ダイアログが開いたときにPrismがこのメソッドを呼んでくれます。この引数が、呼び出し元がパラメーターとして指定したものになります。今回は呼び出し物が指定したTextを読み取って、Textプロパティに代入しています。 

OKボタン/Cancelボタンが押されたときにそれぞれRequestCloseイベントを発動させています。その引数にはDialogResultクラスで、どのボタンが押されたかとDialogParametersをコンストラクタに渡してあげることで、その値を呼び出し元に返すことができます。

そして、呼び出し元のコードでは、OKボタンが押されたことを受けて、ShowDialogメソッドの第3引数のデリゲートが呼ばれますので、そこでButtonResultやDialogParametersを読み取って、適宜何かしらの処理を追加してやれば良いです。

ちなみに、ShowDialogメソッドは同期的に呼ばれます。すなわち、第3引数のデリゲートが制御を返した後にShowDialogメソッドは制御を返します。じゃあなんでShowDialogメソッドの戻り値をIDialogResultにしてくれなかったんだ…。

番外編:標準ダイアログを表示する

さて、ダイアログの表示のしかたは分かったけど、MessageBoxやファイルを開くダイアログなどのWindows標準ダイアログを表示させるにはどうしたら良いでしょう?

結論から言うと、Prismではサポートしていません。今まで説明してきた通り、ダイアログを実装するための支援が充実しているから、MessageBoxやファイルを開くダイアログは自作してねということのようです。

確かに、最近そういったダイアログも独自実装されたアプリをしばしば見かけますが、個人的にはソウジャナイ感をどうしても感じてしまいます。そういう標準ダイアログは、アプリを作る側の省力化だけでなく、使う側としてもどのアプリを使っても同じデザインだからOSとして統一感があり使いやすいわけです。それをわざわざ「自分で実装しろ」というのはいかがなものか…。

というわけで、標準ダイアログを表示するためにはLivetを使いましょう。大丈夫、PrismとLivetも共存できます。

まずは NugetからLivet.Messagingを入れます。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel()
    {
        Messenger = new InteractionMessenger();
 
        ShowMessageBoxCommand = new DelegateCommand(() => Messenger.Raise(new InformationMessage("MessageBox Text", "Caption", System.Windows.MessageBoxImage.Information, "InformationMessageBox")));
    }
 
    public InteractionMessenger Messenger { get; }
 
    public DelegateCommand ShowMessageBoxCommand { get; }
}

その後、ViewModelにInteractionMessengerクラスのプロパティを一つ用意してあげます。Livet標準のViewModel基底型ならもとからこのプロパティを持っているのですが、当然ながらPrismには無いので後付けしてやる必要があります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<UserControl x:Class="PrismTest.Views.ContentRegion"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:ContentRegionViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             d:Height="350" d:Width="525" >
 
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="InformationMessageBox">
            <l:InformationDialogInteractionMessageAction InvokeActionOnlyWhenWindowIsActive="False" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
     
    <Grid>
        <Button Content="Show MessageBox" Command="{Binding ShowMessageBoxCommand}" Width="150" Height="40" />
    </Grid>
</UserControl>

ViewではInteractionMessageTriggerを使い、ViewModelからのメッセージを受け取ってやれば良いです。今回はOKボタンのみのMessageBoxを表示させていますが、その他の種類のMessageBoxだったりファイルを開くダイアログだったりは、対応する***Message型/***InteractionMessageAction型に差し替えれば表示することができます。ここらへんはLivetの使い方ですので、だいぶ昔に書いたこちらの記事を見ていただければ良いかと思います。

Prismの入門記事でPrismではなく別のライブラリを使えというのも変な話ですが、ライブラリによって当然得手不得手というものはありますから、それによって組み合わせが発生するのはやむを得ないことと思います。

***

以上で、3回にわたって連載(?)してきたPrism+DryIocの入門編ですが、ひとまず今回で終わりにしたいと思います。また何か思いついたテーマがあれば随時記事にします。

2024年10月20日日曜日

.NET時代のWPFでMessageBoxにビジュアルスタイルを適用する

WPFには一応標準のMessageBoxが付いています。Windows 95以降(Win32APIにて)標準で装備されているMessageBoxをWPFから呼び出す機能ですね。Livetなどではちゃんとそれを使うための機能が備わっているため、未だにお世話になります。

ですが、実はこのMessageBox、そのままではあまりイケた見た目になりません。

MessageBoxの見た目 

早速MessageBoxを表示させてみましょう。
1
2
3
4
5
6
7
8
9
10
11
<Button HorizontalAlignment="Center" VerticalAlignment="Center" Height="30" Width="150" Content="Show MessageBox" >
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <l:InformationDialogInteractionMessageAction InvokeActionOnlyWhenWindowIsActive="False" >
                <l:DirectInteractionMessage>
                    <l:InformationMessage Caption="Test" Text="Test MessageBox" Image="Information" />
                </l:DirectInteractionMessage>
            </l:InformationDialogInteractionMessageAction>
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>

見てくださいこのボタン。四角くて段付きのボタン!25年前のデザインです。

まじめな話をすると、Windows Me及びWindows 2000まではこのデザインでした。いずれも2000年発売のOSです。その後、Windows XPの発売に合わせて「ビジュアルスタイル」と呼ばれる見た目が導入されました。それを有効にすると、ボタンが少しフラットな見た目になります。

良いですね!ボタンがフラットなデザインになりましたし、アイコンも少しモダンな感じななりました。 

余談ですが、WPFが正式にリリースされたのは(=WPFが初めて組み込まれた.NET Framework 3.0がリリースされたのは)2006年のことですから、これらビジュアルスタイルはそれよりもだいぶ古い技術の話になります。WPFでは見た目を非常に高い柔軟性でいじることができますが、それよりも前はコントロールの見た目もそんなに柔軟に変えられるものではありませんでした。MicrosoftがWin32APIの中で提供するコントロールを使って自分のアプリを作成しなければならない時代では、やはりこういった全体的な見た目にかかわる機能というのはやはり当時は注目度が高かったわけです。

.NET時代のビジュアルスタイル有効化

さて、ネットで少し調べると、まあ古い技術であるため、Win32APIの話(C++のアプリ作成の話)だったり、Windows Formsの話だったり、.NET Framework時代の話だったり、昔の記事がたくさん見つかります。.NET時代の設定はどうしたら良いのでしょうか。

結論から言えば簡単です。

まず、ソリューション エクスプローラーからプロジェクトを右クリックし、「追加」→「新しい項目」を選択します。

つづいて「アプリケーション マニフェスト ファイル」を選択し、そのまま「追加」ボタンを押します。


そうすると、プロジェクト直下にapp.manifestが追加されるので、それを開きます。下のほうを見ると「Windows のコモン コントロールとダイアログのテーマを有効にします (Windows XP 以降)」と書いてあるブロックがあるので、その部分のコメントを外します。

最後にこれをビルドすれば、ビジュアルスタイルが適用されたMessageBoxが表示されます。

--------------

以上です。

2024年10月13日日曜日

WPFでReactivePropertyを使う際のお作法

今回はWPFでReactivePropertyを使う際のお作法を備忘録的に紹介していこうと思います。

ReactivePropertyとはReactive Extensions (Rx)ベースで作られたプロパティを提供するライブラリです。INotifyPropertyChangedを実装しているためViewModelにそのまま使用することができ、また、LINQを使って値の伝搬を表現することができるため、Statefulなアプリを簡単に実装することができる、非常に強力なライブラリです。

1. 導入

Nugetからダウンロードできます。

ReactiveProperty.WPFは入れなくても一応使えないことは無いのですが、あとでハマることになりますので、WPFアプリなら思考停止でとりあえず入れてしまいましょう。

2. UIスレッドへの転送設定

WPFには単一のスレッドからしかUI要素にアクセスできないという仕様があります。ですので、ViewModelにてイベントをそのスレッドに転送してやらねばならないのですが、ReactivePropertyには便利なことに特定のスレッドでイベントを発生させる機能があります。

さて、転送するにあたって、当然ながらどのスレッドがUIスレッドかということをライブラリに教え込まなければなりません。それは、App.xaml/App.xaml.csに以下のコードを追加することで行います。

1
2
3
4
5
6
7
8
9
10
<Application x:Class="WpfTest.App"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfTest"
             StartupUri="MainWindow.xaml"
             Startup="Application_Startup">
     
    <Application.Resources>
    </Application.Resources>
</Application>
1
2
3
4
5
6
7
public partial class App : Application
{       
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        ReactivePropertyScheduler.SetDefault(new ReactivePropertyWpfScheduler(Dispatcher));
    }
}

アプリのスタートアップ時に、Application.DispatcherをReactivePropertySchedulerとして登録するという作業になります。ReactivePropertyWpfSchedulerはReactiveProperty.WPFに入っているクラスで、WPF向けのスケジューラーです。

3. ReactivePropertyとReactivePropertySlim

さて、これで準備が整いましたので、実際に使用していきます。

ReactiveProperty(ライブラリ)には、ReactiveProperty(クラス)とReactivePropertySlim(クラス)があります。ReactivePropertyは色々な機能を持っているが故にパフォーマンスが悪めなのですが、ReactivePropertySlimは機能を絞ってパフォーマンスをかなり改善しています。そのReactivePropertySlimで削られた機能として、主に以下の2つがあります。

  • UIスレッドへの自動転送機能
  • 入力値のバリデーション機能

いずれも、ViewModelとしては必要な機能です。ですので、基本的にViewModelではReactivePropertyModelではReactivePropertySlimを使用すると良いでしょう。

MainWindowViewModel
1
2
3
4
5
6
7
8
9
10
11
12
public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;
    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;
 
        Text = model.Text.ToReactivePropertyAsSynchronized(p => p.Value);
    }
 
    public ReactiveProperty<string> Text { get; }
}
IMainWindowModel
1
2
3
4
public interface IMainWindowModel
{
    IReactiveProperty<string> Text { get; }
}
MainWindowModel
1
2
3
4
5
6
7
8
9
10
11
public class MainWindowModel : IMainWindowModel
{
    public MainWindowModel()
    {
        Text = new ReactivePropertySlim<string>();
        Text.Subscribe(p => System.Diagnostics.Debug.WriteLine(p));
    }
 
    public ReactivePropertySlim<string> Text { get; }
    IReactiveProperty<string> IMainWindowModel.Text => Text;
}

このコードでは、Prism前提でModelとViewModelを疎結合にするため、間にIMainWindowModelを挟んでいます。そこまでの抽象化が必要ない場合は適宜コードを読み替えてください。

ReactivePropertyとReactivePropertySlimを双方向に同期させるためにはToReactivePropertyAsSynchronized()を使います。片方向(Model→ViewModel)の場合はToReadOnlyReactiveProperty()で良いでしょう。

--------------

以上です。その他の細かい使い方は公式ドキュメントを参照ください。

2024年9月29日日曜日

ListView Extensions ver.1.3.1

ListView Extensions ver.1.3.1をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • ソート対象のプロパティ名を入れ子(プロパティのプロパティの…のプロパティ)にできるようにした。
  • SortableGridViewColumnにSortingMemberPathを追加した。この項目に何も設定していない場合(nullの場合)は今まで通りDisplayMemberBindingsのパスをソートのキーとして扱うが、この項目を設定した場合はこれをキーとしてソートされる。
  • ListViewViewModel (ReadOnlyUIObservableCollectionを継承しているクラス)でIndexerとCountのPropertyChangedイベントが発生しなかった不具合を修正
  • ListViewの子要素にComboBoxなどがあると、その選択が変化したときに例外が発生する不具合を修正

下2つはバグ修正ですので、上2つについて説明していきます。

ソート対象のプロパティ名を入れ子(プロパティのプロパティの…のプロパティ)にできるようにした

今までは、ソートのキーにするプロパティ名は、SortableObservableCollectionの要素のプロパティしかできませんでした。ですので、少々わざとらしいですが、例えば以下のようなクラスがあったとしたら、person.Name.Spellなどのいわゆる「プロパティのプロパティ」はソートキーに指定することができませんでした(こちらのコードはgithubに公開しているサンプルコードの抜粋です)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class PersonViewModel : ViewModel
{
    // 中略
     
    public NameViewModel? Name
    {
        get => _Name;
        set => RaisePropertyChangedIfSet(ref _Name, value);
    }
    NameViewModel? _Name;
 
    public string Age => $"{model.Age}歳";
 
    public string Birthday => model.Birthday.ToShortDateString();
 
    public string Height => $"{model.Height_cm}cm";
 
    // 中略
}
 
public class NameViewModel : ViewModel
{
    // 中略
     
    public string? Spell
    {
        get => model.Spell;
        set => model.Spell = value;
    }
 
    public string? Pronunciation
    {
        get => model.Pronunciation;
        set => model.Pronunciation = value;
    }
}

それをできるようにしたという変更です。

これの真価を発揮するのは、ReactivePropertyを使ってViewModelを作ったときです。ReactivePropertyでは、person.Name.Valueなどの形でプロパティにアクセスする必要があるため、必ず2段以上入れ子になります。

実は今までもListVIewViewModelのコンストラクタでプロパティの読み替え用のディクショナリを、SortableObservableCollectionの引数でIComparerのディクショナリを渡すことで無理やり使うことはできなくはなかったのですが、回りくどい方法でソースコードの負荷が高まってしまうためあまりイケている方法ではありませんでした。ですが、今回のアップデートで、DisplayMemberBindingsに表現したとおりのパスを辿るようになったので、ViewModelやModelでは何も書かずに入れ子プロパティを参照できるようになりました。

SortableGridViewColumnにSortingMemberPathを追加した

ListViewのヘッダーはSortableGridViewColumnで作ることができます。ここで、それぞれのヘッダーに対応するソートのキーとするプロパティは、そのままDisplayMemberBindingsのパスを用いていましたが、それを任意のプロパティに設定できるようにしました。

通常は別の名前のプロパティにする必要は無いとは思いますが、例えば、ViewModelとViewでプロパティ名が異なる場合は、実際はソート操作自体はModel(SortableObservableCollection)にて行っているためプロパティ名の読み替えが必要になっていました。前述した通り、ViewModelにはプロパティ名読み替え用にディクショナリを受け取るコンストラクタがありますが、SortingMemberPathで直接設定できるようになりました。

1
2
3
4
5
6
7
8
9
10
11
12
<GridView>
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name.Spell}" Header="Name" />
    <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name.Pronunciation}" Header="Pronunciation" />
    <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" SortingMemberPath="Height_cm" Header="Height" />
    <GridView.ColumnHeaderContainerStyle>
        <Style TargetType="lv:SortableGridViewColumnHeader">
            <Setter Property="SortingArrowLocation" Value="Top" />
        </Style>
    </GridView.ColumnHeaderContainerStyle>
</GridView>

このコードでは、5つ目の「Height」のプロパティ名を「Height_cm」と指定しています。

ちなみにもう一点この機能が必須のところがあって、SortableGridViewColumnにてCellTemplateを指定する場合です。セルの中身をDataTemplateで表現したい場合に使うものですが、DisplayMemberBindingsを設定しているとそちらのほうが優先されてしまいCellTemplateが働きません。その際もこのSortingMemberPathを指定することで、DataTemplateを使いながらソート用のプロパティも指定できるようになりました。

ちなみに、DisplayMemberBindingsはBindingBase型のプロパティですが、SortingMemberPathはString型です。ですので、Visual StudioでIntelliSenseは働きませんのでご注意ください。

--------------

変更点の説明は以上です 。

実際自分でこのライブラリを使い込んでいくといろいろと見つかりますね。まあ、WPFの全貌は10年以上触っていてもよくわからないので、こうやってブラッシュアップしていくしかないですね…。

2024年9月23日月曜日

ListView Extensions ver.1.3.0

ListView Extensions ver.1.3.0をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • 選択項目の同期をListViewSelectedItemsActionからSelectedItemsSync.Source添付プロパティ経由で行うようにした。
  • Obsolete指定していたSortedHeaderを削除

後者は古い機能が削除されただけなので、前者について説明します。

選択項目の同期をListViewSelectedItemsActionからSelectedItemsSync.Source添付プロパティ経由で行うようにした。

ListViewにはSelectedItemsプロパティがあり、複数項目を選択したときはここから選択項目をすべて取得することができますし、このリストをプログラムから操作することで選択項目を変更することができます。しかし、これはViewModelをバインディングできないのです。

というのも、見ての通りこれはget専用プロパティであり、ViewModelのにIListのプロパティを作ってバインディングしようとしてもsetすることができないのです。Mode=OneWayToSourceにしてみてもやはり上手くいきません。BinadbleAttributeが付いているプロパティなのに一体どういうことなんでしょうね。

ということで、ver.1.2.0までのListViewExtensionsではListViewSelectedItemsActionというものを用意していました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="ContextMenu">
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Increment the age" Command="{Binding IncrementAgeCommand}" />
                        <MenuItem Header="Decrement the age" Command="{Binding DecrementAgeCommand}" />
                    </ContextMenu>
                </Setter.Value>
            </Setter>
            <!--<Setter Property="lv:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
            <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
</ListView>

ただし、これを使うのには癖がありすぎました。先日これを使おうとしたところ自分でもめちゃめちゃハマりましたし、ハマった方も多かったのではないでしょうか。

まず、これは適当なタイミングでViewModelからこのアクションを発動させないと同期しません。

1
2
3
4
5
6
7
public void Initialize()
{
    model = MainWindowModel.GetInstance();
 
    People = new ListViewViewModel<PersonViewModel, PersonModel>(model.People, person => new PersonViewModel(person), new Dictionary<string, string>() { { nameof(PersonModel.Height_cm), nameof(PersonViewModel.Height) } }, DispatcherHelper.UIDispatcher);
    Messenger.Raise(new InteractionMessage("SelectedItemsMirroring"));
}

忘れずにそのコードを入れたとして、このアクションを発動させるタイミングもとても重要です。このアクションはListViewのSelectedItemsをListViewSelectedItemsAction.Sourceにコピーする操作をするので、ListViewがインスタンス化されているタイミングでなければなりません。Loadedイベントなどで発動するようにしてもその前なので上手くいかないようです。Window.ContentRenderedイベントに合わせて使えば上手くいきますが、例えばUserControl内での使用などではこのイベントが使えないので一苦労します。

こんな癖つよシステムは使っていられないとのことで、試行錯誤のすえ、今回のバージョンでは以下のような形になりました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<ListView ItemsSource="{Binding People}" lv:SelectedItemsSync.Source="{Binding People.SelectedItemsSetter}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="ContextMenu">
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Increment the age" Command="{Binding IncrementAgeCommand}" />
                        <MenuItem Header="Decrement the age" Command="{Binding DecrementAgeCommand}" />
                    </ContextMenu>
                </Setter.Value>
            </Setter>
            <!--<Setter Property="lv:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

はい、超シンプルです。ListViewに添付プロパティでSelectedItemsSetterを登録するだけです。わかりやすいし、今までのようにViewModelで特別な処理を入れる必要もありませんし、タイミングを選ぶなどといったこともありません。

中身的にはWPFシステムのバインディングではなく、独自のバインディングシステムを使っています。すなわち、ListView.SelectedItemsとPeople.SelectedItemsSetterは別インスタンスで、裏で中身を同期する仕組みを作って動かしています。そのため、今までのSelectedItemsSetterとはプロパティの形態が変わり、以前バージョンとの互換性はなくなっています。

それ以外の使い方は今までのバージョンと合わせています。