ページングの機能を追加しました。
1ページに4件表示し、Nextボタンを押すと次のページを表示し、Previousボタンを押すと前のページを表示します。
まず、はじめに図を用いて、ページングについての概要を説明します。
次に、コーディングに基づいて、解説をします。
画面サンプルを示します。
ページングの概要(図を用いての説明)
データ全体を母集団とし、母集団から表示データを抽出して、画面に表示します。
Nextボタンが押されると次のページの表示データを抽出し、Previousボタンが押されると前のページの表示データを抽出し、抽出したデータを画面に表示します。
母集団は、IQueryable<T>に格納したLINQ式のSELECTやWHEREやORDER BYによって、データベースのテーブルから抽出したレコード(エンティティ)になります。
実際は、IQueryable<T>に格納されたLINQ式に対してToListAsync()メソッドを実行することにより、母集団を得ることができます。
母集団から表示するデータを抽出して画面に表示をする処理の全体像について下図を例にして説明します。
母集団の 件数が14件 あります。
1ページ当り 4件 表示します。
したがって、全体のページ数は 4ページ になります。
この母集団から、画面に表示するデータを抽出して、それを画面に表示します。
母集団からの抽出はIQueryable<T>インターフェースが実装している、
Skipメソッド とTakeメソッド を使います。
※IQueryable<T>インタフェースは、IQueryableインタフェースを実装しているそうです。
下図(ページングの全体像)の例では、現在のページインデックスは2(2ページ目)で、ページサイズは4です。
Skipメソッド の引数に、次の計算式を引数として指定します。
(pageIndex -1)*pageSize
pageIndexが2で、pageSizeは4ですので、4つスキップします。
つぎに、
Takeメソッド の引数に、pageSizeを引数として指定します。
pageSizeは4なので、スキップした次のエンティティから4つ抽出します。
SkipメソッドとTakeメソッドで抽出するコードは下記のようになります。
var items=await source.Skip(
(pageIndex -1)*pageSize).Take(pageSize).ToListAsync();
上記コードでは、items変数に抽出結果を格納しています。
Nextボタンをクリックすると、pageIndexはプラス1(2+1)の3をGETリクエストのクエリ文字列で受け取ります。
上記のSkipメソッドとTakeメソッドにより、3ページ目を表示します。
4ページを表示しているとき、現在のページインデックスの前方にまだ表示可能なページが存在するかを判定するHasNextPage変数の値をfalseにします。
HasNextPageの値がfalseのとき、HTMLでNextボタンをdisabledにして、ボタンを押せないようにします。
現在のページインデックスの前方にまだ表示可能なページが存在するかを判定するコードは次のとおりです。
public bool HasNextPage => PageIndex < TotalPages;
なお、4ページのとき、抽出できる件数は2件です。
Takeメソッドでは引数に4を指定していますが、2件しか抽出できなくてもエラーとはならず、取り出し可能な2件を抽出します。
Previousボタンをクリックすると、pageIndexはマイナス1(2-1)の1をGETリクエストのクエリ文字列で受け取ります。
上記のSkipメソッドとTakeメソッドにより、1ページ目を表示します。
1ページを表示しているとき、現在のページインデックスの後方にまだ表示可能なページが存在するかを判定するHas Previous Page変数の値をfalseにします。
Has Previous Pageの値がfalseのとき、HTMLでPreviousボタンをdisabledにして、ボタンを押せないようにします。
現在のページインデックスの後方にまだ表示可能なページが存在するかを判定するコードは次のとおりです。
public bool HasPreviousPage => PageIndex > 1;
PaginatedListクラスでページに表示するデータの抽出処理を記述
プロジェクトフォルダ―にPaginatedListクラスを作成し、IQueryable<T>のLINQ式の実行結果から、表示するページデータを抽出する処理を記述します。
PaginatedListはList<T>クラスの継承クラスです。
メソッドとコンストラクターについて、説明します。
CreateAsyncメソッド
static指定で静的メソッドにします。
このメソッドは、PaginatedList<T>クラスのインスタンスを作成するためのファクトリーメソッドです。
SkipメソッドとTakeメソッドで母集団から画面に表示するページデータを抽出して、items変数に格納します。
母集団の作成とそこからのデータ抽出処理はToListAsyncメソッドで行います。
Items、count、pageIndex、pageSizeを引数に指定してPaginatedList<T>のインスタンスを返します。
PaginatedList<T>コンストラクタ
AddRange(items)メソッドで、当インスタンス(List<T>型)にitems変数の要素を追加します。
PageIndexプロパティ(現在のページ)とTotalPagesプロパティ(総ページ数)に値をセットします。
PageIndexプロパティとTotalPagesプロパティは、Nextボタンが押されたときに前方に表示可能なページが存在するかを判定するHasNextPageの値や、Previousボタンが押されたときに後方に表示可能なページが存在するか判定するHasPreviousPageの値を決定するために使います。
public bool HasPreviousPage => PageIndex > 1; //PegeIndexが1より大きければtrue(Previous可能)
public bool HasNextPage => PageIndex < TotalPages; //PegeIndexがTotalPagesより小さければtrue(Next可能)
コラム 静的メソッドとは 静的メソッドとは、クラスのインスタンスを作成せずに、クラス名から直接呼び出せるメソッドのことです。静的メソッドにすることで、以下のような利点があります。 ・インスタンス生成にかかるオーバーヘッドがなくなり、パフォーマンスが向上します。 ・インスタンスに依存しない共通の処理を実装できます。 ・インスタンスメソッドからも静的メソッドを呼び出せますが、その逆はできません。静的メソッドにすることで、より汎用的に使用できます。 |
コード全文を示します。
PaginatedList.cs using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; namespace BGC { public class PaginatedList<T>:List<T> { public int PageIndex { get;private set; } public int TotalPages { get; private set; } /** * コンストラクター * pageIndexと pageSizeを引数から取得 * TotalPagesを求める */ public PaginatedList(List<T>items,int count,int pageIndex,int pageSize) { PageIndex = pageIndex; TotalPages = (int)Math.Ceiling(count/(double)pageSize); //トータルページ数を得る this.AddRange(items); //←AddRange(items)でListの末尾にitemsの要素を追加しています } public bool HasPreviousPage => PageIndex > 1; //PegeIndexが1より大きければtrue(Previous可能) public bool HasNextPage => PageIndex < TotalPages; //PegeIndexがTotalPagesより小さければtrue(Next可能) /** * 静的メソッド ファクトリーメソッド * IQueryable<T>の母集団から表示するページデータをitemsに抽出し、 * `items`、`count`、`pageIndex`、`pageSize`を引数として使用して * 新しい`PaginatedList<T>`のインスタンスを返します */ public static async Task<PaginatedList<T>> CreateAsync( IQueryable<T> source,int pageIndex, int pageSize) { var count=await source.CountAsync(); //sourceの要素数をカウント var items=await source.Skip( //表示するページデータを抽出 (pageIndex -1)*pageSize).Take(pageSize).ToListAsync(); return new PaginatedList<T>(items,count,pageIndex,pageSize); } } } |
appsetting.json
“PageSize”: 4,を追加します。
1ページに表示する行数をしていします。
この値は、Index.cshtml.csのOnGetAsyncメソッドの中に次のように記述して、取得します。
var pageSize = _configuration.GetValue(“PageSize”, 4);
(“PageSize”, 4)の4は、appsetting.jsonにPageSizeが定義されていないときの既定値です。
Appsetting.jsonのコードを示します。
青字の部分が今回追加したコードです。
appsetting.json { “PageSize”: 4, “Logging”: “LogLevel”: { “Default”: “Information”, “Microsoft.AspNetCore”: “Warning” }, }, “AllowedHosts”: “*”, “ConnectionStrings”: { “BGCContext”: “Server=localhost;Database=BGC;UserId=root;Password=th8301048” } } |
Index.cshtml.cs
1ページに表示する行数(pageSize)をappSetting.jsonから取得します。
var pageSize = _configuration.GetValue(“PageSize”, 4);のようにGetValueメソッドで取得します。
そのため、事前に
using Microsoft.Extentions.Configuration;
を定義しておきます。
また、Configurationのインスタンスは、次のようにコンストラクタの引数に指定することにより生成されます。
public IndexModel(BGC.Data.BGCContext context, IConfiguration configuration)
OnGetAsyncメソッドの最後の処理で、
引数にIQueryable<Value> valueIQの実行結果(母集団のデータ)、pageIndex(表示するページ番号)、pageSize(ページサイズ)を指定して、PaginatedList<Value>クラスのCreateAsyncメソッドを呼び出して母集団から1ページ分の要素を抽出し、Valuesプロパティに格納します。
このValuesプロパティの要素は、Index.cshtmlのなかで要素を取り出して、ページデータとして表示しています。
そのため、Index.cshtmlからValuesプロパティにアクセスできるよう下記をコーディングします。
public PaginatedList<Value> Values { get;set; } = default!;
Index.cshtmlで詳しく説明します。
Index.cshtml.csのコードを示します。
全コードなので少し長いので、関係する部分だけ確認してください。
青字の部分が今回追加したコードです。
Index.cshtml.cs using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using BGC.Data; using BGC.Models; using System.ComponentModel.DataAnnotations; using System.Configuration; using Microsoft.CodeAnalysis; namespace BGC.Pages { public class IndexModel : PageModel { private readonly BGC.Data.BGCContext _context; private readonly IConfiguration _configuration; public IndexModel(BGC.Data.BGCContext context, IConfiguration configuration) { _context = context; _configuration = configuration; } /** * 並べ替え用プロパティ */ public string BloodGlucoseValueSort { get; set; } public string DateSort { get; set; } public string CurrentFilter { get; set; } public string CurrentSort { get; set; } /** * 検索条件の入力欄とバインドするプロパティ */ [BindProperty(SupportsGet =true)] [DataType(DataType.Date)] [Range(typeof(DateTime), “1956/11/19”, “2100/12/31”)] public DateTime? DateFromFilter { get; set; } [BindProperty(SupportsGet = true)] [DataType(DataType.Date)] [Range(typeof(DateTime), “1956/11/19”, “2100/12/31”)] public DateTime? DateToFilter { get; set; } [BindProperty(SupportsGet = true)] [Range(0, 9999)] public int? BGVFromFilter { get; set; } [BindProperty(SupportsGet = true)] [Range(0, 9999)] public int? BGVToFilter { get; set; } [BindProperty(SupportsGet = true)] [Range(0, 9999)] public int? TimeZoneFromFilter { get; set; } [BindProperty(SupportsGet = true)] [Range(0, 9999)] public int? TimeZoneToFilter { get; set; } /** * WHERE句を作成するときに使用する最小値と最大値を保持する変数 * デフォルト値をセット */ DateTime DateFrom = new DateTime(1956, 11, 19); DateTime DateTo = new DateTime(2100, 12, 31); int BGVFrom = 0; int BGVTo = 9999; int TimeZoneFrom = 0; int TimeZoneTo = 23; /** * WHERE句のクエリを実行するか、しないか */ bool SearchInput = false; /** * データの一覧を表示するためのリスト */ public PaginatedList<Value> Values { get;set; } = default!; /** * GETメソッド */ public async Task OnGetAsync(string sortOrder, string currentFilter, string searchString, int? pageIndex) { /** * 昇順⇔降順の切替 */ CurrentSort = sortOrder; BloodGlucoseValueSort = sortOrder == “BloodGlucoseValue” ? “BloodGlucoseValue_desc” : “BloodGlucoseValue”; DateSort = sortOrder == “Date” ? “Date_desc” : “Date”; if (searchString != null) { pageIndex = 1; //初期値設定 } else { searchString = currentFilter; } /** * クエリ式の作成 */ IQueryable<Value> valueIQ = from s in _context.Value select s; /** * WHERE句を作成する */ await CreateWhere(valueIQ); if (SearchInput) { /** * 検索条件が入力されている */ valueIQ = valueIQ.Where(s => s.Date >= DateFrom && s.Date <= DateTo && s.BloodGlucoseValue >= BGVFrom && s.BloodGlucoseValue <= BGVTo && s.TimeZone >= TimeZoneFrom && s.TimeZone <= TimeZoneTo); } /** * Order句を作成する */ if (string.IsNullOrEmpty(sortOrder)) { sortOrder = “Date”; } bool descending = false; if (sortOrder.EndsWith(“_desc”)) { sortOrder = sortOrder.Substring(0, sortOrder.Length – 5); descending = true; } if (descending) { if (sortOrder.Equals(“Date”)) { valueIQ = valueIQ.OrderByDescending(s => EF.Property<object>(s, sortOrder)).ThenBy(s => s.Time); } else if ( sortOrder.Equals(“BloodGlucoseValue”)) { valueIQ = valueIQ.OrderByDescending(s => EF.Property<object>(s, sortOrder)).ThenBy(s => s.Date).ThenBy(s => s.Time); } } else { if (sortOrder.Equals(“Date”)) { valueIQ = valueIQ.OrderBy(s => EF.Property<object>(s, sortOrder)).ThenBy(s => s.Time); } else if (sortOrder.Equals(“BloodGlucoseValue”)) { valueIQ = valueIQ.OrderBy(s => s.BloodGlucoseValue).ThenBy(s => s.Date).ThenBy(s => s.Time); } } /** * クエリの実行 */ var pageSize = _configuration.GetValue(“PageSize”, 4); Values = await PaginatedList<Value>.CreateAsync(valueIQ.AsNoTracking(), pageIndex ?? 1, pageSize); } /** * POSTメソッド * Search */ public async Task<IActionResult> OnPostAsync() { IQueryable<Value> valueIQ = from s in _context.Value select s; /** * WHERE句で使用する検索条件値を変数にセットする */ await CreateWhere(valueIQ); if (SearchInput) { /** * WHEREのクエリーを追加する */ valueIQ = valueIQ.Where(s => s.Date >= DateFrom && s.Date <= DateTo && s.BloodGlucoseValue >= BGVFrom && s.BloodGlucoseValue <= BGVTo && s.TimeZone >= TimeZoneFrom && s.TimeZone <= TimeZoneTo); } //Values = await valueIQ.AsNoTracking().ToListAsync(); return Page(); } /** * WHERE句を作成します */ public async Task<IActionResult> CreateWhere(IQueryable<Value> valueIQ) { /** * 検索条件が1つ以上指定されている場合 */ if (DateFromFilter.HasValue || DateToFilter.HasValue || BGVFromFilter != null || BGVToFilter != null || TimeZoneFromFilter != null || TimeZoneToFilter != null) { SearchInput = true; /** * 日付の範囲条件が指定されているとき */ if (DateFromFilter.HasValue && DateToFilter.HasValue) /** * From と To どちらにも検索値がある場合 */ { if (DateFromFilter == DateToFilter) { DateFrom = (DateTime)DateFromFilter; DateTo = (DateTime)DateToFilter; } else if (DateFromFilter < DateToFilter) { DateFrom = (DateTime)DateFromFilter; DateTo = (DateTime)DateToFilter; } else if (DateFromFilter > DateToFilter) { DateFrom = (DateTime)DateToFilter; DateTo = (DateTime)DateFromFilter; } } else if (DateFromFilter.HasValue || DateToFilter.HasValue) /** * From または To のどちらかに検索値がある場合 */ { if (DateFromFilter.HasValue) { DateFrom = (DateTime)DateFromFilter; DateTo = (DateTime)DateFromFilter; } else if (DateToFilter.HasValue) { DateFrom = (DateTime)DateToFilter; DateTo = (DateTime)DateToFilter; } } /** * 血糖値の範囲条件が指定されているとき */ if (BGVFromFilter.HasValue && BGVToFilter.HasValue) /** * From と To どちらにも検索値がある場合 */ { if (BGVFromFilter == BGVToFilter) { BGVFrom = (int)BGVFromFilter; BGVTo = (int)BGVToFilter; } else if (BGVFromFilter < BGVToFilter) { BGVFrom = (int)BGVFromFilter; BGVTo = (int)BGVToFilter; } else if (BGVFromFilter > BGVToFilter) { BGVFrom = (int)BGVToFilter; BGVTo = (int)BGVFromFilter; } } else if (BGVFromFilter.HasValue || BGVToFilter.HasValue) /** * From または To のどちらかに検索値がある場合 */ { if (BGVFromFilter.HasValue) { BGVFrom = (int)BGVFromFilter; BGVTo = (int)BGVFromFilter; } else if (BGVToFilter.HasValue) { BGVFrom = (int)BGVToFilter; BGVTo = (int)BGVToFilter; } } /** * 時間帯の範囲条件が指定されているとき */ if (TimeZoneFromFilter.HasValue && TimeZoneToFilter.HasValue) /** * From と To どちらにも検索値がある場合 */ { if (TimeZoneFromFilter == TimeZoneToFilter) { TimeZoneFrom = (int)TimeZoneFromFilter; TimeZoneTo = (int)TimeZoneToFilter; } else if (TimeZoneFromFilter < TimeZoneToFilter) { TimeZoneFrom = (int)TimeZoneFromFilter; TimeZoneTo = (int)TimeZoneToFilter; } else if (TimeZoneFromFilter > TimeZoneToFilter) { TimeZoneFrom = (int)TimeZoneToFilter; TimeZoneTo = (int)TimeZoneFromFilter; } } else if (TimeZoneFromFilter.HasValue || TimeZoneToFilter.HasValue) /** * From または To のどちらかに検索値がある場合 */ { if (TimeZoneFromFilter.HasValue) { TimeZoneFrom = (int)TimeZoneFromFilter; TimeZoneTo = (int)TimeZoneFromFilter; } else if (TimeZoneToFilter.HasValue) { TimeZoneFrom = (int)TimeZoneToFilter; TimeZoneTo = (int)TimeZoneToFilter; } } } return Page(); } } } |
Index.cshtml
@foreach (var item in Model.Values) 文でValuesプロパティからページに表示する要素を取り出して画面に表示します。
アンカータグでPreviousボタンを表示します。
ボタンがクリックされたときページ番号をマイナス1します。
そして、Index.cshtml/csへGETリクエストを発行します。
現在のページインデックスの後方に表示するページがない場合は、PreviousボタンをDisabledの状態にして、ボタンをクリックできないようにします。
<a asp-page=”./Index”
asp-route-sortOrder=”@Model.CurrentSort”
asp-route-pageIndex=”@(Model.Values.PageIndex – 1)”
asp-route-currentFilter=”@Model.CurrentFilter”
class=”btn btn-primary @prevDisabled”>
Previous
</a>
アンカータグでNextボタンを表示します。
ボタンがクリックされたときページ番号をプラス1します。
そして、Index.cshtml.csへGETリクエストを発行します。
現在のページインデックスの前方に表示するページがない場合は、NextボタンをDisabledの状態にして、ボタンをクリックできないようにします。
<a asp-page=”./Index”
asp-route-sortOrder=”@Model.CurrentSort”
asp-route-pageIndex=”@(Model.Values.PageIndex + 1)”
asp-route-currentFilter=”@Model.CurrentFilter”
class=”btn btn-primary @nextDisabled”>
Next
</a>
asp-route-sortOrder=”@Model.CurrentSort”と
asp-route-currentFilter=”@Model.CurrentFilter”
については、別記事で説明をしたいと思います。
ソートと検索機能が絡んでくると、説明が煩雑になるためです。
Index.cshtml @page @model BGC.Pages.IndexModel @{ ViewData[“Title”] = “血糖値管理”; } <h1>血糖値管理</h1> <p> <a asp-page=”Create”>Create New</a><span>   </span><a href=”/”>Back to full List</a> </p> <form asp-page=”./Index” method=”get”> <table class=”table”> <thead> <tr> <th>日付(From)</th> <th>日付(To)</th> <th>血糖値(From)</th> <th>血糖値(To)</th> <th>時間帯(From)</th> <th>時間帯(To)</th> <th></th> </tr> </thead> <tbody> <tr> <td><input asp-for=”DateFromFilter” class=”form-control” /> </td> <td><input asp-for=”DateToFilter” class=”form-control” /> </td> <td><input asp-for=”BGVFromFilter” class=”form-control” /> </td> <td><input asp-for=”BGVToFilter” class=”form-control” /> </td> <td><input asp-for=”TimeZoneFromFilter” class=”form-control” /> </td> <td><input asp-for=”TimeZoneToFilter” class=”form-control” /> </td> <td><input type=”submit” value=”Search” class=”btn-primary” /></td> </tr> </tbody> </table> <span asp-validation-for=”DateFromFilter” class=”text-danger”></span> <span asp-validation-for=”DateToFilter” class=”text-danger”></span> <span asp-validation-for=”BGVFromFilter” class=”text-danger”></span> <span asp-validation-for=”BGVToFilter” class=”text-danger”></span> <span asp-validation-for=”TimeZoneFromFilter” class=”text-danger”></span> <span asp-validation-for=”TimeZoneToFilter” class=”text-danger”></span> </form> <table class=”table”> <thead> <tr> <th> <a asp-page=”/Index” asp-route-sortOrder=”@Model.DateSort”> @Html.DisplayNameFor(model => model.Values[0].Date) </a> </th> <th> @Html.DisplayNameFor(model => model.Values[0].Time) </th> <th> <a asp-page=”/Index” asp-route-sortOrder=”@Model.BloodGlucoseValueSort”> @Html.DisplayNameFor(model => model.Values[0].BloodGlucoseValue) </a> </th> <th> @Html.DisplayNameFor(model => model.Values[0].TimeZone) </th> <th> @Html.DisplayNameFor(model => model.Values[0].Comment) </th> <th> @Html.DisplayNameFor(model => model.Values[0].WorkFlg) </th> <th> @Html.DisplayNameFor(model => model.Values[0].FastingFlg) </th> <th></th> </tr> </thead> <tbody> @foreach (var item in Model.Values) { <tr> <td> @Html.DisplayFor(modelItem => item.Date) </td> <td> @Html.DisplayFor(modelItem => item.Time) </td> <td> @Html.DisplayFor(modelItem => item.BloodGlucoseValue) </td> <td> @Html.DisplayFor(modelItem => item.TimeZone) </td> <td> @Html.DisplayFor(modelItem => item.Comment) </td> <td> @Html.DisplayFor(modelItem => item.WorkFlg) </td> <td> @Html.DisplayFor(modelItem => item.FastingFlg) </td> <td> <a asp-page=”./Edit” asp-route-id=”@item.id”>Edit</a> | <a asp-page=”./Details” asp-route-id=”@item.id”>Details</a> | <a asp-page=”./Delete” asp-route-id=”@item.id”>Delete</a> </td> </tr> } </tbody> </table> @{ var prevDisabled = !Model.Values.HasPreviousPage ? “disabled” : “”; var nextDisabled = !Model.Values.HasNextPage ? “disabled” : “”; } <a asp-page=”./Index” asp-route-sortOrder=”@Model.CurrentSort” asp-route-pageIndex=”@(Model.Values.PageIndex – 1)” asp-route-currentFilter=”@Model.CurrentFilter” class=”btn btn-primary @prevDisabled”> Previous </a> <a asp-page=”./Index” asp-route-sortOrder=”@Model.CurrentSort” asp-route-pageIndex=”@(Model.Values.PageIndex + 1)” asp-route-currentFilter=”@Model.CurrentFilter” class=”btn btn-primary @nextDisabled”> Next </a> |
参考にしたサイト
パート 3、ASP.NET Core の Razor ページと EF Core – 並べ替え、フィルター、ページング | Microsoft Learn
一覧画面へのページング機能追加(MVC)・・・ASP.NET Core開発ノウハウ 4-2 #C# – Qiita
SkipメソッドやTakeメソッドのリファレンスは下記を参考にしてください。
IQueryable<T> インターフェイス (System.Linq) | Microsoft Learn
追記
開発環境での作成は、残すところCSVファイル出力だけになりました。
理解をしながらステップ バイ ステップでやってきました。
ようやく、ゴールが見えてきました。
とはいえ、本番環境はLinux(Ubuntu)でWebサーバーを構築する予定ですが、マシンを購入したり、設置場所の確保の問題があったりで、いつ構築できるのか予定が立ちません。
しばらく、開発環境でいろいろ使ってみて、ブラッシュアップしていこうかな、とも思っています。
また、せっかく理解できた技術なので、もう少しアプリを作って、技術を身につけていこうかな、とも思っています。どんなアプリを作ろうかと、あれこれ考えるのも楽しいものです。