У Razor Pages дані передавалися у View через властивості PageModel — прості публічні поля класу. У MVC все інакше: Controller і View — окремі класи, і між ними потрібен явний «канал» передачі даних. ASP.NET Core надає три механізми — і один з них значно кращий за інші.
Перш ніж розбиратися з передачею даних, розберемо де живуть Views.
Views/
├── Shared/ ← спільні Views для всіх Controllers
│ ├── _Layout.cshtml ← базовий шаблон (те саме що у Razor Pages)
│ ├── Error.cshtml ← сторінка помилки
│ └── _ValidationScriptsPartial.cshtml
├── _ViewStart.cshtml ← вказує _Layout для всіх Views
├── _ViewImports.cshtml ← глобальні using та @addTagHelper
├── Home/ ← папка = ім'я Controller (без "Controller")
│ ├── Index.cshtml ← Action name
│ └── About.cshtml
├── Movie/ ← MovieController
│ ├── Index.cshtml
│ ├── Details.cshtml
│ └── Create.cshtml
└── Product/ ← ProductController
└── Index.cshtml
public class MovieController : Controller
{
public IActionResult Index()
{
// View() без аргументів → шукає Views/Movie/Index.cshtml
return View();
}
public IActionResult Details(int id)
{
// View(model) → Views/Movie/Details.cshtml + передає model
return View(someModel);
}
public IActionResult SpecialPage()
{
// View("CustomName") → Views/Movie/CustomName.cshtml
return View("CustomName");
}
public IActionResult SharedPage()
{
// View з абсолютним шляхом → Views/Shared/CommonView.cshtml
return View("~/Views/Shared/CommonView.cshtml");
}
}
Конвенція: {ControllerName}/{ActionName}.cshtml. Якщо View не знайдено — ASP.NET шукає у Views/Shared/.
objectViewData — це ViewDataDictionary, словник string → object. Доступний і в Controller, і у View:
public IActionResult Index()
{
ViewData["Title"] = "Список фільмів"; // string
ViewData["TotalCount"] = 42; // int
ViewData["IsAdmin"] = User.IsInRole("Admin"); // bool
ViewData["Movies"] = await _service.GetAllAsync(); // List<Movie>
return View();
}
@* Потрібне явне приведення типів — compile-time перевірки немає *@
<h1>@ViewData["Title"]</h1>
<p>Всього: @ViewData["TotalCount"]</p>
@* Небезпечно: якщо ключ відсутній → null, якщо тип не той → RuntimeException *@
@{
var movies = ViewData["Movies"] as List<Movie>;
var count = (int)ViewData["TotalCount"]!; // explicit cast потрібен
}
@foreach (var m in movies ?? [])
{
<div>@m.Title</div>
}
Недоліки: немає compile-time перевірки, потрібні касти, магічні рядки-ключі.
ViewBag — той самий ViewData, але через dynamic синтаксис:
public IActionResult Index()
{
ViewBag.Title = "Список фільмів"; // те саме що ViewData["Title"]
ViewBag.TotalCount = 42;
ViewBag.IsAdmin = User.IsInRole("Admin");
// Насправді зберігається у ViewData["Title"], ViewData["TotalCount"]...
return View();
}
<h1>@ViewBag.Title</h1> @* dynamic — без каст *@
<p>Всього: @ViewBag.TotalCount</p>
@* Але все одно немає IntelliSense і compile-time перевірки *@
@if (ViewBag.IsAdmin)
{
<a href="/admin">Адмін-панель</a>
}
Недоліки: dynamic — жодної compile-time перевірки, помилки лише в runtime, немає IntelliSense.
ViewData і ViewBag — це два інтерфейси до одного сховища. ViewBag.Title = "x" та ViewData["Title"] = "x" — рівнозначні операції. Тому у View можна встановити через ViewBag, а прочитати через ViewData і навпаки.ViewModel — це C# клас або record, спеціально створений для конкретного View. Типізована модель, яку передають через View(model):
namespace MovieApp.Models.ViewModels;
public record MovieIndexViewModel(
List<Movie> Movies,
int TotalCount,
string? FilterGenre,
bool IsAdmin,
int CurrentPage,
int TotalPages
);
public async Task<IActionResult> Index(string? genre, int page = 1)
{
var movies = await _service.GetAllAsync(genre, page);
var totalCount = await _service.CountAsync(genre);
// Створюємо ViewModel з усіма потрібними даними
var viewModel = new MovieIndexViewModel(
Movies: movies,
TotalCount: totalCount,
FilterGenre: genre,
IsAdmin: User.IsInRole("Admin"),
CurrentPage: page,
TotalPages: (int)Math.Ceiling(totalCount / 10.0)
);
return View(viewModel); // ← передаємо ViewModel
}
@* Оголошуємо тип моделі — тепер є IntelliSense і compile-time перевірка *@
@model MovieApp.Models.ViewModels.MovieIndexViewModel
<h1>Фільми (@Model.TotalCount)</h1>
@if (Model.IsAdmin)
{
<a asp-action="Create" class="btn btn-primary">Додати фільм</a>
}
@foreach (var movie in Model.Movies)
{
<div>@movie.Title — @movie.Genre</div>
}
@* Пагінація *@
<nav>
@for (int i = 1; i <= Model.TotalPages; i++)
{
<a asp-action="Index"
asp-route-page="@i"
asp-route-genre="@Model.FilterGenre"
class="@(i == Model.CurrentPage ? "btn btn-primary" : "btn btn-outline-secondary")">
@i
</a>
}
</nav>
Переваги ViewModel:
asp-for Tag Helper працює з властивостями ViewModel| Сценарій | Рекомендація |
|---|---|
| Основні дані сторінки | ViewModel — завжди |
Заголовок сторінки <title> | ViewData["Title"] — загальноприйнята конвенція |
| Breadcrumbs, поточна секція меню | ViewData або ViewBag — дрібні метадані |
| Дані з Parent для Layout | ViewData — так як Layout не має доступу до ViewModel |
| Flash Messages після redirect | TempData |
TempData — це сховище що живе між двома запитами. Ідеально для Flash Messages (повідомлень після redirect):
// POST /movie/create — обробляємо форму
[HttpPost]
public async Task<IActionResult> Create(CreateMovieDto dto)
{
if (!ModelState.IsValid) return View(dto);
var movie = await _service.CreateAsync(dto);
// Зберігаємо повідомлення — воно переживе redirect
TempData["Success"] = $"Фільм «{movie.Title}» успішно додано!";
TempData["NewMovieId"] = movie.Id; // можна зберігати прості типи
// Redirect — перший запит завершено
return RedirectToAction(nameof(Index));
}
// GET /movie — після redirect
public async Task<IActionResult> Index()
{
var movies = await _service.GetAllAsync();
// TempData["Success"] буде доступний у View
// Після читання він автоматично видаляється
return View(movies);
}
@* У _Layout.cshtml — щоб Flash Messages з'являлися на будь-якій сторінці *@
@if (TempData["Success"] is string successMsg)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@successMsg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] is string errorMsg)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@errorMsg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
// TempData читається один раз і видаляється
// Якщо потрібно зберегти для наступного запиту:
TempData.Keep("Success"); // зберегти після читання
// Прочитати без видалення:
var msg = TempData.Peek("Success") as string;
| ViewData/ViewBag | TempData | Session | |
|---|---|---|---|
| Живе | В рамках одного запиту | Між двома запитами (Request→Redirect→Request) | До явного очищення або timeout |
| Зберігання | In-memory (HTTP context) | Cookie або Session | Session store |
| Типи значень | Будь-які | Прості типи (string, int, bool) | Прості типи |
| Використання | Поточна View | Flash Messages після redirect | Кошик, профіль користувача |
Partial Views — ті самі .cshtml файли що і у Razor Pages, але розташовані у Views/Shared/ або Views/{Controller}/:
@* Partial View приймає власну модель *@
@model MovieApp.Models.Movie
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">@Model.Title</h5>
<p class="card-text text-muted">@Model.Genre · @Model.Year</p>
<a asp-controller="Movie" asp-action="Details"
asp-route-id="@Model.Id" class="btn btn-sm btn-outline-primary">
Детальніше
</a>
</div>
</div>
@model List<Movie>
@* Варіант 1: <partial> tag helper (рекомендований) *@
@foreach (var movie in Model)
{
<partial name="_MovieCard" model="movie" />
}
@* Варіант 2: Html.PartialAsync (старіший стиль) *@
@await Html.PartialAsync("_MovieCard", movie)
@* Варіант 3: Html.Partial (синхронний, не рекомендований) *@
@Html.Partial("_MovieCard", movie)
Будуємо покроково Controller що демонструє всі три механізми передачі даних та TempData.
namespace MovieApp.Models;
public record Movie(
int Id,
string Title,
string Genre,
int Year,
string Director,
decimal Rating,
bool IsAvailable
);
namespace MovieApp.Models.ViewModels;
// Strongly-typed ViewModel для сторінки списку
public record MovieIndexViewModel(
IReadOnlyList<Movie> Movies,
IReadOnlyList<string> Genres, // для dropdown фільтра
string? ActiveGenre,
int TotalCount,
bool CanAddMovies // умова показу кнопки "Додати"
);
using System.ComponentModel.DataAnnotations;
namespace MovieApp.Models;
public class CreateMovieDto
{
[Required(ErrorMessage = "Вкажіть назву")]
[StringLength(200, MinimumLength = 2)]
public string Title { get; set; } = "";
[Required] public string Genre { get; set; } = "";
[Required] public string Director { get; set; } = "";
[Range(1888, 2030, ErrorMessage = "Рік від 1888 до 2030")]
public int Year { get; set; } = DateTime.Now.Year;
[Range(0, 10, ErrorMessage = "Рейтинг від 0 до 10")]
public decimal Rating { get; set; }
}
using Microsoft.AspNetCore.Mvc;
using MovieApp.Models;
using MovieApp.Models.ViewModels;
using MovieApp.Services;
namespace MovieApp.Controllers;
public class MovieController : Controller
{
private readonly IMovieService _service;
public MovieController(IMovieService service)
{
_service = service;
}
// ─── Спосіб 3: STRONGLY-TYPED VIEWMODEL (рекомендований) ─────
// GET /movie
public async Task<IActionResult> Index(string? genre)
{
var movies = genre is null
? await _service.GetAllAsync()
: await _service.GetByGenreAsync(genre);
var allGenres = await _service.GetAllGenresAsync();
// ViewData["Title"] — загальноприйнята конвенція для <title>
ViewData["Title"] = genre is null ? "Всі фільми" : $"Жанр: {genre}";
// ViewModel містить всі дані що потрібні View
var viewModel = new MovieIndexViewModel(
Movies: movies,
Genres: allGenres,
ActiveGenre: genre,
TotalCount: movies.Count,
CanAddMovies: true // пізніше: User.IsInRole("Admin")
);
return View(viewModel);
}
// ─── ДЕТАЛІ: простий ViewModel = сам об'єкт ──────────────────
// GET /movie/details/5
public async Task<IActionResult> Details(int id)
{
var movie = await _service.GetByIdAsync(id);
if (movie is null) return NotFound();
// ViewData для breadcrumbs — дрібні метадані
ViewData["Title"] = movie.Title;
ViewData["BreadcrumbTitle"] = movie.Title;
// Передаємо Movie напряму — він і є ViewModel
return View(movie);
}
// ─── CREATE: форма та збереження ─────────────────────────────
[HttpGet]
public IActionResult Create()
{
// Ініціалізуємо DTO для форми
return View(new CreateMovieDto());
}
[HttpPost]
public async Task<IActionResult> Create(CreateMovieDto dto)
{
if (!ModelState.IsValid)
return View(dto);
var movie = await _service.CreateAsync(dto);
// TempData — Flash Message що переживе redirect
TempData["Success"] = $"Фільм «{movie.Title}» ({movie.Year}) додано до каталогу!";
return RedirectToAction(nameof(Index));
}
// ─── DELETE з Flash Message ───────────────────────────────────
[HttpPost]
public async Task<IActionResult> Delete(int id)
{
var movie = await _service.GetByIdAsync(id);
if (movie is null) return NotFound();
await _service.DeleteAsync(id);
TempData["Success"] = $"Фільм «{movie.Title}» видалено.";
return RedirectToAction(nameof(Index));
}
// ─── ДЕМОНСТРАЦІЯ: ViewBag і ViewData порівняно ───────────────
// GET /movie/comparison — показує всі три підходи на одній сторінці
public IActionResult Comparison()
{
// ViewData (словник)
ViewData["Title"] = "Порівняння підходів";
ViewData["MovieCount"] = 42;
ViewData["FeaturedMovie"] = new Movie(1, "Тіні забутих предків", "Драма",
1965, "Сергій Параджанов", 9.2m, true);
// ViewBag (dynamic — те саме сховище)
ViewBag.PageSubtitle = "ViewData vs ViewBag vs ViewModel";
ViewBag.IsHighlighted = true;
// Strongly-typed ViewModel — передаємо через View(model)
var vm = new MovieIndexViewModel(
Movies: [],
Genres: ["Драма", "Комедія", "Трилер"],
ActiveGenre: null,
TotalCount: 0,
CanAddMovies: false
);
return View("Comparison", vm);
}
}
@* Strongly-typed ViewModel *@
@model MovieApp.Models.ViewModels.MovieIndexViewModel
@{ ViewData["Title"] ??= "Фільми"; }
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>@ViewData["Title"] <span class="text-muted fs-5">(@Model.TotalCount)</span></h1>
@if (Model.CanAddMovies)
{
<a asp-action="Create" class="btn btn-primary">+ Додати фільм</a>
}
</div>
@* Flash Message з TempData *@
@if (TempData["Success"] is string msg)
{
<div class="alert alert-success alert-dismissible fade show">
@msg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@* Фільтр по жанру (ViewModel надає список жанрів) *@
<div class="mb-3">
<a asp-action="Index" class="btn btn-sm @(Model.ActiveGenre is null ? "btn-dark" : "btn-outline-dark")">
Всі
</a>
@foreach (var genre in Model.Genres)
{
<a asp-action="Index" asp-route-genre="@genre"
class="btn btn-sm @(genre == Model.ActiveGenre ? "btn-dark" : "btn-outline-dark")">
@genre
</a>
}
</div>
@if (!Model.Movies.Any())
{
<p class="text-muted">У цьому жанрі фільмів поки немає.</p>
}
else
{
<div class="row row-cols-1 row-cols-md-3 g-3">
@foreach (var movie in Model.Movies)
{
@* Partial View для картки фільму *@
<div class="col">
<partial name="_MovieCard" model="movie" />
</div>
}
</div>
}
@model MovieApp.Models.Movie
<div class="card h-100">
<div class="card-body">
<span class="badge bg-secondary float-end">@Model.Genre</span>
<h5 class="card-title">@Model.Title</h5>
<p class="card-text text-muted">@Model.Director, @Model.Year</p>
<div class="d-flex align-items-center gap-2">
<span class="text-warning">★</span>
<strong>@Model.Rating:F1</strong>
@if (!Model.IsAvailable)
{
<span class="badge bg-secondary ms-auto">Недоступний</span>
}
</div>
</div>
<div class="card-footer bg-transparent">
<a asp-controller="Movie" asp-action="Details" asp-route-id="@Model.Id"
class="btn btn-sm btn-outline-primary">Детальніше</a>
<form asp-controller="Movie" asp-action="Delete" asp-route-id="@Model.Id"
method="post" class="d-inline ms-1">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Видалити «@Model.Title»?')">
Видалити
</button>
</form>
</div>
</div>
@model MovieApp.Models.CreateMovieDto
@{ ViewData["Title"] = "Новий фільм"; }
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-action="Index">Фільми</a></li>
<li class="breadcrumb-item active">Додати фільм</li>
</ol>
</nav>
<h1>Додати фільм</h1>
<div class="row">
<div class="col-md-6">
<form asp-action="Create" method="post">
<div class="mb-3">
<label asp-for="Title" class="form-label">Назва</label>
<input asp-for="Title" class="form-control">
<span asp-validation-for="Title" class="text-danger"></span>
</div>
<div class="row">
<div class="col mb-3">
<label asp-for="Genre" class="form-label">Жанр</label>
<input asp-for="Genre" class="form-control">
<span asp-validation-for="Genre" class="text-danger"></span>
</div>
<div class="col mb-3">
<label asp-for="Year" class="form-label">Рік</label>
<input asp-for="Year" class="form-control" type="number">
<span asp-validation-for="Year" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="Director" class="form-label">Режисер</label>
<input asp-for="Director" class="form-control">
<span asp-validation-for="Director" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Rating" class="form-label">Рейтинг (0–10)</label>
<input asp-for="Rating" class="form-control" type="number" step="0.1">
<span asp-validation-for="Rating" class="text-danger"></span>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Зберегти</button>
<a asp-action="Index" class="btn btn-outline-secondary">Скасувати</a>
</div>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Завдання 1.1. У _Layout.cshtml додайте відображення TempData["Success"] і TempData["Error"] як Bootstrap alerts. Переконайтеся що після успішного Create → Redirect вони з'являються на сторінці Index і зникають при наступному перезавантаженні.
Завдання 1.2. Поясніть практичну різницю: у якому випадку ViewBag.Count = 42 у Controller не буде доступним у View? (Підказка: коли відбувається redirect).
Завдання 2.1. Для MovieController.Index створіть MovieIndexViewModel що також містить: TopRatedMovie (Movie з найвищим рейтингом), AverageRating (decimal), GenreDistribution (Dictionary<string, int> — кількість фільмів у кожному жанрі). Відобразіть ці дані у View у вигляді статистики над таблицею.
Завдання 2.2. Реалізуйте Partial View _PaginationNav що приймає ViewModel з полями CurrentPage, TotalPages, Controller, Action. Відображає навігацію «← Попередня | 1 2 3 | Наступна →». Використайте у MovieController.Index без зміни Controller коду.
Завдання 3.1. Реалізуйте повноцінну сторінку порівняння фільмів: MovieController.Compare(int[] ids) — отримує масив id через query string, повертає CompareMoviesViewModel з властивостями: IReadOnlyList<Movie> Movies та Dictionary<string, object[]> AttributeComparison (для кожного атрибуту — масив значень для кожного фільму). View — порівняльна таблиця де рядки = атрибути, стовпці = фільми. Максимум 4 фільми для порівняння — якщо більше, повертайте BadRequest.
View() → шукає Views/{Controller}/{Action}.cshtml; View(model) → передає typed modelstring→object, потребує cast, без IntelliSensedynamic wrapper над ViewData, без compile-time перевіркиasp-for Tag Helper<partial name="_Name" model="item" /> — без змін порівняно з Razor PagesУ наступній статті — Filters: аспектно-орієнтоване програмування в MVC. Filter pipeline, 5 типів фільтрів, global vs action-level filters.
Model Binding: від HTTP до C#
Model Binding у ASP.NET Core MVC: прив'язка параметрів через методи (не [BindProperty]!), порядок пошуку Route→Query→Form→Body, атрибути [FromRoute]/[FromQuery]/[FromForm]/[FromBody]/[FromHeader]/[FromServices], Custom Model Binders через IModelBinder, TryUpdateModelAsync. Демо: ProductSearchController з Money custom binder.
Filters: аспектно-орієнтоване програмування в MVC
Filters в ASP.NET Core MVC: filter pipeline (Authorization → Resource → Action → Exception → Result), п'ять типів фільтрів, async-варіанти, [ServiceFilter] та [TypeFilter], global filters через MvcOptions. Демо: ExecutionTimeFilter, MaintenanceModeFilter, AuditLogFilter.