Уже примерно после 3-его самописного адаптера, в котором надо было реализовывать логику запоминания выбранного элемента, у меня появились мысли, что должно же быть какое-то решение, которое уже включит в себя всё необходимое. Особенно, если в процессе разработки приходилось менять возможность выбора только одного элемента на множественный выбор.
После изучения подхода MVVM и полноценного погружения в него, упомянутый выше вопрос встал намного более заметно. Тем более, что сам адаптер находится на уровне View
, в то время как информация о выбранных элементах зачастую крайне необходима для ViewModel
.
Возможно, я провёл за поиском ответов в интернете недостаточное количество времени, но, в любом случае, готового решения я не нашёл. Однако в одном из проектов мне пришла идея реализации, которая вполне могла бы быть универсальной, поэтому мне захотелось поделиться ею.
Замечание. Хоть для MVVM на Android было бы логично и уместно сделать реализацию с выставленным наружу LiveData
, на данном этапе я ещё не готов к написанию кода с его использованием. Так что это пока только на перспективу. Зато итоговое решение получилось без зависимостей от Android
, что потенциально даёт возможность использовать его на любой платформе, где может работать kotlin.
SelectionManager
Для решения поставленной задачи был составлен общий интерфейс SelectionManager
:
interface SelectionManager {
fun clearSelection()
fun selectPosition(position: Int)
fun isPositionSelected(position: Int): Boolean
fun registerSelectionChangeListener(listener: (position: Int, isSelected: Boolean) -> Unit): Disposable
fun getSelectedPositions(): ArrayList<Int>
fun isAnySelected(): Boolean
fun addSelectionInterceptor(interceptor: (position: Int, isSelected: Boolean, callback: () -> Unit) -> Unit): Disposable
}
По умолчанию уже имеется 3 различные реализации:
MultipleSelection
— объект позволяет выбирать сколько угодно элементов из списка;SingleSelection
— объект позволяет выбрать только один элемент;NoneSelection
— объект не позволяет выбирать элементы вовсе.
Наверно, с последним будет больше всего вопросов, так что попробую дальше уже показывать на примере.
Adapter
В адаптер предполагается добавить объект SelectionManager
в качестве зависимости через конструктор.
class TestAdapter(private val selectionManager: SelectionManager) : RecyclerView.Adapter<TestHolder>() {
//class body
}
В примере не буду заморачиваться с логикой обработки нажатия на элемент, так что просто условимся, что за назначение слушателя клика целиком отвечает холдер (без деталей).
class TestHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(item: Any, onItemClick: () -> Unit) {
//all bind logic
}
}
Далее, чтобы эта магия заработала, в адаптере необходимо совершить 3 следующих шага:
1. onBindViewHolder
В метод bind
холдера передать коллбэк, который вызовет selectionManager.selectPosition(position)
для отображаемого элемента. Так же здесь вам скорее всего понадобится менять отображение (чаще всего только фон) в зависимости от того, является ли элемент выбранным на данный момент — для этого можно вызывать selectionManager.isPositionSelected(position)
.
override fun onBindViewHolder(holder: TestHolder, position: Int) {
val isItemSelected = selectionManager.isPositionSelected(position)
//do whatever you need depending on `isItemSelected` value
val item = ... //get current item by `position` value
holder.bind(item) {
selectionManager.selectPosition(position)
}
}
2. registerSelectionChangeListener
Для того, чтобы адаптер своевременно обновлял нажатые элементы, необходимо подписаться на соответствующее действие. Причём не забудьте, что результат, возвращённый методом подписки, нужно обязательно сохранить.
private val selectionDisposable =
selectionManager.registerSelectionChangeListener { position, isSelected ->
notifyItemChanged(position)
}
Отмечу, что в данном случае значение параметра isSelected
не важно, так как при любом изменении внешний вид элемента меняется. Но вам ничто не мешает добавить дополнительную обработку, для которой это значение важно.
3. selectionDisposable
На предыдущем шаге я не просто так говорил, что результат выполнения метода необходимо сохранить — возвращается объект, который очищает подписку для избежания утечек. После окончания работы к этому объекту обязательно нужно обратиться.
fun destroy() {
selectionDisposable.dispose()
}
ViewModel
Для адаптера магии достаточно, перейдём к ViewModel
. Инициализация SelectionManager
крайне проста:
class TestViewModel: ViewModel() {
val selectionManager: SelectionManager = SingleSelection()
}
Здесь можно по аналогии с адаптер подписаться на изменения (например, чтобы делать кнопку "Удалить" недоступной, когда не выбран ни один элемент), но можно и по нажатию какой-либо итоговой кнопки (например, "Загрузить выбранное") получить список всех выбранных.
fun onDownloadClick() {
val selectedPositions: ArrayList<Int> = selectionManager.getSelectedPositions()
...
}
И вот тут выходит на передний план один из минусов моего решения: на текущем этапе объект способен хранить только позиции элементов. То есть, чтобы получить именно выбранные объекты, а не их позиции, потребуется дополнительная логика с использованием источника данных, подключенного к адаптеру (увы, пока только так). Но, надеюсь, вы справитесь с этим.
Далее останется только связать адаптер с вьюмоделью. Это уже на уровне активити.
class TestActivity: AppCompatActivity() {
private lateinit var adapter: TestAdapter
private lateinit var viewModel: TestViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//`TestViewModel` initialization
adapter = TestAdapter(viewModel.selectionManager)
}
}
Гибкость
Для кого-то это может быть и так достаточно понятным, но я хочу дополнительно отметить, что при такой реализации получается легко управлять способом выбора в адаптерах. Сейчас адаптер может выбирать только один элемент, но если в TestViewModel
изменить инициализацию свойства selectionManager
, весь остальной код заработает "по-новому", не потребовав при этом никаких изменений. То есть, поставьте val selectionManager: SelectionManager = MultipleSelection()
, и теперь адаптер позволяет выбирать сколько угодно элементов.
А если у вас есть какой-то базовый класс адаптера на всё приложение, вы можете не бояться таким же образом включить в него SelectionManager
. Ведь специально для адаптеров, которые вовсе не предполагают выбор элементов, есть реализация NoneSelection
— что с ним ни делай, он никогда не будет иметь выбранных элементов и никогда не вызовет ни один из листенеров. Нет, он не бросает исключений — он просто игнорирует все вызовы, но адаптеру этого знать совершенно не нужно.
Interceptor
Так же бывают случаи, при которых изменение выбора элемента сопровождается дополнительными операциями (например, подгрузкой детальной информации), до успешного завершения которых применение изменений ведёт к некорректному состоянию. Специально для этих случаев я добавил механизм перехвата.
Для добавления перехватчика нужно вызвать метод addSelectionInterceptor
(опять же надо сохранить результат и обратиться к нему после завершения работы). Один из параметров у перехватчика в примере callback: () -> Unit
— до тех пор, пока он не будет вызван, изменения применены не будут. То есть, в случае отсутствия сети подгрузка детальной информации с сервера не сможет завершиться успешно, соответственно состояние используемого selectionManager
не изменится. Если это именно то поведение, к которому вы стремитесь — этот метод вам нужен.
val interceptionDisposable =
selectionManager.addSelectionInterceptor { position: Int, isSelected: Boolean, callback: () -> Unit ->
if(isSelected) {
val selectedItem = ... //get current item by `position` value
val isDataLoadingSuccessful: Boolean = ...
//download data for `selectedItem`
if(isDataLoadingSuccessful) {
callback()
}
}
}
При необходимости можно подключить сколько угодно перехватчиков. В этом случае вызов callback()
первого перехватчика запустить обработку второго. И только callback()
у последнего из них наконец вызовет изменение состояния selectionManager
-а.
Перспективы
- Использование
Disposable
для очистки подписок хоть и эффективно, но не так удобно, какLiveData
. Первая на очереди доработка — воспользоваться возможностямиandroid.arch.lifecycle
для более удобной работы. Скорее всего, это будет отдельный проект, чтобы не добавлять в текущем зависимость от платформы. - Как я уже говорил, получение списка выбранных объектов вышло неудобным. Я хочу ещё попробовать реализовать объект, который сможет так же работать и с контейнером данных. Заодно он мог бы быть и источником данных для адаптера.
Ссылки
Исходные коды можете найти по ссылке — GitHub
Так же проект доступен для внедрения через gradle — ru.ircover.selectionmanager:core:1.0.0