.NET MAUI LiveTileControlのフリップが一度きりで止まる原因と解決策|RotationYで安定化&DispatcherTimer活用

.NET MAUIでFront/Backの2面を持つLiveTileControlを作ったのに、フリップが一度きりで裏面のまま戻らない……という現象は意外と起こりがちです。本記事では原因を切り分け、RotationYをコントロール全体で回す安定実装と、テスト用の定期フリップ/本番用のデータ更新トリガーを整理して解説します。

目次

.NET MAUI LiveTileControlで起きる「フリップが一度きりで止まる」症状

自作のLiveTileControl(FrontView/BackViewの2面を重ねたタイル)で、次のような要件を実現しようとしたときにハマりやすいのが「一度裏面に切り替わったら、そのまま戻らない」「一定間隔でフリップし続けない」といった挙動です。

  • 新しいデータを受信したタイミングで、表⇔裏をフリップさせたい
  • テスト中は、10秒ごとなど一定間隔で自動フリップさせたい

一見するとアニメーションの問題に見えますが、実際は「回転角の状態」「表示面の切り替え(IsVisible)」「データ更新の呼び出し経路」「UIスレッド制御」が絡み合って起きることが多いです。ここをほどいて設計を整理すると、挙動はかなり安定します。

原因になりやすいポイントを先に整理する

まずは、よくある原因を“症状”と“対処”まで含めて俯瞰します。自分のコードがどこに当てはまるか、チェックしてから修正に入ると早いです。

原因起きやすい症状対処の方向性
FrontView/BackViewを個別に回転させ、IsVisibleを切り替える回転角と表示面がズレて「裏面のまま固まる」「見えてはいけない面が一瞬見える」回転はコントロール全体(this.RotationY)に集約し、90度(側面)で表示面を入れ替える
データ更新でFlipが複数経路から呼ばれる「1回の更新で2回フリップ」して元に戻ったように見える/途中でアニメーションが競合するフリップのトリガーを1本化する(propertyChangedに任せる or 明示メソッドから呼ぶ)
一定間隔フリップをViewModelだけで完結させようとするUIスレッドでない呼び出しが混ざり、アニメーションが走らない・途中で止まるタイマーはページ側で持ち、UI要素(コントロール)を呼ぶ。データはViewModelが用意する
裏面のバインディング参照が不正フリップ自体はするが、裏側のLabelが空/更新されないx:Referenceなどで「LiveTileControl自身のBindableProperty」を確実に参照する

結論:Flipは「コントロール全体のRotationY」を回して90度で面を切り替える

フリップが止まる根本要因は、多くの場合「FrontViewとBackViewを別々に回転」「IsVisibleの切り替え」「回転角の初期値・最終値」が噛み合わず、最終的に“今どちらの面を出すべきか”が不整合になる点です。

そこでおすすめなのが、次の方針です。

  • 回転させる対象はFront/Back個別ではなく、LiveTileControl全体(this.RotationY)にする
  • 0→90度で「側面」まで回した瞬間に、FrontView/BackViewのIsVisibleを入れ替える
  • 入れ替え後、RotationYを-90度にジャンプしてから-90→0で戻す(側面同士のジャンプなので見た目の破綻が少ない)

なぜ「90度で切り替え」なのか

0度(正面)で切り替えると、表と裏が同時に見える・ちらつく・裏側が鏡文字に見えるなどの破綻が起きやすいです。90度はカードの側面で、視覚的に“どちらの面もほぼ見えない瞬間”なので、ここで切り替えると違和感が出にくくなります。

実装例:安定するLiveTileControl(RotationY集約版)

ここでは「DataMemberの更新に合わせてフリップできる」ことを前提に、実装を分解して紹介します。ポイントは、フリップ処理を1つのメソッドに閉じ込め、多重呼び出しを防ぐことです。

XAML:FrontView/BackViewを同じGrid上に重ねる

FrontとBackは同じ場所に重ね、初期状態はFrontだけを表示します。裏面のLabelも確実にDataMemberを参照できるよう、x:Referenceでコントロール自身を参照します。

<ContentView
    x:Class="MyApp.Controls.LiveTileControl"
    x:Name="Tile"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    AnchorX="0.5"
    AnchorY="0.5">

    <Grid>
        <Grid x:Name="FrontView" IsVisible="True">
            <Frame Padding="12" CornerRadius="16">
                <Label
                    FontSize="18"
                    Text="{Binding Source={x:Reference Tile}, Path=DataMember}" />
            </Frame>
        </Grid>

        <Grid x:Name="BackView" IsVisible="False">
            <Frame Padding="12" CornerRadius="16">
                <Label
                    FontSize="18"
                    Text="{Binding Source={x:Reference Tile}, Path=DataMember}" />
            </Frame>
        </Grid>
    </Grid>
</ContentView>

C#:Flipを「0→90→(入れ替え)→-90→0」で実行する

以下の例は、回転対象をthis(LiveTileControl本体)に固定した実装です。さらに、アニメーション中の多重呼び出しを抑止するために_isFlippingフラグを持ちます。

このサンプルは「DataMemberが変わったら自動でフリップする」を基本にしています。ページやViewModelから値を更新するだけで、裏面で止まらない安定したフリップが実現できます。

using Microsoft.Maui.Controls;
using Microsoft.Maui.ApplicationModel;

namespace MyApp.Controls;

public partial class LiveTileControl : ContentView
{
    public static readonly BindableProperty DataMemberProperty =
        BindableProperty.Create(
            nameof(DataMember),
            typeof(string),
            typeof(LiveTileControl),
            default(string),
            propertyChanged: OnDataMemberChanged);

    public string? DataMember
    {
        get => (string?)GetValue(DataMemberProperty);
        set => SetValue(DataMemberProperty, value);
    }

    // 好みに合わせて調整(ms)
    public uint FlipDuration { get; set; } = 600;

    // データ更新時に自動フリップさせるか(本番はtrueが分かりやすい)
    public bool FlipOnDataChange { get; set; } = true;

    private bool _isFlipping;
    private bool _pendingFlip;
    private bool _suppressFlipOnDataChange;

    public LiveTileControl()
    {
        InitializeComponent();
        RotationY = 0;
    }

    // DataMember更新時に呼ばれる
    private static void OnDataMemberChanged(BindableObject bindable, object oldValue, object newValue)
    {
        if (Equals(oldValue, newValue))
            return;

        var control = (LiveTileControl)bindable;

        if (!control.FlipOnDataChange || control._suppressFlipOnDataChange)
            return;

        // バインディング更新がどのスレッドから来ても安全なようにUIスレッドへ寄せる
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            await control.FlipOnceAsync();
        });
    }

    // 値の更新をUIスレッドで安全に行う(必要ならフリップも抑止できる)
    public async Task UpdateTileSafelyAsync(string? newData, bool flip = true)
    {
        await MainThread.InvokeOnMainThreadAsync(() =>
        {
            _suppressFlipOnDataChange = !flip;
            DataMember = newData;
            _suppressFlipOnDataChange = false;
        });
    }

    // 値を変えずに「フリップだけしたい」場合の入口(テストにも便利)
    public Task RequestFlipAsync()
        => MainThread.InvokeOnMainThreadAsync(async () => await FlipOnceAsync());

    private async Task FlipOnceAsync()
    {
        // アニメーションが重なると「裏面で止まる」「戻らない」原因になりやすい
        if (_isFlipping)
        {
            _pendingFlip = true;
            return;
        }

        try
        {
            _isFlipping = true;

            // 正面→側面
            await this.RotateYTo(90, FlipDuration / 2, Easing.Linear);

            // 側面のタイミングで面を入れ替える
            SwapFaces();

            // +90から-90へジャンプ(側面なので見た目が破綻しにくい)
            this.RotationY = -90;

            // 側面→正面
            await this.RotateYTo(0, FlipDuration / 2, Easing.Linear);
        }
        finally
        {
            _isFlipping = false;
        }

        // フリップ中に「もう一度フリップして」と来た場合は、最後にもう一回だけ実行する
        if (_pendingFlip)
        {
            _pendingFlip = false;
            await FlipOnceAsync();
        }
    }

    private void SwapFaces()
    {
        FrontView.IsVisible = !FrontView.IsVisible;
        BackView.IsVisible  = !BackView.IsVisible;
    }
}

「フリップ後にどちらを見せたいか」で実装を分ける

ここまでのFlipOnceAsyncは「呼ばれるたびに表裏をトグルする」実装です。要件によっては、次のように設計を分けると意図がブレません。

やりたいことおすすめ設計実装のコツ
新データが来たら裏面に切り替えて、そのまま裏面を見せ続ける「裏面表示状態」を保持するプロパティを持つトグルではなく、FlipToBackAsync/FlipToFrontAsyncに分けて最終状態を保証する
新データが来たら一度だけ“行って来い”で目立たせたい(裏→表)RequestFlipAsyncを2回呼ぶ途中でTask.Delayを入れて“見せたい時間”を確保する
テスト中だけ定期的にフリップしてアニメーションを確認したいページ側タイマー+UpdateTileSafelyAsync/RequestFlipAsyncタイマーはOnAppearingで開始、OnDisappearingで停止してリークを防ぐ

二重フリップを防ぐ:データ更新とFlip呼び出しの経路を一本化する

LiveTileControlのトラブルで特に多いのが「DataMemberが変わったら自動でフリップ」する設計と、「更新処理の中で別途フリップも呼ぶ」設計が混ざってしまうケースです。表裏の状態が一瞬ズレるだけでも、結果として“裏面のまま固まったように見える”ことがあります。

おすすめの選び方

運用方針Flipのトリガー実装の寄せ方
本番は「新データ受信時だけ自動でフリップ」DataMemberPropertyのpropertyChanged(FlipOnDataChange=true)データ更新はDataMemberを変えるだけにする(RequestFlipAsyncの乱用を避ける)
画面操作や特殊演出で「値とは別にフリップしたい」RequestFlipAsyncFlipOnDataChange=falseにして“自動フリップ”を止め、明示呼び出しに統一する
初期表示だけはフリップせずに値を出したいUpdateTileSafelyAsync(…, flip:false)最初の値セットでアニメーションを抑止し、以降はflip:trueで更新する

大事なのは「どの条件でフリップするか」を1つのルールにまとめることです。トリガーが2本以上になると、更新頻度が上がったときに必ず競合が出ます。

テスト:10秒ごとに自動フリップさせる(MainPageでDispatcherTimer)

一定間隔フリップは、ViewModel側に閉じ込めようとすると「UI要素の参照が必要」「UIスレッドでアニメーションを走らせる必要がある」という2点で詰まりやすくなります。そこで、タイマーはページ側に置き、コントロールのメソッドを呼ぶ形にするとシンプルです。

MainPage.xaml:x:Nameでコントロールを参照できるようにする

<ContentPage
    x:Class="MyApp.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:controls="clr-namespace:MyApp.Controls">


<VerticalStackLayout Padding="16" Spacing="12">
    <controls:LiveTileControl x:Name="MyLiveTile" DataMember="{Binding Data}" />
</VerticalStackLayout>


 

MainPage.xaml.cs:OnAppearing/OnDisappearingでタイマーを管理する

ページが表示されていない間もタイマーが動き続けると、バッテリー消費や予期せぬ多重呼び出しの原因になります。テスト用途であっても、開始と停止をライフサイクルで管理しましょう。

using Microsoft.Maui.Controls;

namespace MyApp;

public partial class MainPage : ContentPage
{
    private IDispatcherTimer? _timer;
    private int _counter = 0;

    public MainPage(MainPageViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
    }

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

        if (_timer != null)
            return;

        _timer = Dispatcher.CreateTimer();
        _timer.Interval = TimeSpan.FromSeconds(10);

        _timer.Tick += OnTimerTick;

        _timer.Start();
    }

    private async void OnTimerTick(object? sender, EventArgs e)
    {
        // 変化が分かりやすいようにカウンターや時刻を渡す
        _counter++;
        var text = $&quot;test #{_counter} / {DateTime.Now:HH:mm:ss}&quot;;

        // DataMemberを更新すると、FlipOnDataChange=trueなら自動でフリップする
        await MyLiveTile.UpdateTileSafelyAsync(text, flip: true);

        // もし「値は変えずにフリップだけ」を試したいならこちら
        // await MyLiveTile.RequestFlipAsync();
    }

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

        if (_timer == null)
            return;

        _timer.Tick -= OnTimerTick;
        _timer.Stop();
        _timer = null;
    }
}

ポイントは、タイマーのTickで“必ず変化する値”を渡すことです。ViewModelのDataが更新されていないのに同じ値を渡し続けると、裏面に表示されないのか、そもそも値が変わっていないのか切り分けが難しくなります。

本番:新しいデータを受信した時だけフリップさせる

本来の要件が「APIなどから新しい値を取得したときだけタイルを反転させたい」なら、最も自然なのはViewModelがDataを更新し、バインディング経由でLiveTileControlのDataMemberが更新され、propertyChangedでフリップする流れです。

データ更新の流れ(おすすめ)

  • ViewModel:APIから新しい文字列(または表示用モデル)を取得してDataプロパティへセット
  • XAMLバインド:LiveTileControlのDataMember=”{Binding Data}” によりコントロールへ反映
  • LiveTileControl:DataMemberPropertyのpropertyChangedが動き、FlipOnDataChange=trueならFlipOnceAsyncを実行

ViewModel例:Data更新だけを責務にする

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace MyApp;

public class MainPageViewModel : INotifyPropertyChanged
{
private string? _data;


public string? Data
{
    get => _data;
    set
    {
        if (_data == value)
            return;

        _data = value;
        OnPropertyChanged();
    }
}

public async Task RefreshAsync()
{
    // 例:API取得後に整形した文字列をセット
    var newData = $"API更新: {DateTime.Now:yyyy/MM/dd HH:mm:ss}";
    Data = newData;
}

public event PropertyChangedEventHandler? PropertyChanged;

private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));


} 

この設計のメリットは、ViewModelが「いつデータが変わったか」だけを管理し、UIアニメーションというUI責務はコントロール側に閉じ込められる点です。MVVMの分離を保ちつつ、実装も読みやすくなります。

「値は同じだが更新イベントとしては扱いたい」場合

API側の都合で同じ文字列が続くこともあります。その場合、ViewModel側で比較しているとDataが更新されず、結果としてフリップも起きません。こうしたケースでは、次のいずれかで“更新”を表現します。

  • Dataとは別に、UpdateId(カウンター)やLastUpdated(日時)を持ち、タイル表示に混ぜる
  • Dataは同じでも、受信イベントに合わせてRequestFlipAsyncを呼ぶ(FlipOnDataChange=falseの運用が分かりやすい)

裏面にデータが表示されないときのチェックリスト

フリップはできているのに裏面の文字が空、あるいは更新されない場合は、アニメーションより先に“バインディング”を疑うべきです。特にカスタムコントロール内のBindingContextは、ページとは別の文脈になることがあるため注意が必要です。

チェック項目よくあるミス解決のヒント
裏面Labelが参照しているBindingText=”{Binding DataMember}” のままで、BindingContextがViewModel側を向いているText=”{Binding Source={x:Reference Tile}, Path=DataMember}” のように、コントロール自身を参照する
x:Nameの指定x:Referenceの参照先名が違う/同名が重複しているContentViewにx:Name=”Tile”を付け、参照先を統一する
値が変わっているかタイマーから毎回同じ値を渡している/Dataが更新されていないDateTime.Nowやカウンターなど、毎回変化する値で検証する
UIスレッドで更新できているかバックグラウンドスレッドからDataMember更新やFlipを呼んでいるMainThread.InvokeOnMainThreadAsyncやDispatcherでUIスレッドへ切り替える

実務で効く改善アイデア

多重更新が頻繁に来るなら「最後の更新だけ反映」する

通知やストリーミングのように短時間で連続更新が来る場合、毎回フリップするとUIが落ち着きません。次のような考え方が現実的です。

  • フリップ中に新しいデータが来たら、いったんDataMemberだけ更新しておき、アニメーションが終わったら最後にもう一度だけフリップする
  • あるいは、フリップを一定の最小間隔(例:2秒)で間引く

上で紹介した_pendingFlipは「フリップ中にもう1回だけ予約する」簡易実装です。さらに厳密にするなら、SemaphoreSlimで直列化したり、CancellationTokenSourceで古いアニメーションを打ち切る設計も検討できます。

フリップ方向・速度・イージングは“情報の重要度”で変える

同じフリップでも、重要な更新と軽微な更新で演出を変えると、ユーザーが情報の優先度を直感的に理解しやすくなります。

  • 重要な更新:FlipDurationをやや長め(例:700〜900ms)+Easing.CubicInOutで“ため”を作る
  • 軽微な更新:FlipDuration短め(例:300〜500ms)+Easing.Linearで軽快に
  • エラー通知:色変更やアイコン点滅など、フリップ以外の視覚シグナルも併用する

テスト用タイマーは“本番に持ち込まない”

定期フリップはあくまで動作確認のための仕組みとして割り切ると安全です。本番は「データ更新時のみフリップ」へ寄せたほうが、次の観点でメリットがあります。

  • 無駄なアニメーションが減り、バッテリーやGPU負荷が下がる
  • 更新がないのに動き続けるUIは、ユーザーにとってノイズになりやすい
  • バックグラウンド復帰時のタイマー再開など、地味なバグ要因が減る

よくある質問

FrontView/BackViewを個別に回転しても直せますか?

可能ではありますが、状態管理が複雑になりがちです。回転角が「どちらを正面としているか」を常に表すように設計し、IsVisibleの切り替えと同期させる必要があります。今回のようにフリップが止まる症状が出ているなら、まずは回転対象をコントロール全体に集約したほうが、原因の切り分けも保守もラクになります。

UpdateTileSafelyAsyncを使うときの注意点は?

FlipOnDataChange=trueのままUpdateTileSafelyAsync(..., flip:true)を呼ぶなら、更新は1回でフリップも1回になるよう設計されています(内部で二重フリップしないよう抑止します)。もし自分の実装で「UpdateTileSafelyAsyncの中でもフリップ」「propertyChangedでもフリップ」のように両方を呼んでいる場合は、どちらかを削って一本化してください。

フリップ中にタップされると困ります

アニメーション中だけ操作を無効化したい場合は、フリップ開始時にInputTransparent = true、終了時にfalseへ戻すのが簡単です。タイルがボタン的な役割を持つ場合は、誤タップ防止として効果があります。

まとめ:安定したフリップのための最短ルート

  • フリップが一度きりで止まる場合、Front/Back個別回転とIsVisible切り替えの不整合が起点になりやすい
  • 回転対象をLiveTileControl全体(this.RotationY)へ集約し、90度で表示面を入れ替えると安定する
  • フリップのトリガーは一本化し、データ更新時(FlipOnDataChange)か明示呼び出し(RequestFlipAsync)のどちらかに寄せる
  • テストの定期フリップはページ側のDispatcherTimerで管理し、ライフサイクルで開始・停止する
  • 裏面にデータが出ないときは、まずx:Referenceでバインディング参照先を固定して確認する

この形に整えると、LiveTileControlは「データの表示」と「表裏切り替え」という役割に集中でき、ViewModelは「データの取得・更新」に集中できます。結果として不具合の再発も減り、将来的にタイルのデザインや更新頻度を変えたくなったときも拡張しやすくなります。

この記事を書いた人

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

コメント

コメントする

目次