Simple RecyclerView demo App - Stopwatch
Теги: Android, Kotlin, RecyclerView
Задание
Напишем простое приложение с RecyclerView. Элементом списка сделаем секундомер с возможностью запускать, останавливать и сбрасывать таймер.
Всегда начинаем с продумывания, что у нас должно получиться. Допустим, мы хотим что-то такое:
init-00
Первым делом создаём проект в Android Studio c одной empty Activity, и убираем всё лишнее. Скажем, в данном проекте мы не используем тесты или proguard - поэтому убираем зависимости, папки, файлы - все, что нам не понадобится. Версии зависимостей должны быть актуальными - обновляем на стабильные последние версии.
Мы будем использовать View Binding. Добавляем в проект, чуть переписываем MainActivity:
Проверяем, что всё компилиться: https://github.com/ziginsider/Simple-RecyclerView-Android-Demo-app-Stopwatch/tree/init-00
Мы готовы двигаться дальше.
layouts-01
Итак, у нас есть один экран, один RecyclerView, UI элемента RecyclerView известен. Начнем с разметки layout’ов приложения и будем от этого отталкиваться.
В activity_main.xml один RecyclerView и кнопка для создания таймера. Элемент RecyclerView таймер, который содержит:
- мигающий индикатор
- текстовое представление таймера
- кнопка “Start/Pause”
- кнопка “Restart”
- кнопка “Delete”
Для кнопок будем использовать подходящие иконки. Добавим в проект векторные файлы “ic_baseline_delete_24.xml”, “ic_baseline_pause_24.xml”, “ic_baseline_play_arrow_24.xml” и “ic_baseline_refresh_24.xml” - см. в репозитории: https://github.com/ziginsider/Simple-RecyclerView-Android-Demo-app-Stopwatch/tree/layouts-01/app/src/main/res/drawable
Для индикатора создадим animation-list:
Тут я думаю всё понятно, 700 ms показываем кружок, 700 ms не показываем - создается впечатление мигания. Ниже мы увидим, как запускать такую анимацию.
Таким образом layout для элемента списка:
activity_main.xml:
Обратите внимание, для RecyclerView в разметке мы испоьзовали такую хитрую штуку tools:listitem
- которая позволяет сразу увидеть, как будет выглядеть Recycler c айтемами.
Когда закончили с разметкой, можно двигаться дальше. Если что-то в дальнейшем в UI нам не понравится - всегда сможем поправить.
recycler-02
Далее набросаем логику RecyclerView. Пока просто: нажимаем на кнопку “Add Timer” - элемент добавляется в список.
Для этого сперва подумаем, что у нас будет элементом списка RecyclerView. Создадим модель айтема:
id
- чтобы отличать айтемы друг от другаcurrentMs
- количество миллисекунд прошедших со стартаisStarted
- работает ли секундомер или остановлен
Теперь в MainActivity
добавляем private val stopwatches = mutableListOf<Stopwatch>()
- в этом списке будут хранится стейты секундомеров
Создаем класс адаптера для RecyclerView. Будем использовать ListAdapter. Это адаптер для Recycler “на стероидах”, он является частью фреймворка RecyclerView, по-умолчанию использует DiffUtil с асинхронными потоками - короче штука удобная и в простых случаях его стоит использовать. Для RecyclerView нам понадобится ViewHolder, поэтому сперва создаем этот класс:
пройдемся по коду:
private val binding: StopwatchItemBinding
- передаем во ViewHolder сгенерированный класс байдинга для разметки элемента RecyclerView. В родительский ViewHolder передаемbindig.root
т.е. ссылку на View данного элемента RecyclerViewfun bind(stopwatch: Stopwatch) {
- в методbind
передаем экземпляр Stopwatch, он приходит к нам из методаonBindViewHolder
адаптера и содержит актуальные параметры для данного элемента списка.binding.stopwatchStopwatch.text = stopwatch.currentMs.displayTime()
- пока просто выводим время секундомера.displayTime()
- данный метод расширения для Long конвертирует текущее значение таймера в миллисекундах в формат “HH:MM:SS:MsMs” и возвращает соответствующую строку
Теперь можно приступить к созданию класса адаптера:
- В
onCreateViewHolder
инфлейтим View и возвращаем созданный ViewHolder holder.bind(getItem(position))
- тут понятно - для конкретного ViewHolder обновляем параметры.onBindViewHolder
вызывается в момент создания айтема, в моменты пересоздания (например, айтем вышел за пределы экрана, затем вернулся) и в моменты обновления айтемов (этим у нас занимается DiffUtil)- Имплементация DiffUtil помогает понять RecyclerView какой айтем изменился (был удален, добавлен) и контент какого айтема изменился - чтобы правильно проиграть анимацию и показать результат пользователю. В
areContentsTheSame
лучше проверять на равество только те параметры модели, которые влияют на её визуальное представление на экране.
B MainActivity пока просто генерим айтем, при нажатии на “Add stopwatch”, добавляем его в список и список сабмитим в RecyclerView. По сути это и есть основной алгоритм работы с Recycler:
- Работаем с айтемом
- Обновляем список
- Список сабмитим в адаптер RecyclerView
Проверям, что все компилется и работает как надо https://github.com/ziginsider/Simple-RecyclerView-Android-Demo-app-Stopwatch/tree/recycler-02
timer-03
Теперь займемся логикой. Сначала просто запустим наш секундомер, а на следующей стадии заставим кнопки выполнять свою функцию. Итак, мы создаем элемент списка RecyclerView и секундомер должен начать работать.
В Android можно по-разному решить эту задачу: например, использовать Handler(), чтобы создать свой таймер, или что-нибудь с потоками создать или с корутинами или стороннюю либу можем подключить… Но в учебных целях мы пойдем по простому пути - будем использовать класс Android CountDownTimer
Класс имеет интуитивно понятное API - мы задаем продолжительность работы millisInFuture
и величну интервала countDownInterval
- через данное время будет вызываться коллбэк onTick()
- пока это всё что нужно понимать.
Изменяем код ViewHolder’a:
Код простой. Обратите внимание, что в методе startTimer
обязательно нужно кэнсельнуть таймер перед созданием нового. Это необзодимо по той причине, что RecyclerView переиспользует ViewHolder’ы и один таймер может наложится на другой. Будут трабблы с шагом интервала.
В целях тестирования в MainActivity, в момент создания экземпляра Stopwatch выставляйте значение isStarted
как true
. Теперь можно стартануть проект и посмотреть, что получилось:
https://github.com/ziginsider/Simple-RecyclerView-Android-Demo-app-Stopwatch/tree/timer-03
Ура! Что-то работает:
Возможно, вы заметили некоторые проблемы с подобной организацией работы таймера прямо внутри ViewHolder. Если нет, то мы еще об этом скажем в конце статьи.
buttons-04
Займемся кнопками. Мы можем стратовать, останавливать, сбрасывать и удалять таймер. Создаём соответствующий интерфейс, имплементируем который в MainActivity (поскольку именно в этом классе у нас логика управления списком таймеров), и передадим эту имплементацию в качестве параметра в адаптер RecyclerView:
В MainActivity имплементируем:
Заметьте, что когда мы модифицируем айтем, мы пересоздаём список. Это не очень эффективно. Попробуйте переписать код так, чтобы искомый айтем менялся в списке stopwatches
и сабмитайте список в адаптер.
В ViewHolder:
Заметьте, что мы добавили старт (и остановку) анимации для индикатора. По остальному должно быть понятно - мы меняем состояние айтема через listener.
Такой подход, когда ViewHolder обрабатывает только визуальное представление айтема, который пришел ему в методе bind
, и ничего не меняет напрямую, а все колбэки обрабатываются снаружи (в нашем случае через listener) - является предпочтительным. Тут мы можем указать на проблему данного приложения. Если создать достаточное количество таймеров, и после скролла, запущенный таймер окажется за экраном, то таймер может остановится, и продолжит работу, только когда опять окажется видимым. Это происходит потому, что ViewHolder переиспользуется. Поэтому нужно быть аккуратным когда меняешь состоние айтема внутри ViewHolder’a - как в нашем случае с использованием CountDownTimer.
P.S. В адаптере, в имплементации DiffUtil мы добавили метод getChangePayload. В данном случае это утовка, чтобы айтем не бликовал (проигрывается анимация для всего айтема), когда мы нажимаем на кнопки. Нормальная реализация payload выходит за рамки этого простого примера.
override fun getChangePayload(oldItem: Stopwatch, newItem: Stopwatch) = Any()
Результат: https://github.com/ziginsider/Simple-RecyclerView-Android-Demo-app-Stopwatch/tree/buttons-04
</br>