.NET MAUI 8 で「開始日(From)」「終了日(To)」の 2 つの DatePicker を使い、日付範囲を選ばせたい――その実装自体は難しくありません。ところが MinimumDate / MaximumDate を動的に更新したとき、Windows では反映されるのに Android だけ前の制限が残り続けるケースがあります。ここでは再現条件・原因の整理から、Android 向けにハンドラーをカスタムして確実に反映させる回避策まで、実装例つきでまとめます。
やりたいこと:2つの DatePicker で「日付範囲」を破綻なく選ばせる
よくある UI ですが、要件は意外と複合的です。今回は .NET MAUI 8 を前提に、次の制約を同時に満たしたいケースを扱います。
- 全体としての許容範囲:minDate ~ maxDate の範囲に収める
- 開始日と終了日の整合性:開始日(DateStart) ≤ 終了日(DateEnd) を守る
- 実装方針:
- DateStart を変更したら、To 側の MinimumDate を DateStart に更新
- DateEnd を変更したら、From 側の MaximumDate を DateEnd に更新
言い換えると「From で選んだ日以降しか To で選べない」「To で選んだ日以前しか From で選べない」を UI で担保する形です。設計としては自然で、MVVM でもコードビハインドでもよく採用されます。
| 項目 | From(開始日) | To(終了日) |
|---|---|---|
| 全体の下限 | minDate | minDate |
| 全体の上限 | maxDate | maxDate |
| 相互制約 | MaximumDate = DateEnd | MinimumDate = DateStart |
現象:Windows は期待通り、Android だけ「前の制限」が残り続ける
Windows では次のように動きます。
- DateStart を変更 → To の MinimumDate が追従して更新される
- DateEnd を変更 → From の MaximumDate が追従して更新される
- 範囲を戻す(例:最小日付を 2/14 → 2/3 に戻す)→ UI 上の選択可能範囲も戻る
しかし Android(エミュレーターで顕著)では、MinimumDate / MaximumDate を 2回目以降に更新しても、UI 側が更新されず「一度かかった制限」が残ることがあります。
| 操作 | 期待する挙動 | Android で起きること(例) |
|---|---|---|
| To の MinimumDate を 2/14 に更新 | To は 2/14 以降のみ選択可能 | 期待通り 2/14 以降のみになる |
| その後、To の MinimumDate を 2/3 に更新 | To は 2/3 以降が選択可能に戻る | 2/14 のままで戻らない(前の制限が残る) |
この時点で疑いがちなのが「更新ロジック(VM の PropertyChanged やバインディング)が悪いのでは?」ですが、実際には 更新値は正しく変わっているのに、Android のダイアログに反映されないタイプの問題であることが多いです。
原因:Android ネイティブ DatePicker の「最小日付・最大日付の再更新」が正しく反映されない挙動
結論から言うと、原因はロジックではなく Android 側の UI コントロール(ネイティブ DatePicker / DatePickerDialog)での反映の癖です。
Android ネイティブには DatePicker の最小日付・最大日付を指定する API(setMinDate / setMaxDate に相当)がありますが、同じダイアログ(同じ DatePicker インスタンス)に対して、
- 一度 setMinDate したあとに
- 別の値で setMinDate を呼んでも
- 内部状態が更新されず、前回の制限が残る
という挙動に当たることがあります。MAUI で DatePicker を使うと、プラットフォーム側では「タップ → ネイティブのダイアログ表示」という流れになるため、最終的にユーザーが触るのは Android のダイアログ内 DatePickerです。ここに反映されない限り、見た目上は「更新していない」ように見えてしまいます。
ポイントは次の通りです。
- 「DateStart を変えたら To の MinimumDate を変える」設計は妥当
- Windows で動いているなら、VM やイベントのロジックは概ね正しい
- 問題は Android の UI 反映(特に 2回目以降の更新)
回避策の考え方:ダイアログ表示時に「一旦リセット → 再設定」で再反映を強制する
Android の反映が不安定なら、「ダイアログを表示する瞬間」に、確実に制限を叩き直すのが現実的です。今回の回避策は次の手順です。
| 手順 | やること | 狙い |
|---|---|---|
| 1 | MinDate を 0 にリセット | 「前回の制限」を一度完全に解除して内部状態を揺さぶる |
| 2 | 少し待つ(例:Task.Delay(300)) | ダイアログ表示直後のレイアウト・初期化が終わるタイミングを待つ |
| 3 | 本来の MinDate を再設定 | ユーザーが操作する DatePicker に確実に制限を反映させる |
この「0 → 復元」は、いわば Android 固有のワークアラウンドです。Windows では不要なので、必ず Android のみに限定して適用します。
実装方針:Android だけ DatePickerHandler をカスタムする
MAUI の DatePicker 自体を作り直す必要はありません。やることは、
- XAML で使うための CustomDatePicker : DatePicker を用意
- Android 用に CustomDatePickerHandler : DatePickerHandler を用意
- ダイアログ作成時に ShowEvent にフックし、表示タイミングで MinDate を「0 → 復元」
- MauiProgram の ConfigureMauiHandlers で Android のみ登録
- XAML の DatePicker を CustomDatePicker に置換(バインディングは TwoWay 推奨)
以下は「そのまま持って行って調整しやすい」形のサンプルです。
ファイル構成例
MyApp/
Controls/
CustomDatePicker.cs
Platforms/
Android/
CustomDatePickerHandler.cs
MauiProgram.cs
Views/
DateRangePage.xaml
ViewModels/
DateRangeViewModel.cs
CustomDatePicker:XAML から差し替えるための薄い派生クラス
まずは空の派生クラスを作ります。これがあると、XAML で「この DatePicker だけはカスタムハンドラーを当てる」という構成にしやすくなります。
using Microsoft.Maui.Controls;
namespace MyApp.Controls
{
// XAML で使うための薄い派生クラス
public class CustomDatePicker : DatePicker
{
}
}
Android:CustomDatePickerHandler でダイアログ表示時に MinDate を再反映
次に Android 専用のハンドラーを作ります。ポイントは「ダイアログが表示されるタイミング」で DatePicker(ネイティブ)に触れることです。
#if ANDROID
using System;
using System.Threading.Tasks;
using Android.App;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
namespace MyApp.Platforms.Android
{
// CustomDatePicker 用の Android ハンドラー
public class CustomDatePickerHandler : DatePickerHandler
{
DatePickerDialog? _dialog;
// DatePickerDialog を作るタイミングでフックする
protected override DatePickerDialog CreateDatePickerDialog(int year, int month, int day)
{
_dialog = base.CreateDatePickerDialog(year, month, day);
// 多重登録を避ける
_dialog.ShowEvent -= OnDialogShow;
_dialog.ShowEvent += OnDialogShow;
return _dialog;
}
// ダイアログ表示タイミングで MinDate を「0 → 復元」して再反映を強制
private async void OnDialogShow(object? sender, EventArgs e)
{
if (_dialog is null)
return;
// VirtualView は MAUI の DatePicker(今回なら CustomDatePicker)
if (VirtualView is not Microsoft.Maui.Controls.DatePicker view)
return;
var nativePicker = _dialog.DatePicker;
// 1) いったんリセット(1970/01/01 相当)
nativePicker.MinDate = 0;
// 2) 少し待つ(端末や OS によって必要時間が変わることがある)
await Task.Delay(300);
// 3) 本来の MinimumDate を再設定
var minMs = ToUnixTimeMilliseconds(view.MinimumDate);
nativePicker.MinDate = minMs;
// MaximumDate も同様に「残る」症状がある場合は、同じ発想で叩き直す
// ※通常は MinDate の方が問題になりやすいので、必要な場合だけ有効化してください
// var maxMs = ToUnixTimeMilliseconds(view.MaximumDate);
// nativePicker.MaxDate = maxMs;
}
protected override void DisconnectHandler(MauiDatePicker platformView)
{
// イベント解除(リーク防止)
if (_dialog is not null)
{
_dialog.ShowEvent -= OnDialogShow;
_dialog = null;
}
base.DisconnectHandler(platformView);
}
// Android は「Unix epoch ミリ秒(UTC)」で受け取るので、ローカル日付の 00:00 を epoch ms にする
private static long ToUnixTimeMilliseconds(DateTime date)
{
var localMidnight = DateTime.SpecifyKind(date.Date, DateTimeKind.Local);
return new DateTimeOffset(localMidnight).ToUnixTimeMilliseconds();
}
}
}
#endif
この実装の意図を、実際の動きに合わせてもう少し噛み砕くと次の通りです。
- MAUI 側で MinimumDate が更新されても、Android のダイアログ内 DatePicker に反映されないことがある
- そこで、ユーザーが実際に触る直前(ShowEvent)に、ネイティブの MinDate を叩き直す
- 「0 に戻す」ことで Android 側の内部状態を一度リセットし、その後で本命の値を設定する
Task.Delay(300) は「絶対に 300ms が正しい」という意味ではなく、端末やエミュレーターの初期化タイミングと競合しないための保険です。動作が安定する最小値を探して調整するのが実務的です(例えば 100~300ms の範囲で落ち着くことが多いです)。
MauiProgram.cs:Android のみハンドラーを登録する
次に、CustomDatePicker に対して Android だけカスタムハンドラーを当てます。通常の DatePicker には影響させないのが安全です。
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.Hosting;
using MyApp.Controls;
namespace MyApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
#if ANDROID
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler(typeof(CustomDatePicker), typeof(MyApp.Platforms.Android.CustomDatePickerHandler));
});
#endif
return builder.Build();
}
}
このように #if ANDROID で囲むことで、Windows/iOS など他プラットフォームには影響しません。ワークアラウンドは「必要な場所にだけ」入れるのが保守のコツです。
XAML:DatePicker を CustomDatePicker に置き換えて TwoWay バインディング
ここまで準備できたら、XAML 側の DatePicker を CustomDatePicker に差し替えます。2つの DatePicker を MVVM で制御する例を示します。
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MyApp.Controls"
x:Class="MyApp.Views.DateRangePage">
<VerticalStackLayout Padding="16" Spacing="12">
<Label Text="開始日(From)" />
<local:CustomDatePicker
Date="{Binding DateStart, Mode=TwoWay}"
MinimumDate="{Binding RangeMin}"
MaximumDate="{Binding StartMax, Mode=OneWay}" />
<Label Text="終了日(To)" />
<local:CustomDatePicker
Date="{Binding DateEnd, Mode=TwoWay}"
MinimumDate="{Binding EndMin, Mode=OneWay}"
MaximumDate="{Binding RangeMax}" />
<Label
Text="{Binding DebugText}"
FontSize="12"
Opacity="0.7" />
</VerticalStackLayout>
</ContentPage>
ポイントは、相互制約を「From の最大」「To の最小」として別プロパティで表現している点です。こうしておくと、UI 更新とバインディングの責務がはっきりし、デバッグもしやすくなります。
ViewModel:DateStart / DateEnd の整合性を保ちつつ、制限用プロパティを更新する
VM 側は、次の二段構えにすると破綻しづらいです。
- UI の選択範囲は MinimumDate / MaximumDate で先に防ぐ
- それでも起こり得る「逆転」や「境界超え」をプロパティ setter で最終調整する
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MyApp.ViewModels
{
public class DateRangeViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
// 全体範囲(要件の minDate / maxDate)
public DateTime RangeMin { get; } = new DateTime(2025, 2, 1);
public DateTime RangeMax { get; } = new DateTime(2025, 2, 28);
private DateTime _dateStart;
private DateTime _dateEnd;
public DateRangeViewModel()
{
_dateStart = new DateTime(2025, 2, 3);
_dateEnd = new DateTime(2025, 2, 14);
}
public DateTime DateStart
{
get => _dateStart;
set
{
var newValue = Clamp(value, RangeMin, RangeMax);
if (_dateStart == newValue)
return;
_dateStart = newValue;
OnPropertyChanged();
// 逆転防止:開始日が終了日を超えたら終了日を追従
if (_dateEnd < _dateStart)
{
_dateEnd = _dateStart;
OnPropertyChanged(nameof(DateEnd));
}
// To 側の最小(= 開始日)を更新
OnPropertyChanged(nameof(EndMin));
// From 側の最大(= 終了日)を更新
OnPropertyChanged(nameof(StartMax));
OnPropertyChanged(nameof(DebugText));
}
}
public DateTime DateEnd
{
get => _dateEnd;
set
{
var newValue = Clamp(value, RangeMin, RangeMax);
if (_dateEnd == newValue)
return;
_dateEnd = newValue;
OnPropertyChanged();
// 逆転防止:終了日が開始日より前なら開始日を追従
if (_dateStart > _dateEnd)
{
_dateStart = _dateEnd;
OnPropertyChanged(nameof(DateStart));
}
// To 側の最小(= 開始日)を更新
OnPropertyChanged(nameof(EndMin));
// From 側の最大(= 終了日)を更新
OnPropertyChanged(nameof(StartMax));
OnPropertyChanged(nameof(DebugText));
}
}
// From の MaximumDate として使う(開始日 ≤ 終了日 を UI 側でも担保)
public DateTime StartMax => DateEnd;
// To の MinimumDate として使う
public DateTime EndMin => DateStart;
public string DebugText => $"From={DateStart:yyyy/MM/dd} To={DateEnd:yyyy/MM/dd} (Range {RangeMin:MM/dd}-{RangeMax:MM/dd})";
private static DateTime Clamp(DateTime value, DateTime min, DateTime max)
{
var d = value.Date;
if (d < min.Date) return min.Date;
if (d > max.Date) return max.Date;
return d;
}
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
この VM は次の点で「実運用で壊れにくい」作りです。
- 全体範囲(RangeMin/RangeMax)で必ずクランプする
- 逆転(DateStart > DateEnd / DateEnd < DateStart)を setter 内で確実に補正する
- MinimumDate/MaximumDate 用のプロパティ(EndMin/StartMax)を分離して、UI 更新が明確
- Mode=TwoWay を明示して「UI操作 ↔ プロパティ」のズレを起こしにくい
Android だけ反映されないときのチェックポイント
「本当に Android 側の反映問題なのか?」を切り分けるときは、次の観点で確認すると早いです。
値は更新されているか(VM/コードビハインド)
- DateStart/DateEnd の更新タイミングでログを出す
- MinimumDate/MaximumDate に入れている値を画面に表示(DebugText のような形)
値が正しいのに、ダイアログに入った瞬間の選択可能範囲だけが古いなら、今回の「Android 反映問題」に当たりやすいです。
同じダイアログ(同じネイティブ DatePicker)を触っているか
MAUI の内部実装や OS の振る舞いにより、ネイティブ側が再利用されることがあります。再利用される場合、最小日付・最大日付の “再設定” が効かない癖が出ると、症状が「前回の制限が残る」になりがちです。
運用上の注意:ワークアラウンドを安全に使うコツ
Android のみ適用する
今回の回避策は Android 固有です。Windows では不要ですし、他 OS に入れるメリットも基本ありません。#if ANDROID で限定する、または Platforms/Android 配下に閉じ込める構成にしておくと安全です。
Delay は「固定の正解」ではない
Task.Delay(300) は、エミュレーターや端末性能、OS バージョン差による「初期化のズレ」を吸収するための値です。
- 短すぎる → 反映が間に合わず、症状が残る
- 長すぎる → 開いた直後の操作感がわずかに重く感じる場合がある
まずは 200~300ms あたりで安定させ、必要なら 100ms ずつ調整するのが現場ではやりやすいです。
MaximumDate も同様に残る場合
本記事の主眼は MinimumDate の反映ですが、MaximumDate でも似た症状が出る環境があります。その場合は同じ発想で、表示タイミングに max を叩き直してください。
- MinDate だけで直るなら、触る範囲は最小限に(副作用を減らす)
- MaxDate も必要なら、同様に再設定する
「逆転補正」は UI と VM の両方でやると強い
MinimumDate/MaximumDate を正しく反映できても、アプリ側の状態更新順序や初期値、外部からの値投入(API レスポンスで日付が変わる等)で一瞬だけ逆転が発生することはあります。最後の砦として VM の setter で補正しておくと、データ整合性が壊れません。
コードビハインド派の実装でも考え方は同じ
MVVM ではなく、質問概要のように「pickerTo.MinimumDate = DateStart」「pickerFrom.MaximumDate = DateEnd」をコードビハインドでやっている場合でも、問題の本質は同じです。
- 値の更新はできている(= C# のロジックは動いている)
- Android のダイアログ側が 2回目以降の更新を飲み込む
- ダイアログ表示時に “強制的に再反映” させるのが効く
そのため、どの設計スタイルでも「Android のみハンドラーをカスタムする」という着地点は共通です。
まとめ:ロジックを疑う前に、Android の UI 反映癖を疑う
- .NET MAUI 8 の DatePicker で、MinimumDate / MaximumDate を動的に更新するのは一般的な設計
- Windows で期待通りなら、日付範囲ロジック自体はほぼ正しい
- Android だけ「前の制限が残る」なら、ネイティブ DatePicker の反映問題が原因になり得る
- 回避策として、Android のみハンドラーをカスタムし、ダイアログ表示時に MinDate を 0 → 復元して再反映を強制する
- バインディングは TwoWay を明示し、VM 側でも逆転補正を入れておくと運用が安定する

コメント