.NET MAUI Shellで「戻る」時に前のページ状態を保持する最短手順|スクロール位置・バインドを消さない実装

一覧→詳細→戻るのたびにリストが先頭へ飛ぶ――.NET MAUI Shell でよく相談される現象です。原因のほとんどは「ページのライフタイム(生成と破棄)」にあります。本記事では、Shell を使い続けたまま 前ページのインスタンスを再利用し、スクロール位置やバインド済みデータを保持する実装を、設計意図・コード・落とし穴まで含めて徹底解説します。

目次

.NET MAUI Shell で「戻る」時に前ページ状態(スクロール位置など)を保持する方法

課題の整理

一覧ページ(CollectionView を含む)から詳細ページへ遷移し、戻ると一覧が再描画され、スクロール位置や選択状態、バインド済みコレクションが失われることがあります。
従来の NavigationPage の Push/Pop はスタック上に同一インスタンスが残るため状態が維持されやすいのに対し、Shell.Current.GoToAsync() では ページの生成方法次第で毎回新しいインスタンスになり、結果として「戻る=初期化」のように見えることがある、というのが本質です。

結論(最短の解決策)

一覧ページとその ViewModel を DI コンテナに Singleton 登録するだけで、Shell は毎回同じインスタンスを解決して再利用します。これにより、CollectionView のスクロール位置・選択状態・ロード済みコレクションなど、ページの インスタンスに紐づく状態が保持されます。

// MauiProgram.cs
builder.Services.AddSingleton<ListingViewModel>();
builder.Services.AddSingleton<ListingPage>();

// 詳細は都度生成でOK(重くしない)
builder.Services.AddTransient<DetailsViewModel>();
builder.Services.AddTransient<DetailsPage>();

ルート登録は従来通りで構いません。

// AppShell.cs / AppShell.xaml.cs など
Routing.RegisterRoute(nameof(DetailsPage), typeof(DetailsPage));

遷移コードもそのままで問題ありません。

// 一覧 → 詳細
await Shell.Current.GoToAsync(nameof(DetailsPage), true, new Dictionary<string, object>
{
    ["Item"] = selectedItem
});

// 詳細 → 戻る(相対パスで Pop 相当)
await Shell.Current.GoToAsync(".."); 

DI ライフタイムと状態保持の関係

ライフタイム概要戻る時の状態主な用途
AddTransient解決のたびに新インスタンス初期化される(状態は基本的に失われる)軽量な一時ページ、詳細ページなど
AddSingletonアプリ存続中は同一インスタンス保持される(スクロール位置・選択・データ)一覧やタブのルートページ、状態を持つ VM
AddScopedMAUI では明示スコープを使わない限り実質 Transient 相当シナリオ依存特殊なフロー単位の共有が必要なとき

重要なのは「Shell はページ生成時に IServiceProvider を使って解決し、既定でページは Transient として登録されがち」という点です。
一覧ページを Singleton にするだけで「戻っても同じインスタンス」に切り替わります。


実装フロー:最短 3 ステップ

1) MauiProgram.cs に Singleton を登録

using Microsoft.Extensions.DependencyInjection;

public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp();

```
    // ViewModel / Page のライフタイムを明示
    builder.Services.AddSingleton&lt;ListingViewModel&gt;();
    builder.Services.AddSingleton&lt;ListingPage&gt;();

    builder.Services.AddTransient&lt;DetailsViewModel&gt;();
    builder.Services.AddTransient&lt;DetailsPage&gt;();

    // 依存サービス(API クライアント等)は責務に応じて
    builder.Services.AddHttpClient&lt;IProductsApi, ProductsApi&gt;();

    return builder.Build();
}
```

} 

2) AppShell にルートを登録

// AppShell.xaml(例)
<Shell
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:views="clr-namespace:YourApp.Views"
    x:Class="YourApp.AppShell">

```
&lt;TabBar&gt;
    &lt;ShellContent
        Title="一覧"
        Route="listing"
        ContentTemplate="{DataTemplate views:ListingPage}" /&gt;
&lt;/TabBar&gt;
```

 
// AppShell.xaml.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute(nameof(DetailsPage), typeof(DetailsPage));
    }
}

ポイント:

  • 一覧ページを ShellContent の ContentTemplate で定義しておくと、タブ(またはフライアウト)直下の「常駐ページ」として扱いやすくなります。
  • Singleton と組み合わせると、より確実に 同一インスタンスが再利用されます。

3) 一覧ページ:スクロール位置を“保険的に”保存・復元

Singleton だけで多くのケースは解決しますが、OS の再作成・レイアウト変更・自前の再初期化などで位置がズレる可能性もあります。
そのため CollectionView の Scrolled を使って「最初に見えているインデックス」を ViewModel に保持し、OnAppearing で ScrollTo する保険を入れておくと安心です。

// ListingViewModel.cs
public class ListingViewModel : INotifyPropertyChanged
{
    public ObservableCollection&lt;Product&gt; Items { get; } = new();

    private int? _firstVisibleIndex;
    public int? FirstVisibleIndex
    {
        get =&gt; _firstVisibleIndex;
        set { _firstVisibleIndex = value; OnPropertyChanged(nameof(FirstVisibleIndex)); }
    }

    public ICommand RefreshCommand { get; }
    public ICommand ItemTappedCommand { get; }

    private readonly IProductsApi _api;

    public ListingViewModel(IProductsApi api)
    {
        _api = api;
        RefreshCommand = new Command(async () =&gt; await LoadAsync());
        ItemTappedCommand = new Command&lt;Product&gt;(async item =&gt; {
            if (item == null) return;
            var navParams = new Dictionary&lt;string, object&gt; { ["Item"] = item };
            await Shell.Current.GoToAsync(nameof(DetailsPage), true, navParams);
        });
    }

    public async Task LoadAsync()
    {
        if (Items.Count &gt; 0) return; // 既にロード済みならスキップ(状態保持)
        var products = await _api.GetProductsAsync(page: 1);
        Items.Clear();
        foreach (var p in products) Items.Add(p);
    }

    public event PropertyChangedEventHandler PropertyChanged;
    void OnPropertyChanged(string name) =&gt; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
// ListingPage.xaml
&lt;ContentPage
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    x:Class="YourApp.Views.ListingPage"
    Title="一覧"&gt;

    &lt;Grid RowDefinitions="Auto,*"&gt;
        &lt;Button Text="更新" Command="{Binding RefreshCommand}" Margin="12" /&gt;
        &lt;CollectionView
            x:Name="List"
            Grid.Row="1"
            ItemsSource="{Binding Items}"
            SelectionMode="Single"
            SelectionChanged="OnSelectionChanged"
            Scrolled="OnListScrolled"
            RemainingItemsThreshold="20"
            RemainingItemsThresholdReached="OnLoadMore"&gt;

            &lt;CollectionView.ItemTemplate&gt;
                &lt;DataTemplate&gt;
                    &lt;Grid Padding="12"&gt;
                        &lt;Label Text="{Binding Name}" FontAttributes="Bold" /&gt;
                        &lt;Label Text="{Binding Price, StringFormat='\\{0:C\\}'}" Grid.Row="1"/&gt;
                    &lt;/Grid&gt;
                &lt;/DataTemplate&gt;
            &lt;/CollectionView.ItemTemplate&gt;

        &lt;/CollectionView&gt;
    &lt;/Grid&gt;
&lt;/ContentPage&gt;
// ListingPage.xaml.cs
public partial class ListingPage : ContentPage
{
    public ListingViewModel Vm { get; }

    public ListingPage(ListingViewModel vm)
    {
        InitializeComponent();
        BindingContext = Vm = vm;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        await Vm.LoadAsync();

        // 保険:保存済みのインデックスにスクロール(アニメーションなし)
        if (Vm.FirstVisibleIndex.HasValue)
        {
            List.ScrollTo(Vm.FirstVisibleIndex.Value, position: ScrollToPosition.Start, animate: false);
        }
    }

    void OnListScrolled(object sender, ItemsViewScrolledEventArgs e)
    {
        Vm.FirstVisibleIndex = e.FirstVisibleItemIndex;
    }

    async void OnLoadMore(object sender, EventArgs e)
    {
        // 追尾読み込みのサンプル(省略可)
        // var more = await Vm.LoadMoreAsync();
        // ...
    }

    async void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var item = e.CurrentSelection?.FirstOrDefault() as Product;
        if (item == null) return;
        await Vm.ItemTappedCommand.ExecuteAsync(item);
        ((CollectionView)sender).SelectedItem = null; // 戻ったとき選択が残るなら適宜リセット
    }
}
// DetailsPage.xaml.cs(抜粋)
[QueryProperty(nameof(Item), "Item")]
public partial class DetailsPage : ContentPage
{
    public Product Item
    {
        get =&gt; (Product)BindingContext;
        set =&gt; BindingContext = value;
    }

    public DetailsPage(DetailsViewModel vm)
    {
        InitializeComponent();
        // 必要に応じて vm と Item を接続
    }

    async void OnBackClicked(object sender, EventArgs e)
    {
        await Shell.Current.GoToAsync(".."); // 相対戻り(Pop 相当)
    }
}

これで OK。 一覧ページはアプリ中ずっと同一インスタンスであり、スクロール位置や Items が保持されます。
さらに 保険の ScrollTo により、配置変更やレイアウト再計算が入っても実用上ズレにくくなります。


なぜ「戻る」で状態が消えるのか ― ライフサイクルの落とし穴

ページの生成経路

  • 相対戻り(GoToAsync("..")):ナビゲーションスタックから Pop され、本来は前ページインスタンスが残る挙動です。
  • 絶対遷移(GoToAsync("//listing") など):スタックを組み替えながら ルートを再生成します。結果として一覧が新規インスタンスになりがちです。
  • 型指定のルート呼び出し(GoToAsync(nameof(ListingPage))):戻るの代わりに「新しい一覧へ遷移」しているため、再生成されます。
やりたいこと良い例避けたい例理由
前ページへ戻るawait Shell.Current.GoToAsync("..")await Shell.Current.GoToAsync(nameof(ListingPage))後者は新規ページを生成してしまう
ルートへ戻る(リセット)await Shell.Current.GoToAsync("//listing")—これは「状態を捨てたい」時に使う
状態維持したいSingleton 登録 + 相対戻りTransient のまま絶対遷移再生成のたびに初期化される

つまり、戻る操作が「Pop」ではなく「再ナビゲーション」になっていると、ページは再生成されます。
Singleton 登録はこの問題を構造的に回避します。


他の選択肢(補足情報と比較)

「手動で位置を保存・復元」方式

  • Scrolled(ItemsViewScrolledEventArgs)で FirstVisibleItemIndex と VerticalOffset を保存
  • OnAppearing で ScrollTo(index) または ScrollTo(item)

メリット:ライフタイムに依存しない/細かく制御できる
デメリット:実装コスト/仮想化・高さ計算の違いによる微妙なズレ/負荷試験が必要

状態コンテナ(軽量キャッシュ)を使う

ページを Singleton にしたくないとき、キー付きの状態レジストリを用意してスクロール位置などを保存する方法もあります。

public interface IPageStateStore
{
    void Put(string key, object state);
    T? Get<T>(string key);
}

public class PageStateStore : IPageStateStore
{
private readonly Dictionary _store = new();
public void Put(string key, object state) => _store[key] = state;
public T? Get(string key) => _store.TryGetValue(key, out var v) ? (T)v : default;
} 

一覧で Put("listing.scroll", idx)、戻ったら Get<int?>("listing.scroll") を復元するといった使い方です。

将来のプロパティ(例:NavigationCacheMode 的なもの)

他プラットフォームにある NavigationCache のような仕組みは、現時点では .NET MAUI の標準プロパティとしては提供されていません。実用上は本記事の Singleton + ScrollTo 保険がシンプルで堅実です。


メモリとパフォーマンスの注意点(Singleton の副作用)

  • 肥大化の抑制:巨大画像・動画・大規模コレクションは VM 側でページング(仮想化)する。
  • イベントの解除:タイマー、メッセンジャー、外部イベントを購読したら必ず解除。OnDisappearing や IDisposable を活用。
  • 一時データを弱参照:キャッシュに WeakReference を使い、復元不能なら再ロードする設計に。
  • 低メモリ時の再生成:OS によるプロセス再起動で状態は消えます。永続化が必要な情報は Preferences などへ。
  • スレッドセーフ:複数場所から同一 VM に触る場合は ObservableCollection の更新を UI スレッドで行う。
よくあるリーク要因対策
イベント購読の解除忘れ弱参照イベント or 明示解除(-=>)
長寿命タイマーOnDisappearing で停止/CancellationToken で制御
画像の積み上げイメージソースの破棄・縮小・キャッシュ戦略を見直す

設計チェックリスト(導入前に確認)

  • 一覧ページと VM は Singleton か?(MauiProgram.cs)
  • 戻るは GoToAsync("..") で Pop しているか?
  • 一覧の初回ロードは LoadAsync() で 二重ロードを避けるガードがあるか?
  • スクロール位置は 保険として VM に保存・復元できるか?
  • リークになりそうな購読やタイマーを適切に管理しているか?

トラブルシューティング

戻っても冒頭へ戻る/ちらつく

  • 一覧の Items を戻るたびに Clear() していないか。
  • OnAppearing で ScrollTo より先に Items を差し替えていないか。
  • データテンプレートの高さが可変で、復元直後に再配置が走っていないか(プレースホルダを使い高さを安定化)。

「戻る」で新しいインスタンスになってしまう

  • GoToAsync(nameof(ListingPage)) や //listing を使っていないか確認。
  • ListingPage / ListingViewModel が Singleton で登録されているか確認。

メモリ使用量が増える

  • VM に大きな配列を溜めない。ページング/差分更新。
  • 画像サムネイルを利用し、原寸は詳細側で読み込む。

サンプル全体像(参考)

// Product.cs
public record Product(string Id, string Name, decimal Price);

// IProductsApi.cs
public interface IProductsApi
{
    Task&lt;IEnumerable&lt;Product&gt;&gt; GetProductsAsync(int page);
}

// ProductsApi.cs(ダミー)
public class ProductsApi : IProductsApi
{
    public Task&lt;IEnumerable&lt;Product&gt;&gt; GetProductsAsync(int page)
    {
        var list = Enumerable.Range(1, 100).Select(i =&gt; new Product($"{page}-{i}", $"Item {i}", i));
        return Task.FromResult(list);
    }
}
// DetailsViewModel.cs(必要に応じて)
public class DetailsViewModel { }

この最小構成で、一覧ページは一度生成されたらアプリ終了まで再利用され、CollectionView の位置も保たれます。


もう一歩踏み込む:UI/UX を損なわない工夫

  • 選択状態の維持:戻った瞬間に「選択が残っていると見た目が固まる」ことがあります。SelectionChanged 後に SelectedItem = null で解除するか、詳細から戻る直前に解除します。
  • 骨組みスクリーン(Skeleton):初回だけスケルトンを表示し、2 回目以降は即時表示。状態保持と相性抜群。
  • 検索条件の保持:VM にフィルタやソート状態を持たせ、戻った際に同一クエリで表示できるようにする。

FAQ

ページを Singleton にするのはアンチパターンでは?

「すべてのページを Singleton」は推奨しませんが、ナビゲーションの入口となる一覧・タブ直下のルートページを Singleton にするのは、モバイルでは実用的です。重要なのは、長寿命化によるメモリ保持を設計で抑制することです。

ViewModel だけ Singleton にしてページは Transient でも良い?

データは保持されますが、CollectionView のスクロール位置や視覚状態は ページ・ビューに紐づくため、ページが再生成されれば位置は失われます。位置も維持したい場合はページを含めて Singleton にするか、復元コード(ScrollTo)を入れてください。

相対戻り(..)なのに初期化される

戻る途中で別ルートに差し替えられている、または OnAppearing 側で初期化を走らせているケースが多いです。
初期データロードに 二重実行防止のガード(if (Items.Count == 0) など)を必ず入れましょう。


まとめ

Shell で「戻ると一覧が初期化される」問題は、ページのライフタイムが Transient のまま再生成されることが主因です。
ListingPage と ListingViewModel を DI で Singleton 登録し、戻る際は GoToAsync("..") を使う――この 2 点を徹底するだけで、スクロール位置・選択・バインド済みデータは堅実に保持できます。
さらに保険として Scrolled でインデックスを保存し OnAppearing で ScrollTo を行えば、レイアウト差や端末差でも UX の一貫性を確保できます。
メモリとイベント管理にだけ気を付ければ、Shell でも 「戻ってもそのまま」の体験を簡潔なコードで実現できます。

この記事を書いた人

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

コメント

コメントする

目次