В этом уроке разбираем экран Tasks (список задач) приложения TodoApp
Прочитайте введение, чтобы понимать, как строятся уроки.
Приложение
Рассматриваемое приложение - todoapp. Это приложение представляет из себя менеджер задач. Пользователь может создавать, редактировать, завершать и удалять задачи.
Ниже приведен список основных экранов. По ссылке вы можете перейти в урок, в котором подробно рассматривается реализация функций экрана.
Отображается список задач. При нажатии на задачу открывается экран просмотра задачи. При нажатии на FAB кнопку открывается экран создания задачи. Есть возможность использовать фильтр по статусу задачи.
Рассматриваемые функции экрана:
1) Получение данных и отображение их в списке
2) Фильтр по типу задач
3) Удаление завершенных задач
4) Переход на экран создания новой задачи
5) Переход на экран просмотра данных задачи
6) Завершение задачи
При нажатии на FAB кнопку открывается экран редактирования задачи.
Рассматриваемые функции экрана:
1) Получение данных и отображение их на экране
2) Завершение задачи
3) Удаление задачи
4) Переход на экран редактирования задачи
Редактирование/создание задачи
При нажатии на FAB кнопку происходит возврат к списку задач
Экран
Рассмотрим экран со списком задач.
Функции экрана, которые мы будем разбирать:
1) Получение данных и отображение их в списке
4) Переход на экран создания новой задачи
5) Переход на экран просмотра данных задачи
Из Architecture Components здесь используются: ViewModel, LiveData и Data Binding.
Также здесь активно используется SingleLiveEvent. Это LiveData, который не будет слать последнее значение новым слушателям при их подключении. В основном это полезно при поворотах экрана, чтобы не было повторных срабатываний при переподключении слушателей. Например, чтобы повторно не показывался Toast или SnackBar, когда View после пересоздания снова подключается к LiveData.
Основные компоненты:
TasksActivity, TasksFragment - экран
TasksViewModel - логика
TasksRepository - данные
Схема ссылок выглядит так:
Activity и фрагмент держат ссылку на ViewModel. А ViewModel держит ссылку на репозиторий. Эта схема хороша тем, что никто не держит ссылок на Activity или фрагмент. А значит, при повороте экрана нет риска возникновения утечек.
Рассмотрим, как и где создаются основные компоненты.
TasksViewModel относится к TasksActivity. При создании TasksViewModel используется фабрика ViewModelFactory, чтобы передать TasksRepository и Application context.
Код в TasksActivity.java:
@Override protected void onCreate(Bundle savedInstanceState) { ... mViewModel = obtainViewModel(this); ... } public static TasksViewModel obtainViewModel(FragmentActivity activity) { // Use a Factory to inject dependencies into the ViewModel ViewModelFactory factory = ViewModelFactory.getInstance(activity.getApplication()); TasksViewModel viewModel = ViewModelProviders.of(activity, factory).get(TasksViewModel.class); return viewModel; }
Используется Application, а не Activity context, чтобы ViewModel не держала ссылку на Activity.
Фрагмент получает тот же экземпляр TasksViewModel, что и Activity. Код в TasksFragment.java:
@Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { mTasksViewModel = TasksActivity.obtainViewModel(getActivity()); ... }
Т.е. у Activity и фрагмента есть общий объект TasksViewModel. Это важно, потому что они будут использовать его для общения друг с другом вместо колбэков.
Взаимодействие
Рассмотрим, какие способы взаимодействия друг с другом есть у основных компонентов.
Data Binding и LiveData позволяют TasksViewModel не хранить ссылку на TasksFragment (и TasksActivity). У TasksViewModel просто нет необходимости знать что-либо о TasksFragment и просить его выполнить какое-либо действие, например, отобразить данные, прогрессбар, SnackBar и т.п. Вместо этого биндинг подписывает экранные компоненты фрагмента на Observable поля в TasksViewModel. И при изменении значений этих полей, мы автоматически видим результат на экране. Или фрагмент подписывается на LiveData, находящийся в TasksViewModel, и через него получает данные или указания.
Давайте рассмотрим это все более детально.
В layout файле фрагмента TasksFragment (tasks_frag.xml) настроен Data Binding, в котором используется TasksViewModel
<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.tasks.TasksViewModel" /> </data> ... </layout>
Отображение списка задач
Как список с данными попадает из TasksViewModel на экран?
В TasksViewModel есть поле:
public final ObservableList<Task> items = new ObservableArrayList<>();
В него складываются задачи, полученные из репозитория.
При помещении в него данных, они автоматически будут отображены в списке на экране. Это реализовано биндингом. В layout файле в ListView используется viewmodel.items:
<ListView android:id="@+id/tasks_list" app:items="@{viewmodel.items}" ... />
У ListView, конечно, нет атрибута items. Он создан искусственно с помощью BindingAdapter.
Класс TasksListBindings.java:
@BindingAdapter("app:items") public static void setItems(ListView listView, List<Task> items) { TasksAdapter adapter = (TasksAdapter) listView.getAdapter(); if (adapter != null) { adapter.replaceData(items); } }
Из ListView достается адаптер и ему передаются данные items.
Нет данных
Как TasksViewModel показывает другое содержимое экрана в случае, если данных нет?
В TasksViewModel есть поле:
public final ObservableBoolean empty = new ObservableBoolean(false);
Этот boolean флаг определяет, что будет отображаться на экране: список с данными или тексты/иконки о том, что данных нет.
Реализовано это биндингом для атрибута visibility в нескольких View
android:visibility="@{viewmodel.empty ? View.VISIBLE : View.GONE}"
Отображение процесса загрузки данных
Как отображается прогрессбар при загрузке данных?
В TasksViewModel есть поле:
public final ObservableBoolean dataLoading = new ObservableBoolean(false);
Этот boolean флаг, отвечает за отображение прогрессбара во время загрузки данных.
Реализовано это биндингом. В 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.
Отображение ошибки при загрузке данных
Как TasksViewModel показывает другое содержимое экрана в случае, если была ошибка при загрузке данных?
В TasksViewModel есть поле:
private final ObservableBoolean mIsDataLoadingError = new ObservableBoolean(false);
Но я не нашел, как оно используется. Похоже, это просто забыли реализовать, потому что в layout файле тоже нет никаких View, которые могли бы быть показаны в случае ошибок. Но в целом логика должна быть примерно та же, что и в случае отсутствия данных.
Обновление списка при pullToRefresh
Как TasksViewModel узнает о том, что был pullToRefresh?
В layout у ScrollChildSwipeRefreshLayout в параметр onRefresh передается viewmodel:
<com.example.android.architecture.blueprints.todoapp.ScrollChildSwipeRefreshLayout android:id="@+id/refresh_layout" ... android:onRefresh="@{viewmodel}">
И тут снова используется BindingAdapter.
Класс SwipeRefreshLayoutDataBinding.java:
@BindingAdapter("android:onRefresh") public static void setSwipeRefreshLayoutOnRefreshListener(ScrollChildSwipeRefreshLayout view, final TasksViewModel viewModel) { view.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { @Override public void onRefresh() { viewModel.loadTasks(true); } }); }
Для ScrollChildSwipeRefreshLayout вешается обработчик OnRefreshListener, в котором просим viewModel снова загрузить данные.
Отображение SnackBar
Как TasksViewModel отображает SnackBar?
В TasksViewModel есть поле:
private final SnackbarMessage mSnackbarText = new SnackbarMessage();
SnackbarMessage - это расширенный SingleLiveEvent<Integer>. TasksViewModel будет передавать ему ID строки. И этот ID уйдет к подписчикам.
В нашем случае подписчиком будет фрагмент. В onActivityCreated класса TasksFragment.java выполняется подписка:
mTasksViewModel.getSnackbarMessage().observe(this, new SnackbarMessage.SnackbarObserver() { @Override public void onNewMessage(@StringRes int snackbarMessageResourceId) { SnackbarUtils.showSnackbar(getView(), getString(snackbarMessageResourceId)); } });
Подписываемся и при получении ID строки отображаем SnackBar.
onActivityResult
Как TasksViewModel обрабатывает результаты вызовов startActivityForResult?
Из Activity будут идти вызовы startActivityForResult. Обработка результатов этих вызовов будет делегирована TasksViewModel.
Код в TasksActivity.java:
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) { mViewModel.handleActivityResult(requestCode, resultCode); }
Адаптер списка
Как TasksViewModel получает нажатия на элементы списка?
Давайте отдельным пунктом рассмотрим адаптер, т.к. в нем реализованы обработчики нажатий на элементы списка.
TasksViewModel передается в конструктор адаптера TasksAdapter.java:
public TasksAdapter(List<Task> tasks, TasksViewModel tasksViewModel) { mTasksViewModel = tasksViewModel; setList(tasks); }
Далее она будет использована при обработке нажатий.
В методе getView в TasksAdapter.java настраивается биндинг:
@Override public View getView(int position, final View view, final ViewGroup viewGroup) { TaskItemBinding binding; if (view == null) { // Inflate LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext()); // Create the binding binding = DataBindingUtil.inflate(inflater, R.layout.task_item, viewGroup, false); //binding = TaskItemBinding.inflate(inflater, viewGroup, false); } else { // Recycling view binding = DataBindingUtil.getBinding(view); } TaskItemUserActionsListener userActionsListener = new TaskItemUserActionsListener() { @Override public void onCompleteChanged(Task task, View v) { boolean checked = ((CheckBox)v).isChecked(); mTasksViewModel.completeTask(task, checked); } @Override public void onTaskClicked(Task task) { mTasksViewModel.getOpenTaskEvent().setValue(task.getId()); } }; binding.setTask(mTasks.get(position)); binding.setListener(userActionsListener); binding.executePendingBindings(); return binding.getRoot(); }
Сначала методом DataBindingUtil.inflate создается пара view + биндинг в случае, когда view == null. Если же view пришло не null, то получаем из него биндинг.
Далее создается слушатель, методы которого будут использоваться биндингом в layout. Чуть дальше увидим, где именно.
Слушатель и задача передаются в биндинг. И процесс биндинга запускается безотлагательно.
Метод binding.getRoot() вернет корневое View биндинга.
В layout файле task_item.xml прописаны вызовы обоих методов слушателя.
На onClick корневого LinearLayout висит метод onTaskClicked
android:onClick="@{() -> listener.onTaskClicked(task)}"
А на onClick чекбокса висит onCompleteChanged
android:onClick="@{(view) -> listener.onCompleteChanged(task, view)}"
Соответственно, при нажатии на задачу в списке и при нажатии на чекбокс будут вызваны методы слушателя, которые в свою очередь вызовут методы TasksViewModel.
При разборе функций экрана мы увидим, что именно произойдет дальше. Сейчас пока цель была - рассмотреть, как нажатие на элементы списка приводит к вызову методов TasksViewModel.
Функции
1) Получение данных и отображение их в списке
Схема выглядит так:
1. Начинается все в фрагменте, в методе onResume.
@Override public void onResume() { super.onResume(); mTasksViewModel.start(); }
Метод start приводит к вызову метода loadTasks в TasksViewModel.java. В этом методе происходит следующее:
2. Включается отображение прогрессбара
dataLoading.set(true);
3. Запускается процесс получения данных из репозитория
mTasksRepository.getTasks(new TasksDataSource.LoadTasksCallback() {...})
Ответ получим в колбэк.
Далее могут быть два варианта: приходят данные или ошибка. Соответственно, в моем тексте будет два продолжения цепочки шагов с пункта 4.
Сначала рассмотрим вариант с данными.
4. onTasksLoaded - из репозитория приходят данные
В зависимости от установленного на экране фильтра, из полученных из репозитория задач формируется список tasksToShow.
for (Task task : tasks) { switch (mCurrentFiltering) { case ALL_TASKS: tasksToShow.add(task); break; case ACTIVE_TASKS: if (task.isActive()) { tasksToShow.add(task); } break; case COMPLETED_TASKS: if (task.isCompleted()) { tasksToShow.add(task); } break; default: tasksToShow.add(task); break; } }
5. Выключается прогрессбар.
dataLoading.set(false);
6. Выключается отображение ошибки в UI (она могла быть включена во время предыдущей попытки получения данных)
mIsDataLoadingError.set(false);
7. Задачи из отфильтрованного списка tasksToShow передаются в items
items.clear(); items.addAll(tasksToShow);
Биндинг отобразит их на экране.
8. Устанавливается флаг empty в зависимости от того, есть ли данные в items.
empty.set(items.isEmpty());
Биндинг в зависимости от значения empty отобразит либо список, либо тексты/картинки показывающие пользователю, что данных нет.
Теперь рассмотрим вариант, когда репозиторий вернул ошибку.
Схема почти та же:
4. onDataNotAvailable - репозиторий сообщает, что что-то пошло не так
5. Включается отображение ошибки UI.
mIsDataLoadingError.set(true);
Как я уже написал при разборе биндинга, этот флаг почему-то никак не используется. Но он мог бы отображать какой-то текст на экране.
2) Фильтр по типу задач
Мы можем выбрать, какой тип задач будет отображаться в списке: все, незавершенные или завершенные.
В фрагменте по нажатию на меню фильтра вызывается popup menu. И для него вешается обработчик.
Метод showFilteringPopUpMenu в TasksFragment.java:
popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.active: mTasksViewModel.setFiltering(TasksFilterType.ACTIVE_TASKS); break; case R.id.completed: mTasksViewModel.setFiltering(TasksFilterType.COMPLETED_TASKS); break; default: mTasksViewModel.setFiltering(TasksFilterType.ALL_TASKS); break; } mTasksViewModel.loadTasks(false);
При нажатии на пункт меню происходит следующее:
1. В зависимости от нажатого пункта popup меню, выбранный тип фильтра передается в TasksViewModel в метод setFiltering.
Метод setFiltering в TasksViewModel.java выглядит так:
public void setFiltering(TasksFilterType requestType) { mCurrentFiltering = requestType; // Depending on the filter type, set the filtering label, icon drawables, etc. switch (requestType) { case ALL_TASKS: ... break; case ACTIVE_TASKS: ... break; case COMPLETED_TASKS: ... break; } }
Фильтр сохраняется в поле mCurrentFiltering. А под троеточиями скрыт код настройки текстов/картинок в зависимости от фильтра.
2. Вызывается метод loadTasks, который мы рассмотрели в предыдущей функции. Он сработает по той же схеме. Просто перед запуском loadTasks будет установлен фильтр mCurrentFiltering. И когда данные придут из репозитория, TasksViewModel просеет их с учетом mCurrentFiltering, и на экран попадут только нужные задачи.
3) Удаление завершенных задач
Мы можем удалять завершенные задачи из списка.
При нажатии на этот пункт меню в TasksFragment.java вызывается метод:
mTasksViewModel.clearCompletedTasks();
Содержимое метода clearCompletedTasks в TasksViewModel.java:
public void clearCompletedTasks() { mTasksRepository.clearCompletedTasks(); mSnackbarText.setValue(R.string.completed_tasks_cleared); loadTasks(false, false); }
1.Вызов метода репозитория для удаления выполненных задач
2. Отображение SnackBar
3. Загрузка свежих данных.
4) Переход на экран создания новой задачи
Схема вызова другого Activity и получения от него результата:
1. В фрагменте по нажатию на кнопку добавления вызывается метод:
mTasksViewModel.addNewTask();
2. Содержимое метода addNewTask в TasksViewModel.java:
public void addNewTask() { mNewTaskEvent.call(); }
mNewTaskEvent - это SingleLiveEvent
private final SingleLiveEvent<Void> mNewTaskEvent = new SingleLiveEvent<>();
TasksActivity подписалось на этот SingleLiveEvent еще при создании, в своем onCreate методе:
@Override protected void onCreate(Bundle savedInstanceState) { ... mViewModel = obtainViewModel(this); // Subscribe to "new task" event mViewModel.getNewTaskEvent().observe(this, new Observer<Void>() { @Override public void onChanged(@Nullable Void _) { addNewTask(); } }); }
В основе SingleLiveEvent лежит LiveData. Мы используем Activity (this), как Lifecycle при подписке, следовательно, подписка будет учитывать состояние Activity. И когда Activity будет уничтожено, например, при повороте экрана, Observer будет отписан автоматически и никаких утечек памяти не будет.
3. Метод addNewTask в TasksActivity.java:
@Override public void addNewTask() { Intent intent = new Intent(this, AddEditTaskActivity.class); startActivityForResult(intent, AddEditTaskActivity.REQUEST_CODE); }
Идет вызов Activity для создания новой задачи и ожидание ответа (т.к. startActivityForResult, а не startActivity).
4. При сохранении, в AddEditTaskActivity будет вызван код:
@Override public void onTaskSaved() { setResult(ADD_EDIT_RESULT_OK); finish(); }
5. Напомню, что в TasksActivity в методе onActivityResult идет вызов TasksViewModel.handleActivityResult.
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) { mViewModel.handleActivityResult(requestCode, resultCode); }
6. Когда экран создания новой задачи вернет положительный результат, будет показан SnackBar.
mSnackbarText.setValue(R.string.successfully_added_task_message);
А данные в списке обновятся, т.к. в onResume фрагмента идет вызов метода mTasksViewModel.start().
5) Переход на экран просмотра данных задачи
Схема очень похожа на предыдущую и шаги примерно те же.
1-2. При нажатии на задачу в списке, адаптер выполнит код:
mTasksViewModel.getOpenTaskEvent().setValue(task.getId());
Метод getOpenTaskEvent в TasksViewModel.java возвращает mOpenTaskEvent
private final SingleLiveEvent<String> mOpenTaskEvent = new SingleLiveEvent<>();
TasksActivity подписалось на этот SingleLiveEvent еще при создании, в своем onCreate методе:
@Override protected void onCreate(Bundle savedInstanceState) { ... mViewModel = obtainViewModel(this); // Subscribe to "open task" event mViewModel.getOpenTaskEvent().observe(this, new Observer<String>() { @Override public void onChanged(@Nullable String taskId) { if (taskId != null) { openTaskDetails(taskId); } } }); ... }
3. Метод openTaskDetails в TasksActivity.java:
@Override public void openTaskDetails(String taskId) { Intent intent = new Intent(this, TaskDetailActivity.class); intent.putExtra(TaskDetailActivity.EXTRA_TASK_ID, taskId); startActivityForResult(intent, AddEditTaskActivity.REQUEST_CODE); }
Идет вызов TaskDetailActivity для просмотра данных задачи. И ему передается ID задачи.
4-6. Результат будет обработан в TasksViewModel. Если он положительный, то будет показан SnackBar
mSnackbarText.setValue(R.string.successfully_saved_task_message);
6) Завершение задачи
По нажатию на чекбокс, задача должна сменить статус на Завершена.
1. При нажатии на чекбокс задачи в списке, адаптер выполнит код:
mTasksViewModel.completeTask(task, checked);
Содержимое метода completeTask в TasksViewModel.java:
public void completeTask(Task task, boolean completed) { // Notify repository if (completed) { mTasksRepository.completeTask(task); showSnackbarMessage(R.string.task_marked_complete); } else { mTasksRepository.activateTask(task); showSnackbarMessage(R.string.task_marked_active); } }
2. Идет вызов соответствующего метода репозитория
3. Показ SnackBar.
В реализации этой функции есть ошибка - не обновляются данные в адаптере.
Т.е. мы ставим чекбокс, но при этом не меняем статус задачи в адаптере. И после скролла задача не отобразит свой актуальный статус, потому что она просто не знает его. Также по этой причине не меняется фон задачи. Он сразу должен быть серым, если задача завершена.
Тут надо либо вручную менять статус задачи в адаптере и вызвать notifyItemChanged, либо запрос новых данных делать.
Возможно, это еще пофиксят.
Присоединяйтесь к нам в Telegram:
- в канале StartAndroid публикуются ссылки на новые статьи с сайта startandroid.ru и интересные материалы с хабра, medium.com и т.п.
- в чатах решаем возникающие вопросы и проблемы по различным темам: Android, Compose, Kotlin, RxJava, Dagger, Тестирование, Performance
- ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня