.NET MAUIのLiveTileControlが表示されない原因と解決策|ContentViewでBindablePropertyを反映するOnHandlerChanged実装

.NET MAUIでWindows Phone風のライブタイル(Live Tile)UIをContentViewとして自作したのに、XAMLで設定したTitleや背景色が反映されず何も表示されない……。この症状は「UIをコンストラクターで組み立てている」ことが原因で起きやすい代表例です。この記事では、OnHandlerChangedへ移す理由と実装例、透過・自動更新・Fluent UIフォントアイコンまで含めて“期待どおり動く状態”に整える手順をまとめます。

目次

起きている現象:XAMLで指定したプロパティが効かず、タイルが空っぽに見える

Windows Phone時代のLive Tile風UIを.NET MAUIで再現しようとすると、次のような「全部つながっている」症状に遭遇しがちです。

  • XAMLでTitleを指定したのにタイトルが出ない(Labelが空のまま)
  • LiveTileBackgroundColorを指定しても背景色が変わらない(透明に見える)
  • TransparencyPercentageを変えても透過が効かない(または意図と逆になる)
  • アイコン(Fluent UIのフォントアイコン)が出ない
  • RefreshRateを設定しても自動更新が走らない
  • HTTPで取得したデータを表示・ログ出力したいのに何も起きない

これらは別々の問題に見えますが、根っこは同じことが多いです。特に「最初のUI構築タイミング」を外すと、見た目も処理も全部“動いていないように見える”状態になります。

原因:ContentViewのコンストラクターでUIを作ると、XAMLからのBindablePropertyがまだ入っていない

今回の主原因はこれです。

LiveTileControl(ContentView派生)のコンストラクターでGridやBorderを作り、Contentにセットしていると、コンストラクター実行時点ではXAMLから設定した値(Title、LiveTileBackgroundColor、TransparencyPercentageなど)がまだ初期値のままのことがあります。その状態でCreateText()やCreateIcon()を呼ぶため、テキストは空、色も既定値で構築され、結果として「何も表示されない」に見えます。

タイミングをイメージしやすいように、ざっくり流れを表にすると次のようになります(細部は環境で変わりますが、重要なのは“コンストラクター時点ではXAML値が確定していないことがある”点です)。

タイミング内部で起きやすいことTitle/背景色などの値
① new LiveTileControl()コンストラクターが実行されるまだ初期値(null/既定値)になりがち
② XAMLの属性適用XAMLからBindablePropertyがセットされるここで初めて指定値が入る
③ Visual Treeに追加Handler生成・プラットフォーム要素と紐付く値は基本的に反映済み
④ OnHandlerChangedHandlerが付与された(または変更された)タイミングXAMLの指定値を使ってUIを確定しやすい

つまり、コンストラクターで「プロパティ値に依存するUI」を組むのが危険で、表示も色もアイコンも初期値で固まってしまいます。

解決策:UI構築をOnHandlerChangedへ移して“値が入った後”に組み立てる

受理された解決策の核はシンプルです。

  • コンストラクターではUIを作り込まない(せいぜい初期化・イベント登録程度)
  • XAMLからのプロパティが反映されてから呼ばれやすいOnHandlerChangedでUIを構築する

最小修正版のイメージは次のとおりです。

protected override void OnHandlerChanged()
{
    base.OnHandlerChanged();

    var grid = new Grid
    {
        HorizontalOptions = LayoutOptions.Fill,
        VerticalOptions = LayoutOptions.Fill
    };

    grid.Children.Add(CreateIcon());
    grid.Children.Add(CreateText());

    var border = new Border
    {
        StrokeShape = new RoundRectangle
        {
            CornerRadius = new CornerRadius(10)
        },
        BackgroundColor = LiveTileBackgroundColor,
        Content = grid
    };

    Content = border;
}

この位置に移すだけで、「Titleが空のまま」「背景色が反映されない」「アイコンが出ない」などの“最初の表示問題”が一気に解消しやすくなります。

OnHandlerChangedで組むときの実務上の注意点

実務では次の2点を意識すると事故が減ります。

  • OnHandlerChangedは複数回呼ばれる可能性がある(再アタッチ、ハンドラの再生成など)ため、UIを二重に作らない工夫が必要
  • UIを一度作って終わりではなく、プロパティ変更に追従する(Titleが後から変わる、テーマ変更で色が変わる、RefreshRateだけ変える等)設計にしておくと便利

そこでおすすめなのが、「OnHandlerChangedでUIを一度だけ作り、BindablePropertyのpropertyChangedで見た目を更新する」方式です。後半で完成形サンプルを載せます。

背景色+透過の落とし穴:どの色を基準にアルファを掛けているか

「緑色+30%透過」を狙っているのに、思った色にならないケースはかなり多いです。原因はだいたい次のどちらかです。

  • 透過処理の基準色がBackgroundColorになっていて、実際に指定しているLiveTileBackgroundColorを見ていない
  • “30%透過”の解釈が逆(30%透明=不透明度70%なのか、アルファ0.3なのか)

まず、透過を掛ける基準色は「最終的に表示したい色」に合わせるのが素直です。XAMLでLiveTileBackgroundColor="Green"を指定しているなら、基準はLiveTileBackgroundColorにするのが筋です。

var transparency = Math.Clamp(TransparencyPercentage, 0, 100) / 100.0;

// 「30%透過」= 30%が透明、70%が見える(不透明度70%)
var alpha = (float)(1.0 - transparency);

var baseColor = LiveTileBackgroundColor;
var finalColor = baseColor.WithAlpha(alpha);

_border.BackgroundColor = finalColor;

透過率の解釈はプロジェクト内で統一しましょう。迷ったら次の表をチームの“仕様”として決めてしまうのが安定です。

指定(TransparencyPercentage)意味計算例(alpha)見た目
0透過なし1.0べた塗り
3030%透明(70%見える)0.7少し薄い
100完全透明0.0見えない

なお、「30%透過」を“アルファ0.3”として扱いたいなら、計算をalpha = transparencyにするだけです。ここは好みではなく仕様なので、UIデザイナーや要件に合わせて決めるのが正解です。

自動更新が動かない最大の理由:StartAutoRefreshをどこからも呼んでいない

コードにPeriodicTimerのループを書いていても、StartAutoRefresh()を呼び出していなければ一度も動きません。実装者が意外と見落とすポイントです。

おすすめは次のどちらかです。

  • コントロール自身が開始する:OnHandlerChangedやLoadedで開始し、Unloadedで停止する
  • 親ページが開始する:ページのOnAppearing()で開始し、OnDisappearing()で停止する(画面表示と同期させたい場合に強い)

LiveTileのように「表示されている間だけ動けば良い」UIは、ページやコントロールのライフサイクルに合わせて開始・停止できる設計がベターです。停止を入れないと、画面遷移後もタイマーが動き続けて電池と通信量に効きます。

protected override void OnHandlerChanged()
{
    base.OnHandlerChanged();
    BuildUiIfNeeded();
    ApplyVisuals();

    // ここで開始(ただし多重起動に注意)
    StartAutoRefresh();
}

public void StopAutoRefresh()
{
    _refreshCts?.Cancel();
    _refreshCts?.Dispose();
    _refreshCts = null;
}

RefreshRateの“安全な扱い”

RefreshRateをそのまま秒として使う場合、0や負数、極端に短い値に対するガードを入れておくと安心です。

  • RefreshRate <= 0:自動更新オフ
  • RefreshRate == 1:毎秒更新(HTTP取得だと負荷が高いので注意)
  • 最低でも5秒や10秒など、現実的な下限を設ける

Fluent UIのフォントアイコンを確実に表示する方法

「フォントアイコンが出ない」問題は、原因が複数あります。特に多いのは次の2つです。

  • IconSourceがstringのままで、画像パスとして解釈されている(フォントグリフとして描画されない)
  • フォント登録(fonts.AddFont)はしているが、XAML/C#側の指定が合っていない(FontFamily名やGlyphコードの不一致)

MAUIでフォントアイコンを扱うなら、プロパティ型をstringではなくImageSourceにするのが扱いやすいです。XAML側で<FontImageSource>を直接渡せるため、画像・フォントの両対応ができます。

XAMLからFontImageSourceを渡す例

<local:LiveTileControl Title="Sample"
                      LiveTileBackgroundColor="Green"
                      TransparencyPercentage="30"
                      RefreshRate="10">
    <local:LiveTileControl.IconSource>
        <FontImageSource FontFamily="FluentUI"
                         Glyph=""
                         Size="48"
                         Color="White" />
    </local:LiveTileControl.IconSource>
</local:LiveTileControl>

C#側でImageSourceをそのままImageに入れる

private Image CreateIcon()
{
    return new Image
    {
        Source = IconSource,
        WidthRequest = 48,
        HeightRequest = 48,
        HorizontalOptions = LayoutOptions.Center,
        VerticalOptions = LayoutOptions.Center
    };
}

これで「画像パスも使いたい」「フォントアイコンも使いたい」を両立できます。なお、FontFamily="FluentUI"の名前は、MauiProgram.csの登録名と一致している必要があります。

// MauiProgram.cs の例
builder.ConfigureFonts(fonts =>
{
    fonts.AddFont("FluentSystemIcons-Regular.ttf", "FluentUI");
});

アニメーションとHTTP更新を“動いているように見せる”ための実装ポイント

ライブタイルは「情報が更新される」だけでなく「更新されたことが分かる」動きが重要です。ところが、MAUIでは次の条件を外すとアニメーションも更新も止まって見えます。

  • UI更新(ラベル書き換え、アニメーション)はUIスレッドで行う
  • 定期更新ループは開始・停止を制御し、例外が起きてもループ全体が死なないようにする
  • HTTP取得は失敗する前提で、タイムアウトや例外時にログを残す

コントロール内部で完結させる場合は、次のように「取得はバックグラウンド、反映はDispatcher(またはMainThread)でUIへ」という形にすると安定します。

private async Task RefreshOnceAsync(CancellationToken token)
{
    // 1) アニメーション(UIスレッド)
    await Dispatcher.DispatchAsync(async () =>
    {
        await UpdateAnimationAsync();
    });

    // 2) データ取得(バックグラウンド)
    var newData = await FetchDataAsync(token);

    // 3) 表示反映(UIスレッド)
    await Dispatcher.DispatchAsync(() =>
    {
        UpdateContent(newData);
    });

    // 4) ログ(環境に応じて Console / Debug を使い分け)
    System.Diagnostics.Debug.WriteLine($"New data: {newData}");
}

Console.WriteLineが見えないときはDebug.WriteLineも併用する

MAUIの実行環境(Android/iOS/Windows/Mac)によっては、Console.WriteLineが思った場所に出ないことがあります。開発中は次のどちらか(または両方)を入れておくと追跡しやすいです。

  • System.Diagnostics.Debug.WriteLine(...)(Visual Studioの出力に出やすい)
  • Console.WriteLine(...)(環境によっては表示される)

症状別チェック表:どこを直せば“期待どおり”に近づくか

「表示されない」「更新しない」を短時間で切り分けるための表です。

症状ありがちな原因対処
Border内が空で何も見えないコンストラクターでUIを構築し、Title/Iconが初期値で空のままUI構築をOnHandlerChangedへ移す/UI参照を保持して後からApplyする
背景色が指定どおりにならない透過の基準色がBackgroundColorになっている/alpha計算が逆LiveTileBackgroundColorを基準にWithAlphaする/透過率の仕様を統一
自動更新が一度も走らないStartAutoRefreshが呼ばれていないOnHandlerChanged/Loaded/OnAppearingでStart、Unloaded/OnDisappearingでStop
アイコンが出ないIconSourceがstringのまま/FontFamily名不一致/Glyph不一致IconSourceをImageSourceにしてFontImageSourceを渡す/フォント登録名を揃える
アニメーションしないUIスレッド以外でアニメーションを実行している/例外でループが止まっているDispatcher/MainThreadで実行/例外を握りつぶさずログ+継続

完成形サンプル:OnHandlerChangedでUIを作り、プロパティ変更に追従しつつ自動更新も動かす

ここでは「XAMLで指定した値が確実に表示される」「後からプロパティが変わっても追従する」「自動更新を開始・停止できる」ことを重視したサンプルを示します。実プロジェクトでは、取得URLや表示形式などを置き換えてください。

using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace YourApp.Controls;

public class LiveTileControl : ContentView
{
    // ===== BindableProperties =====

    public static readonly BindableProperty TitleProperty =
        BindableProperty.Create(
            nameof(Title),
            typeof(string),
            typeof(LiveTileControl),
            default(string),
            propertyChanged: OnVisualPropertyChanged);

    public static readonly BindableProperty TitleColorProperty =
        BindableProperty.Create(
            nameof(TitleColor),
            typeof(Color),
            typeof(LiveTileControl),
            Colors.White,
            propertyChanged: OnVisualPropertyChanged);

    public static readonly BindableProperty LiveTileBackgroundColorProperty =
        BindableProperty.Create(
            nameof(LiveTileBackgroundColor),
            typeof(Color),
            typeof(LiveTileControl),
            Colors.Green,
            propertyChanged: OnVisualPropertyChanged);

    public static readonly BindableProperty TransparencyPercentageProperty =
        BindableProperty.Create(
            nameof(TransparencyPercentage),
            typeof(double),
            typeof(LiveTileControl),
            0d,
            propertyChanged: OnVisualPropertyChanged);

    // 画像/フォントアイコンどちらも渡せるように ImageSource にする
    public static readonly BindableProperty IconSourceProperty =
        BindableProperty.Create(
            nameof(IconSource),
            typeof(ImageSource),
            typeof(LiveTileControl),
            default(ImageSource),
            propertyChanged: OnVisualPropertyChanged);

    // 秒(0以下なら無効)
    public static readonly BindableProperty RefreshRateProperty =
        BindableProperty.Create(
            nameof(RefreshRate),
            typeof(int),
            typeof(LiveTileControl),
            0,
            propertyChanged: OnRefreshRateChanged);

    // 例:取得先URL(必要なら)
    public static readonly BindableProperty DataUrlProperty =
        BindableProperty.Create(
            nameof(DataUrl),
            typeof(string),
            typeof(LiveTileControl),
            default(string));

    public string Title
    {
        get => (string)GetValue(TitleProperty);
        set => SetValue(TitleProperty, value);
    }

    public Color TitleColor
    {
        get => (Color)GetValue(TitleColorProperty);
        set => SetValue(TitleColorProperty, value);
    }

    public Color LiveTileBackgroundColor
    {
        get => (Color)GetValue(LiveTileBackgroundColorProperty);
        set => SetValue(LiveTileBackgroundColorProperty, value);
    }

    public double TransparencyPercentage
    {
        get => (double)GetValue(TransparencyPercentageProperty);
        set => SetValue(TransparencyPercentageProperty, value);
    }

    public ImageSource IconSource
    {
        get => (ImageSource)GetValue(IconSourceProperty);
        set => SetValue(IconSourceProperty, value);
    }

    public int RefreshRate
    {
        get => (int)GetValue(RefreshRateProperty);
        set => SetValue(RefreshRateProperty, value);
    }

    public string DataUrl
    {
        get => (string)GetValue(DataUrlProperty);
        set => SetValue(DataUrlProperty, value);
    }

    // ===== UI references =====

    private Border _border;
    private Label _titleLabel;
    private Label _dataLabel;
    private Image _iconImage;

    private bool _uiBuilt;

    // ===== Auto refresh =====

    private CancellationTokenSource _refreshCts;

    // 使い捨てにしない(例:アプリ全体で共有したいならDIでもOK)
    private static readonly HttpClient _http = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(10)
    };

    public LiveTileControl()
    {
        // コンストラクターでは「プロパティ値に依存するUI確定」をしない
        // 必要ならデフォルト値の設定やイベント登録だけに留める
    }

    protected override void OnHandlerChanged()
    {
        base.OnHandlerChanged();

        // Handlerが外れたケースもあり得るので、必要ならガード
        if (Handler is null)
            return;

        BuildUiIfNeeded();
        ApplyVisuals();

        // ここで開始(RefreshRate <= 0 なら何もしない)
        StartAutoRefresh();
    }

    private void BuildUiIfNeeded()
    {
        if (_uiBuilt)
            return;

        _titleLabel = new Label
        {
            FontSize = 14,
            LineBreakMode = LineBreakMode.TailTruncation,
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.Start
        };

        _dataLabel = new Label
        {
            FontSize = 18,
            FontAttributes = FontAttributes.Bold,
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.End
        };

        _iconImage = new Image
        {
            WidthRequest = 48,
            HeightRequest = 48,
            HorizontalOptions = LayoutOptions.Center,
            VerticalOptions = LayoutOptions.Center
        };

        var grid = new Grid
        {
            RowDefinitions =
            {
                new RowDefinition { Height = GridLength.Auto },
                new RowDefinition { Height = new GridLength(1, GridUnitType.Star) },
                new RowDefinition { Height = GridLength.Auto }
            },
            ColumnDefinitions =
            {
                new ColumnDefinition { Width = GridLength.Auto },
                new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }
            },
            Padding = new Thickness(12)
        };

        // 0行目:タイトル(2列使う)
        grid.Add(_titleLabel, 0, 0);
        Grid.SetColumnSpan(_titleLabel, 2);

        // 1行目:アイコン(左)+余白(右)
        grid.Add(_iconImage, 0, 1);

        // 2行目:データ表示(2列使う)
        grid.Add(_dataLabel, 0, 2);
        Grid.SetColumnSpan(_dataLabel, 2);

        _border = new Border
        {
            StrokeShape = new RoundRectangle { CornerRadius = new CornerRadius(10) },
            Content = grid
        };

        Content = _border;
        _uiBuilt = true;
    }

    private void ApplyVisuals()
    {
        if (!_uiBuilt)
            return;

        _titleLabel.Text = Title ?? string.Empty;
        _titleLabel.TextColor = TitleColor;

        // 透過適用
        var transparency = Math.Clamp(TransparencyPercentage, 0, 100) / 100.0;
        var alpha = (float)(1.0 - transparency); // 「30%透過」= alpha 0.7
        _border.BackgroundColor = LiveTileBackgroundColor.WithAlpha(alpha);

        // アイコン適用
        _iconImage.Source = IconSource;
    }

    private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var control = (LiveTileControl)bindable;
        control.ApplyVisuals();
    }

    private static void OnRefreshRateChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var control = (LiveTileControl)bindable;

        // すでに動いているなら再起動して反映
        control.StopAutoRefresh();
        control.StartAutoRefresh();
    }

    public void StartAutoRefresh()
    {
        if (!_uiBuilt)
            return;

        // 0以下なら自動更新しない
        if (RefreshRate <= 0)
            return;

        // 多重起動防止
        if (_refreshCts != null)
            return;

        _refreshCts = new CancellationTokenSource();
        _ = RunAutoRefreshLoopAsync(_refreshCts.Token);
    }

    public void StopAutoRefresh()
    {
        _refreshCts?.Cancel();
        _refreshCts?.Dispose();
        _refreshCts = null;
    }

    private async Task RunAutoRefreshLoopAsync(CancellationToken token)
    {
        var seconds = Math.Max(1, RefreshRate);
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(seconds));

        // 初回をすぐ反映したい場合はここで一度実行してもOK
        await SafeRefreshOnceAsync(token);

        while (await timer.WaitForNextTickAsync(token))
        {
            await SafeRefreshOnceAsync(token);
        }
    }

    private async Task SafeRefreshOnceAsync(CancellationToken token)
    {
        try
        {
            // 更新が“見える”ように軽くアニメーション
            await Dispatcher.DispatchAsync(async () =>
            {
                await UpdateAnimationAsync();
            });

            var newData = await FetchDataAsync(token);

            await Dispatcher.DispatchAsync(() =>
            {
                UpdateContent(newData);
            });

            System.Diagnostics.Debug.WriteLine($"New data: {newData}");
        }
        catch (OperationCanceledException)
        {
            // StopAutoRefreshでキャンセルされた
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine(ex);
        }
    }

    private async Task UpdateAnimationAsync()
    {
        // Border全体を少しだけ“脈動”させる例
        if (_border is null)
            return;

        await _border.ScaleTo(1.02, 120);
        await _border.ScaleTo(1.00, 120);
    }

    private async Task<string> FetchDataAsync(CancellationToken token)
    {
        // 実運用では DataUrl を必須にする、またはDIでサービスを注入する等がおすすめ
        if (string.IsNullOrWhiteSpace(DataUrl))
            return DateTime.Now.ToString("HH:mm:ss");

        var text = await _http.GetStringAsync(DataUrl, token);
        return text;
    }

    private void UpdateContent(string newData)
    {
        // 表示フォーマットは自由。例:空ならハイフンなど
        _dataLabel.Text = string.IsNullOrWhiteSpace(newData) ? "-" : newData;
    }
}

このサンプルで解決できること

  • 表示されない問題:UI構築をOnHandlerChangedへ移し、XAMLの値が入った後に反映
  • 色・透過が効かない問題:LiveTileBackgroundColorを基準にalphaを計算して適用
  • アイコンが出ない問題:IconSourceをImageSourceにしてFontImageSourceを渡せる設計
  • 自動更新が動かない問題:StartAutoRefreshをコントロール側で開始し、多重起動を防止
  • アニメーションが止まる問題:UIスレッドで実行し、例外が出てもループが即死しないよう保護

さらに品質を上げるなら(運用のコツ)

  • 画面遷移で停止したいなら、親ページのOnAppearing/OnDisappearingでStart/Stopを呼ぶ設計にする
  • 更新頻度が高い場合は、HTTP取得にキャッシュやETag、差分取得を入れて通信量を抑える
  • 複数タイルを並べるなら、各タイルが個別にHttpClientを持つのではなくサービス層に寄せる(同時アクセス制御やリトライを統一できる)

まとめ:LiveTileControlが動かないときは“UIを作るタイミング”を最優先で見直す

  • ContentViewのコンストラクターでプロパティ依存のUIを組むと、XAML値が入る前に確定してしまい「何も出ない」に見えやすい
  • まずはUI構築をOnHandlerChangedへ移し、値が反映された後にContentを作る
  • 透過は「どの色を基準にするか」と「透過率の解釈(alpha計算)」を揃える
  • 自動更新はStartAutoRefresh()を確実に呼び、停止(キャンセル)もセットで設計する
  • フォントアイコンはFontImageSourceを渡せるよう、IconSourceをImageSourceにすると扱いやすい

この記事を書いた人

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

コメント

コメントする

目次