.NET MAUI 8 DatePickerのMinimumDate/MaximumDateがAndroidで更新されない原因と対策(カスタムハンドラー)

.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(終了日)
全体の下限minDateminDate
全体の上限maxDatemaxDate
相互制約MaximumDate = DateEndMinimumDate = 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 の反映が不安定なら、「ダイアログを表示する瞬間」に、確実に制限を叩き直すのが現実的です。今回の回避策は次の手順です。

手順やること狙い
1MinDate を 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 側でも逆転補正を入れておくと運用が安定する

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次