Туториал 5. TodoList
Разбор Sample-проекта TodoList — коллекции, синхронизация, фильтрация, диалоги.
Что строим
Список задач с добавлением, удалением, редактированием, поиском и отметкой выполнения. Демонстрирует работу с ObservableList, CreateSync, динамическое создание View из префабов.
Файлы: Samples~/05. TodoList/. Сцена: Scenes/Todo List.unity.
Предполагается знание: Stats.
Зачем нужна отдельная Model?
В Counter и Greeter ViewModel сам являлся состоянием. Но когда логика становится сложнее (сохранение, сетевые запросы, несколько экранов), её выносят в Model.
Model — чистый C# класс, не знающий ни о Unity, ни о ViewModel. ViewModel лишь адаптирует Model для View.
Model ViewModel View
(бизнес- (адаптер, (UI,
логика) биндинг) биндеры)
Модель
Todo
public sealed class Todo : IReadOnlyTodo
{
public string Id { get; }
public string Text { get; set; }
public bool IsCompleted { get; set; }
public Todo(string id) { Id = id; }
}
TodoStorage
Хранилище с ObservableList<Todo>:
public sealed class TodoStorage : IEnumerable<Todo>
{
private readonly ObservableList<Todo> _todos = new();
public IReadOnlyObservableList<Todo> Todos => _todos;
public void Add(string text = "", bool isCompleted = false)
{
var newTodo = new Todo(Guid.NewGuid().ToString())
{
Text = text, IsCompleted = isCompleted
};
_todos.Add(newTodo);
}
public void Remove(string id) { /* ... */ }
}
ViewModels
TodoItemViewModel — элемент списка
[ViewModel]
public sealed partial class TodoItemViewModel
{
[Access(Access.Public)]
[OneWayBind] private bool _isVisible;
[OneTimeBind] private readonly IRelayCommand _editCommand;
[OneTimeBind] private readonly IRelayCommand _deleteCommand;
// Кастомные свойства с ручной привязкой
[TwoWayBind]
public string Text
{
get => Todo.Text;
set
{
if (Todo.Text == value) return;
Todo.Text = value;
OnTextPropertyChanged();
}
}
[TwoWayBind]
public bool IsCompleted
{
get => Todo.IsCompleted;
set
{
if (Todo.IsCompleted == value) return;
Todo.IsCompleted = value;
OnIsCompletedPropertyChanged();
}
}
public readonly Todo Todo;
public TodoItemViewModel(
Todo todo,
IRelayCommand<TodoItemViewModel> editCommand = null,
IRelayCommand<TodoItemViewModel> deleteCommand = null)
{
Todo = todo;
_editCommand = editCommand.CreateCommandWithoutParametersOrEmpty(this);
_deleteCommand = deleteCommand.CreateCommandWithoutParametersOrEmpty(this);
}
}
Паттерны:
- Кастомные свойства
TextиIsCompletedпроксируют значения изTodo— привязка напрямую к модели OnTextPropertyChanged()/OnIsCompletedPropertyChanged()— ручное уведомление об изменении[Access(Access.Public)]— публичный доступ кIsVisibleдля управления из родительского ViewModel[OneTimeBind] private readonly— команды задаются в конструкторе, не меняютсяCreateCommandWithoutParametersOrEmpty(this)— конвертируетIRelayCommand<T>вIRelayCommand, подставляяthis
TodoStorageViewModel — список задач
[ViewModel]
public sealed partial class TodoStorageViewModel : IDisposable
{
[TwoWayBind]
[TwoWayBind] private string _searchInput = string.Empty;
[OneTimeBind]
[OneTimeBind] private readonly IReadOnlyObservableListSync<TodoItemViewModel> _todoItemViewModels;
public TodoStorageViewModel(TodoStorage todoStorage, EditTextDialog editTodoDialog)
{
_todoStorage = todoStorage;
_editTextDialog = editTodoDialog;
_todoItemViewModels = todoStorage.Todos.CreateSync(CreateTodoViewModel);
}
private TodoItemViewModel CreateTodoViewModel(Todo todo)
{
var viewModel = new TodoItemViewModel(todo, OnTodoItemEditedCommand, OnTodoItemDeletedCommand);
SetTodoItemVisible(viewModel);
return viewModel;
}
[RelayCommand]
private void AddTodo()
{
_countAddedTodo++;
_todoStorage.Add($"New Todo {_countAddedTodo}");
}
partial void OnSearchInputChanged(string newValue)
{
foreach (var viewModel in TodoItemViewModels)
SetTodoItemVisible(viewModel);
}
private void SetTodoItemVisible(TodoItemViewModel viewModel) =>
viewModel.IsVisible = string.IsNullOrWhiteSpace(SearchInput)
|| viewModel.Todo.Text.Contains(SearchInput);
}
Ключевой паттерн: CreateSync
_todoItemViewModels = todoStorage.Todos.CreateSync(CreateTodoViewModel);
CreateSync автоматически синхронизирует ObservableList<Todo> с ObservableList<TodoItemViewModel>:
- При добавлении
Todo→ создаётсяTodoItemViewModelчерез фабрику - При удалении
Todo→ удаляется соответствующийTodoItemViewModel - Поддерживает порядок и все операции списка
EditTextDialogViewModel — диалог редактирования
[ViewModel]
public sealed partial class EditTextDialogViewModel
{
[TwoWayBind] private string _text;
[OneTimeBind] private readonly IRelayCommand _cancelCommand;
[OneTimeBind] private readonly IRelayCommand _renamedCommand;
public EditTextDialogViewModel(string text, Action<string> renamed, Action cancelled)
{
_text = text;
_cancelCommand = cancelled.CreateCommandOrEmpty();
_renamedCommand = new RelayCommand(
execute: () => renamed.Invoke(Text),
canExecute: () => Text != text);
}
partial void OnTextChanged(string newValue) =>
_renamedCommand?.NotifyCanExecuteChanged();
}
Кнопка «Rename» активна только если текст изменился.
Views
TodoItemView
[View]
public sealed partial class TodoItemView : MonoView
{
[SerializeField] private MonoBinder[] _text;
[SerializeField] private MonoBinder[] _isCompleted;
[SerializeField] private ButtonCommandBinder[] _editCommand;
[SerializeField] private ButtonCommandBinder[] _deleteCommand;
// Программный биндер — привязка видимости этого объекта
private GameObjectVisibleBinder IsVisible => new(gameObject);
}
Паттерн: программный биндер через свойство.
IsVisible не [SerializeField] — создаётся программно. Source Generator видит его благодаря [View] атрибуту и включает в привязку.
TodoStorageView
[View]
public sealed partial class TodoStorageView : MonoView
{
[SerializeField] private MonoBinder _searchInput;
[SerializeField] private ButtonCommandBinder[] _addTodoCommand;
[SerializeField] private ObservableListViewModelMonoBinder _todoItemViewModels;
}
ObservableListViewModelMonoBinder — коллекционный биндер, создающий/удаляющий View из префабов при изменении списка.
EditTextDialogView
[View]
public sealed partial class EditTextDialogView : MonoView
{
[SerializeField] private MonoBinder[] _text;
[BindId("CancelCommand")]
[SerializeField] private ButtonCommandBinder[] _cancelButton;
[BindId("RenamedCommand")]
[SerializeField] private ButtonCommandBinder[] _renamedButton;
}
[BindId("CancelCommand")] — имя поля View (_cancelButton) не совпадает с ViewModel (_cancelCommand). BindId переопределяет имя привязки.
Диалоги
public sealed class EditTextDialog
{
public void Open(string text, Action<string> renamed, Action cancel = null)
{
var view = Object.Instantiate(_prefab, _parent);
cancel += () => Dispose();
renamed += _ => Dispose();
view.Initialize(new EditTextDialogViewModel(text, renamed, cancel));
void Dispose() =>
view.DestroyViewAndGameObject()?.DisposeViewModel();
}
}
Паттерн: инстанцирование View, инициализация ViewModel с callback, уничтожение при закрытии.
Ключевые паттерны
| Паттерн | Описание |
|---|---|
CreateSync | Автосинхронизация Model → ViewModel коллекций |
| Программный биндер | private T Property => new(...) в View |
[BindId] | Переопределение имени привязки |
[Access(Access.Public)] | Публичный доступ к свойству для родительского ViewModel |
| Callback-диалоги | ViewModel с Action для результата |
См. также
- Коллекции — ObservableList, CreateSync
- Collection Binders — ViewModelObservableListBinder
- View Factories — PrefabViewFactory, PrefabViewPool
- Туториал VirtualizedList — виртуализация больших списков
Следующий шаг
Custom Binder → — биндер для компонента, о котором StarterKit не знает.