В своей предыдущей статье я рассказал о первой попытке написать библиотеку для простого и удобного выбора элементов из списка в Android с учётом подхода MVVM. В прошлый раз решение не было привязано к платформе, поэтому конечной цели я не достиг.
Спустя несколько месяцев, когда я достаточно подумал, попрокрастинировал и поработал, у меня уже получилось решение, более подходящее именно для Android, так как основано на LiveData
. Прошу всех интересующихся ознакомиться.
Внесённые изменения
Прежде чем говорить о новой библиотеке, для начала по возможности кратко опишу изменения в том, о чём говорил в прошлой статье.
Наследование перехватчиков
Первое, что мне показалось странным в собственном решение, так это общий интерфейс, обязывающий реализовывать логику перехватчиков — метод addSelectionInterceptor
— таким образом я по сути не дал унаследовать свою стандартную реализацию этого метода для новых классов. Поэтому указанный метод я вынес в отдельный интерфейс — InterceptableSelectionManager
, который наследует общий интерфейс. В итоге пользователи могут выбрать вариант, в котором их не вынуждают работать с перехватчиками, так как этот функционал, как мне кажется, всё-таки не очень часто востребован.
Ну и говоря о наследовании написанной мной логики, сделан базовый абстрактный класс BaseInterceptableSelectionManager
, в котором только перехватчики и описаны. Нравится — используйте на здоровье. В любом случае, вы всегда можете использовать интерфейс и всё с нуля реализовать по-своему.
Более точное именование
Да, я изменил имя метода интерфейса в проекте, который объявил готовым и выложил в общий доступ. Да, я понимаю, что гореть мне в аду за такие выходки. В своё оправдание могу лишь сказать, что имя на самом деле неточно описывало происходящее в нём действие. Речь о методе, который в предыдущей версии назывался selectPosition
, а теперь он clickPosition
.
Дело в том, что прежний вариант говорил о том, что после выполнения переданная позиция должна оставаться выбранной в любом случае, в то время как внутренняя работа классов меняла состояние выделения для этого элемента. Я принял решение, что данная непрозрачность должна быть срочно устранена. Искренне надеюсь, что эта попытка исправить собственную ошибку найдёт понимание среди пользователей (если вдруг были те, кто пробовал предыдущую версию).
Ну и здесь же скажу, что я так же добавил в интерфейс метод deselectPosition
, который снимает выделение с элемента, если оно было. Тут уже с именем и поведением полное совпадение.
Новые возможности
Теперь о новье. Начну с того, что добавлено в предыдущую библиотеку, а в конце уже расскажу о новой.
Источник данных
Как я указывал в предыдущей статье, одним из недостатков моего решения было то, что оно оперирует исключительно положением выбранных элементов, из-за чего для получения непосредственно выбранных элементов пользователю пришлось бы самостоятельно писать логику их получения. Я нашёл решение, как исправить этот недочёт — создать класс SelectableDataSource
.
class SelectableDataSource<T>(private var dataSource: ArrayList<T>,
private val selectionManager: SelectionManager)
: SelectionManager by selectionManager
Что хотел бы здесь отметить:
Класс принимает в конструктор
SelectionManager
, который будет отвечать за логику выбора элементов. То есть, для изменения с единичного выбора на множественный нужно всего лишь в конструктор передать экземпляр другого класса. Всё остальное будет работать без изменений.
Класс сам по себе реализует интерфейс
SelectionManager
, что позволяет использовать его в тех же случаях, когда использовались более простые экземпляры без источника данных.
В качестве источника данных используется
ArrayList
, передаваемый первым параметров в конструктор. Так как класс позволяет изменять источник (о чём будет ниже), есть дополнительный конструктор, в который элементы уже передавать не нужно — это будет интерпретировано как пустойArrayList
.
constructor(selectionManager: SelectionManager) : this(arrayListOf(), selectionManager)
Для изменения источника данных есть специальный метод
setDataSource
.
fun setDataSource(dataSource: ArrayList<T>, changeMode: ChangeDataSourceMode)
Про замену самого списка элементов говорить нечего, а вот об обработке уже выделенных элементов стоит рассказать. Есть несколько возможных вариантов режима работы:
ChangeDataSourceMode.ClearAllSelection
— самый простой, при котором просто всё выделение сбрасывается полностью. Этот режим выбран по умолчанию, так что если он вам подходит, можете второй параметр в метод вовсе не передавать;ChangeDataSourceMode.HoldSelectedPositions
— выделение будет оставлено согласно позициям в массиве до изменений. То есть, если элемент в позиции 2 был выделен до вызова метода, то после него элемент в той же позиции 2 останется выделенным. Разумеется, это справедливо только для случаев, когда после изменений есть хотя бы 3 элемента;ChangeDataSourceMode.HoldSelectedItems
— будет оставлено выделение именно на выбранных элементах (если они остались). Как мне видится, наиболее часто необходимый режим. В случае, когда работа ведётся с элементами вашего кастомного класса, не забудьте переопределить методEquals
.
Так как в описываемом классе наконец появилась привязка к источнику данных, в нём я добавил логику с
ArrayIndexOutOfBoundsException
. Поэтому вызовclickPosition
с неверной позицией сгенерирует исключение, а в случае изменения количества элементов после вызоваsetDataSource
слушатели будут оповещены только о тех позициях, которые остались в новом источнике. И тут важно отметить, чтобы вы корректно назначали своих слушателей, ибо при попытке отслеживанияSelectionManager
'а, который был передан в конструктор, отсеивания более недоступных позиций не произойдёт — он же ничего об источнике данных не знает.
val selectionManager: SelectionManager val dataSource = SelectableDataSource<User>(selectionManager) selectionManager.registerSelectionChangeListener { position: Int, isSelected: Boolean -> ...} //опасно dataSource.registerSelectionChangeListener { position: Int, isSelected: Boolean -> ...} //безопасно
Внимательные читали могли заметить, что в самом начале статьи я говорил о разделении интерфейсов
SelectionManager
'а, а тут воспользовался только одним из них. Так вот, для перехватываемого варианта есть отдельный классInterceptableSelectableDataSource
. Из отличий только, собственно, реализация интерфейса с перехватчиками, а всё остальное без изменений.
class InterceptableSelectableDataSource<T>(dataSource: ArrayList<T>, private val selectionManager: InterceptableSelectionManager) : SelectableDataSource<T>(dataSource, selectionManager), InterceptableSelectionManager
LiveDataSource
И вот она — кульминация статьи. Наконец-то я созрел для того, чтобы уложить всё необходимое в LiveData
. При инициализации нужно просто передать InterceptableSelectionManager
, который вам необходим.
val users = LiveDataSource<User>(MultipleSelection())
Для изменения источника данных можно использовать метод setDataSource
, в точности повторяющий сигнатуру описанного выше класса SelectableDataSource
.
val newValues: ArrayList<User>
users.setDataSource(newValues)
//или
users.setDataSource(newValues, ChangeDataSourceMode.HoldSelectedItems)
Теперь об отслеживании изменений. Для наблюдения за изменениями источника данных есть свойство allItems
, которое тоже является LiveData
.
viewModel.users.allItems.observe(this, Observer { items: ArrayList<User> -> ... })
//this - ссылка на текущую Activity или любой другой LifecycleOwner
Для отслеживания изменений списка выбранных элементов можно совершить аналогичное действие со свойством selectedItems
.
viewModel.users.selectedItems.observe(this, Observer { selectedItems: ArrayList<User> -> ... })
В том случае, если нужно следить не за изменением полного списка выбранный элементов, а за конкретными изменениями выделения, есть методы observeSelectionChange
и observeItemSelectionChange
. Их различие в том, что первый оперирует положением элементов, а второй — непосредственно самими элементами.
viewModel.users.observeSelectionChange(this) { position: Int, isSelected: Boolean -> ... }
viewModel.users.observeItemSelectionChange(this) { user: User, isSelected: Boolean -> ... }
Перспективы
Да, я всё ещё не закончил и опять вижу, что можно было бы доработать.
- Пожалуй, логичным продолжением вижу создание базового класса для адаптера на основе
RecyclerView.Adapter
, который помимо простого интерфейса подписки на моюLiveData
будет включать в себя наиболее часто используемую логику работы с линейным списком элементов. Пока нет полной уверенности, что получится что-то универсальное и удобное в использовании, но надежды я не теряю. - На данный момент я даже не анализировал своё решение на потокобезопасность. Подозреваю, что стоило бы.
- Сейчас класс
LiveDataSource
не имеет возможности простой замены вложенного в негоSelectionManager
'а, что иногда могло бы быть полезным. Ведь есть распространённый подход, когда в обычном режиме выбирается только один элемент, а по долгому тапу переключается на множественный выбор. Подумаю, можно ли что-то с этим сделать.
Сылки
Исходные коды для библиотек:
Ссылки в Gradle:
implementation 'ru.ircover.selectionmanager:core:1.1.0'
implementation 'ru.ircover.selectionmanager:livesource:1.0.0'