.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生成・プラットフォーム要素と紐付く | 値は基本的に反映済み |
| ④ OnHandlerChanged | Handlerが付与された(または変更された)タイミング | 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 | べた塗り |
| 30 | 30%透明(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にすると扱いやすい

コメント