В этом уроке разбираем экран TaskDetail (просмотр задачи) приложения TodoApp
Прочитайте введение, чтобы понимать, как строятся уроки.
Приложение
Рассматриваемое приложение - todoapp. Это приложение представляет из себя менеджер задач. Пользователь может создавать, редактировать, завершать и удалять задачи.
Ниже приведен список основных экранов. По ссылке вы можете перейти в урок, в котором подробно рассматривается реализация функций экрана.
Отображается список задач. При нажатии на задачу открывается экран просмотра задачи. При нажатии на FAB кнопку открывается экран создания задачи. Есть возможность использовать фильтр по статусу задачи.
Рассматриваемые функции экрана:
1) Получение данных и отображение их в списке
2) Фильтр по типу задач
3) Удаление завершенных задач
4) Переход на экран создания новой задачи
5) Переход на экран просмотра данных задачи
6) Завершение задачи
При нажатии на FAB кнопку открывается экран редактирования задачи.
Рассматриваемые функции экрана:
1) Получение данных и отображение их на экране
2) Завершение задачи
3) Удаление задачи
4) Переход на экран редактирования задачи
Редактирование/создание задачи
При нажатии на FAB кнопку происходит возврат к списку задач
Экран
Рассмотрим экран просмотра задачи. На этот экран мы можем попасть, нажав на задачу на экране со списком задач.
У задачи есть заголовок, описание и статус (активна/завершена).
Функции экрана, которые мы будем разбирать:
1) Получение данных и отображение их на экране
4) Переход на экран редактирования задачи
Из Architecture Components здесь используются: ViewModel, LiveData и Data Binding.
Также здесь активно используется SingleLiveEvent. Это LiveData, который не будет слать последнее значение новым слушателям при их подключении. В основном это полезно при поворотах экрана, чтобы не было повторных срабатываний при переподключении слушателей. Например, чтобы повторно не показывался Toast или SnackBar, когда View после пересоздания снова подключается к LiveData.
Основные компоненты
TaskDetailActivity, TaskDetailFragment - экран
TaskDetailViewModel - логика
TasksRepository - данные
Схема ссылок выглядит так:
Activity и фрагмент держат ссылку на ViewModel. А ViewModel держит ссылку на репозиторий. Эта схема хороша тем, что никто не держит ссылок на Activity или фрагмент. А значит, при повороте экрана нет риска возникновения утечек.
Рассмотрим, как и где создаются основные компоненты.
TaskDetailViewModel относится к TaskDetailActivity. При создании TaskDetailViewModel используется фабрика ViewModelFactory, чтобы передать TasksRepository и Application context.
Код в TaskDetailActivity.java:
@Override protected void onCreate(Bundle savedInstanceState) { ... mTaskViewModel = obtainViewModel(this); ... } @NonNull public static TaskDetailViewModel obtainViewModel(FragmentActivity activity) { // Use a Factory to inject dependencies into the ViewModel ViewModelFactory factory = ViewModelFactory.getInstance(activity.getApplication()); return ViewModelProviders.of(activity, factory).get(TaskDetailViewModel.class); }
Используется Application, а не Activity context, чтобы ViewModel не держала ссылку на Activity.
Фрагмент получает тот же экземпляр TaskDetailViewModel, что и Activity. Код в TaskDetailFragment.java:
@Nullable @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { ... mViewModel = TaskDetailActivity.obtainViewModel(getActivity()); ... }
Т.е. у Activity и фрагмента есть общий объект - TaskDetailViewModel. Это важно, потому что они будут использовать его для общения друг с другом вместо колбэков.
Взаимодействие
Data Binding и LiveData позволяют TaskDetailViewModel не хранить ссылку на TaskDetailFragment. У TaskDetailViewModel просто нет необходимости знать что-либо о TaskDetailFragment и просить его выполнить какое-либо действие, например, отобразить данные, SnackBar и т.п. Вместо этого биндинг подписывает экранные компоненты фрагмента на Observable поля в TaskDetailViewModel. И при изменении значений этих полей, мы автоматически видим результат на экране. Или фрагмент подписывается на LiveData, находящийся в TaskDetailViewModel, и через него получает какие-то данные или указания.
Давайте рассмотрим это взаимодействие более детально.
В layout файле фрагмента TaskDetailFragment (taskdetail_frag.xml) настроен Data Binding
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <data> ... <variable name="viewmodel" type="com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailViewModel" /> <variable name="listener" type="com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailUserActionsListener" /> </data> ... </layout>
Используются TaskDetailViewModel и TaskDetailUserActionsListener.
Отображение задачи
Как задача попадает из TaskDetailViewModel на экран?
В TaskDetailViewModel есть поле.
public final ObservableField<Task> task = new ObservableField<>();
В нем хранится задача, полученная из репозитория. При помещении в него данных, они автоматически будут отображены в списке на экране. Это реализовано биндингом. В layout файле в паре TextView используется viewmodel.task:
<TextView android:id="@+id/task_detail_title" android:text="@{viewmodel.task.title}" ... /> <TextView android:id="@+id/task_detail_description" android:text="@{viewmodel.task.description}" ... />
Для чекбокса, отображающего статус задачи, в TaskDetailViewModel используется отдельное поле:
public final ObservableBoolean completed = new ObservableBoolean();
Оно заполняется при получении таска из репозитория:
completed.set(task.isCompleted());
А в layout оно используется в CheckBox.checked:
<CheckBox android:id="@+id/task_detail_complete" android:checked="@{viewmodel.completed}" ... />
Зачем это реализовано отдельным полем, я не знаю. Может быть и есть какой-то скрытый смысл, но мне кажется, что вполне можно было в биндинге использовать task.completed:
<CheckBox android:id="@+id/task_detail_complete" android:checked="@{viewmodel.task.completed}" ... />
Нет данных
Если по какой-то причине из репозитория пришел null, то экран должен показать текст No data.
В TaskDetailViewModel есть метод:
public boolean isDataAvailable() { return task.get() != null; }
Он проверяет, что из репозитория были получены не null данные.
Этот метод используется в биндинге в visibility у LinearLayout, который в себе содержит TextView с текстом No data.
android:visibility="@{viewmodel.dataAvailable ? View.GONE : View.VISIBLE}"
И в RelativeLayout, который отображает данные задачи:
android:visibility="@{viewmodel.dataAvailable ? View.VISIBLE : View.GONE}"
Правда в текущей реализации есть баг. Этот биндинг нерабочий. Потому что при помещении данных в поле task метод viewmodel.isDataAvailable никак не сможет уведомить биндинг, что значение изменилось. Это просто метод, а не Observable поле.
Чтобы биндинг сработал, нам надо перезапускать его вручную:
viewDataBinding.invalidateAll();
что, конечно, является не самым удобным вариантом.
Лучше добавить переменную:
public final ObservableBoolean dataAvailable = new ObservableBoolean(false);
В нее при получении данных из репозитория писать значение:
dataAvailable.set(task.get() != null);
После этого в биндинге будет корректно работать конструкция viewmodel.dataAvailable.
А метод isDataAvailable надо убрать.
Отображение процесса загрузки данных
Как отображается прогрессбар при загрузке данных?
В TaskDetailViewModel есть поле:
private boolean mIsDataLoading;
И метод к нему
public boolean isDataLoading() { return mIsDataLoading; }
Этот boolean флаг ставится в true, когда стартует запрос данных из репозитория, и в false - когда репозиторий присылает ответ.
В layout он используется в ScrollChildSwipeRefreshLayout
<com.example.android.architecture.blueprints.todoapp.ScrollChildSwipeRefreshLayout android:id="@+id/refresh_layout" ... app:refreshing="@{viewmodel.dataLoading}">
Атрибута refreshing у ScrollChildSwipeRefreshLayout нет. Но и BindingAdapter здесь не используется. Тут сделано хитро. Когда мы передаем значение в какой-либо атрибут View, биндинг пытается в классе View найти метод set для этого атрибута. В данном случае, биндинг будет пытаться вызвать метод setRefreshing в классе ScrollChildSwipeRefreshLayout. Такой метод есть, он включает/выключает крутилку (прогрессбар). И мы передаем туда boolean из viewmodel.dataLoading.
И все бы хорошо, но биндинг опять не рабочий. Причина та же, что и в предыдущем случае - isDataLoading это просто метод, а не Observable поле. И при смене значения в переменной mIsDataLoading биндинг не будет срабатывать. Надо делать нормальный ObservableBoolean
public final ObservableBoolean dataLoading = new ObservableBoolean(false);
и его использовать в биндинге.
Для меня загадка, почему Google постит такой некачественный код. Такое ощущение, что стажеры делали.
Обновление списка при pullToRefresh
Как TaskDetailViewModel узнает о том, что на экране пользователь сделал pullToRefresh?
У ScrollChildSwipeRefreshLayout в атрибуте onRefreshListener указываем метод viewmodel::onRefresh.
<com.example.android.architecture.blueprints.todoapp.ScrollChildSwipeRefreshLayout android:id="@+id/refresh_layout" ... app:onRefreshListener="@{viewmodel::onRefresh}" ... >
Теперь при pullToRefresh действии будет вызван метод onRefresh в TaskDetailViewModel.
Отображение SnackBar
Как TaskDetailViewModel отображает SnackBar?
В TaskDetailViewModel есть поле
private final SnackbarMessage mSnackbarText = new SnackbarMessage();
SnackbarMessage - это расширенный SingleLiveEvent<Integer>. TaskDetailViewModel будет передавать ему ID строки. И этот ID уйдет к подписчикам.
В нашем случае подписчиком будет фрагмент. В onActivityCreated класса TaskDetailFragment.java выполняется подписка:
mViewModel.getSnackbarMessage().observe(this, new SnackbarMessage.SnackbarObserver() { @Override public void onNewMessage(@StringRes int snackbarMessageResourceId) { SnackbarUtils.showSnackbar(getView(), getString(snackbarMessageResourceId)); } });
Подписываемся и при получении ID строки отображаем SnackBar.
Функции экрана
1) Получение данных и отображение их на экране
При открытии экран отображает данные, полученные из репозитория.
Схема:
1. Начинается все в фрагменте, в методе onResume.
TaskDetailFragment.java:
@Override public void onResume() { super.onResume(); mViewModel.start(getArguments().getString(ARGUMENT_TASK_ID)); }
Запускается метод start в TaskDetailViewModel и в него передеается id задачи из аргументов.
Метод start делает два следующих шага:
2. Включает прогрессбар:
mIsDataLoading = true;
(при корректной реализации)
3. Запускает процесс получения данных из репозитория
mTasksRepository.getTask(taskId, this);
Ответ получим в колбэк.
Далее могут быть два варианта: приходят данные или ошибка. Соответственно, в моем тексте будет два продолжения цепочки шагов с пункта 4.
Сначала рассмотрим вариант с данными.
4. onTasksLoaded - из репозитория приходит задача
5. Вызывается метод setTask и в него передается полученный из репозитория task
public void setTask(Task task) { this.task.set(task); if (task != null) { completed.set(task.isCompleted()); } }
В this.task передаем задачу и обновляем флаг completed. Биндинг отобразит эти данные на экране.
6. Выключается прогрессбар:
mIsDataLoading = false;
(при корректной реализации)
Если же в репозитории случилась ошибка, схема будет почти та же:
4. onDataNotAvailable - репозиторий сообщает, что что-то пошло не так
5. Передаем null в ObservableField<Task>
task.set(null);
Биндинг должен показать на экране No data (при корректной реализации)
6. Выключается прогрессбар:
mIsDataLoading = false;
(при корректной реализации)
2) Завершение задачи
По нажатию на чекбокс, задача меняет статус на Завершена.
Схема:
В биндинге layout файла фрагмента есть переменная listener
<data> ... <variable name="listener" type="com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailUserActionsListener" /> </data>
1. Ее метод onCompleteChanged вызывается при нажатии на чекбокс
<CheckBox android:id="@+id/task_detail_complete" ... android:onClick="@{(view) -> listener.onCompleteChanged(view)}" ... />
В фрагменте реализация TaskDetailUserActionsListener выглядит так:
new TaskDetailUserActionsListener() { @Override public void onCompleteChanged(View v) { mViewModel.setCompleted(((CheckBox) v).isChecked()); } };
Вызывается метод setCompleted в TaskDetailViewModel и передается состояние чекбокса.
Не очень понятно, зачем вообще нужен этот listener. Можно было из биндинга напрямую TaskDetailViewModel вызывать.
Метод setCompleted в TaskDetailViewModel.java:
public void setCompleted(boolean completed) { if (mIsDataLoading) { return; } Task task = this.task.get(); if (completed) { mTasksRepository.completeTask(task); showSnackbarMessage(R.string.task_marked_complete); } else { mTasksRepository.activateTask(task); showSnackbarMessage(R.string.task_marked_active); } }
2. В зависимости от значения completed вызываются методы completeTask/activateTask репозитория.
3. И отображается SnackBar
3) Удаление задачи
По нажатию на DELETE TASK задача будет удалена
Схема
1. При нажатии на пункт меню в TaskDetailFragment вызывается метод:
mViewModel.deleteTask();
Метод deleteTask в TaskDetailViewModel.java:
public void deleteTask() { if (task.get() != null) { mTasksRepository.deleteTask(task.get().getId()); mDeleteTaskCommand.call(); } }
В этом методе:
2. Вызывается метод репозитория deleteTask для удаления задачи.
3. Сообщается в Activity, что задача была удалена.
Для этого используется SingleLiveEvent
private final SingleLiveEvent<Void> mDeleteTaskCommand = new SingleLiveEvent<>();
TaskDetailActivity при создании подписался на этот SingleLiveEvent:
viewModel.getDeleteTaskCommand().observe(this, new Observer<Void>() { @Override public void onChanged(@Nullable Void _) { TaskDetailActivity.this.onTaskDeleted(); } });
4. Метод onTaskDeleted в TaskDetailActivity.java:
@Override public void onTaskDeleted() { setResult(DELETE_RESULT_OK); // If the task was deleted successfully, go back to the list. finish(); }
Возвращает результат в предыдущее Activity (TasksActivity)
4) Переход на экран редактирования задачи
При нажатии на FAB кнопку открывается экран редактирования
Схема:
1. В TaskDetailFragment по нажатию на кнопку, вызывается метод:
mViewModel.editTask();
2. Метод editTask в TaskDetailViewModel.java:
public void editTask() { mEditTaskCommand.call(); }
mEditTaskCommand - это SingleLiveEvent
private final SingleLiveEvent<Void> mEditTaskCommand = new SingleLiveEvent<>();
На него подписалось TaskDetailActivity при создании:
viewModel.getEditTaskCommand().observe(this, new Observer<Void>() { @Override public void onChanged(@Nullable Void _) { TaskDetailActivity.this.onStartEditTask(); } });
При срабатывании вызывается метод onStartEditTask
3. Метод onStartEditTask вызывает AddEditTaskActivity, в котором реализовано редактирование задачи
@Override public void onStartEditTask() { String taskId = getIntent().getStringExtra(EXTRA_TASK_ID); Intent intent = new Intent(this, AddEditTaskActivity.class); intent.putExtra(AddEditTaskFragment.ARGUMENT_EDIT_TASK_ID, taskId); startActivityForResult(intent, REQUEST_EDIT_TASK); }
4. При получении ответа от AddEditTaskActivity, результат передается в предыдущее Activity (TasksActivity) и экран закрывается.
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_EDIT_TASK) { // If the task was edited successfully, go back to the list. if (resultCode == ADD_EDIT_RESULT_OK) { // If the result comes from the add/edit screen, it's an edit. setResult(EDIT_RESULT_OK); finish(); } } }
Присоединяйтесь к нам в Telegram:
- в канале StartAndroid публикуются ссылки на новые статьи с сайта startandroid.ru и интересные материалы с хабра, medium.com и т.п.
- в чатах решаем возникающие вопросы и проблемы по различным темам: Android, Compose, Kotlin, RxJava, Dagger, Тестирование, Performance
- ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня