Android: form builder на старых добрых View
Привет всем! Хочу поделится идеей создания form builder-а, которую я реализовал некоторое время назад.
В приложении я писал модуль, отвечающий за платежи. По предварительным расчетам модуль должен был поддерживать более 300 платежей, каждый платеж приблизительно 10 экранов, т.е. это более 3000 различных экранов. Я тогда не использовал jetpack compose и от мысли, что мне придется написать огромное количество «View-based layouts» xml файлов (а потом их рефакторить и поддерживать) мне становилось как-то не по себе.
Мне предложили сделать form builder, который позволял бы легко и в декларативной манере добавлять новые экраны, не плодить огромное количество однотипных файлов и легко вносить изменения. Конечно jetpack compose позволяет достичь всего этого из коробки, но бывает, что по тем или иным причинам вы остаетесь на старом добром View UI и идея какого-либо builder-а может быть для вас актуальна.
Итак первое, что мне было нужно — это не плодить xml файлы тысячами. В идеале, хорошо бы иметь один общий файл формы и наполнять его различным содержимым. В моем случае формы были достаточно похожи друг на друга: набор ограниченного числа UI элементов и внизу формы кнопка типа «submit form» (иногда с какими-то пояснениями / ссылками под ней). Решил использовать RecyclerView, в который можно было динамически вставлять нужное количество элементов. Как-то так выглядел xml файл формы:
…
RecyclerView понадобилось два, один для основных элементов формы и другой для кнопки / кнопок внизу экрана и связанных с ней UI элементов. С помощью data binding-а форма связывалась с ViewModel и получала все необходимые элементы и все их изменения (для этого эл-ты были обернуты в LiveData).
Далее BaseViewModel:
internal abstract class BaseViewModel(appContextProvider: AppContextProvider) : ViewModel() {
val rvMainAdapter = MutableLiveData>(ViewsAdapter())
val rvBottomAdapter = MutableLiveData>(ViewsAdapter())
val mainItems = MutableLiveData>()
val bottomItems = MutableLiveData>()
…
}
@BindingAdapter("adapter")
internal fun setRecyclerViewAdapter(recyclerView: RecyclerView, adapter: ListAdapter<*, *>) {
recyclerView.adapter = adapter
}
@BindingAdapter("listData")
internal fun bindRecyclerView(recyclerView: RecyclerView, data: List?) {
(recyclerView.adapter as ListAdapter).submitList(data)
}
В нем находятся адаптеры и списки UI элементов для RecyclerView, которые «байндяться» к форме.
Теперь о самих UI элементах. Для каждого элемента создается свой xml файл. Приведу пример наиболее сложного элемента InputView:
<...InputView
android:id="@+id/item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:design_label="@{fv.title}"
app:design_hint="@{fv.hint}"
app:design_assistiveText="@{fv.assistive}"
app:design_text="@={fv.text}"
app:error_text="@{fv.error}"
app:design_icon="@{fv.endDrawableRes}"
app:design_iconColor="@{fv.endDrawableColorRes}"
app:inputType="@{fv.inputType}"
app:imeOptions="@{fv.imeOptions}"
app:enabled="@{fv.enabled}"
app:onIconClickListener="@{() -> fv.onIconClick()}"
app:fv="@{fv}" />
InputView — элемент из дизайн-библиотеки программы, которому передаются («байндяться») все необходимые данные.
Вот так выглядят FieldValues:
internal abstract class BaseFieldValue(
open val id: Int,
val type: ViewType,
var containerId: Int? = null,
var isVisible: Boolean = true,
open var data: Any? = null,
) {
abstract fun bind(parent: ViewGroup, binding: ViewDataBinding)
override fun equals(other: Any?): Boolean {…}
override fun hashCode(): Int {…}
}
internal data class InputFieldValue(
@IdRes override val id: Int,
val title: String? = null,
val text: MutableLiveData = MutableLiveData(null),
var error: MutableLiveData = MutableLiveData(""),
val inputType: String = INPUT_TYPE_NUMBER,
val imeOptions: String = IME_ACTION_NEXT,
val hint: String? = null,
val assistive: String? = null,
@DrawableRes val endDrawableRes: Int = 0,
@ColorRes val endDrawableColorRes: Int = 0,
var enabled: Boolean = true,
val formatter: BaseInputFieldFormatter = BaseInputFieldFormatter(),
val validator: BaseInputFieldValidator = BaseInputFieldValidator(),
val action: ((data: Any?) -> Unit)? = null,
override var data: Any? = null,
val onIconClickAction: ((data: Any?) -> Unit)? = null,
) : BaseFieldValue(id, ViewType.INPUT) {
override fun bind(parent: ViewGroup, binding: ViewDataBinding) {
(binding as PsItemInputViewBinding).fv = this
}
fun onIconClick() {
onIconClickAction?.invoke(data)
}
}
Повторю, что InputView самый сложный элемент, другие выглядят сильно проще. Функция bind связывает данные с xml файлом. Для InputView используются formatter и validator для соответственно форматирования и проверки правильности введенных данных (например, через каждые несколько цифр можно добавлять пробел или тире и не давать вводить более определенного количества цифр, а номер карты можно проверять например по алгоритму Лу́на).
Еще один интересный элемент, который стоит отметить, это ContainerView. Он позволяет располагать элементы горизонтально (RecyclerView у меня вертикальная, т.е. элементы следуют друг за другом сверху вниз, а иногда в форме надо расположить несколько элементов горизонтально).
----
internal data class ContainerFieldValue(
@IdRes override val id: Int = View.generateViewId(),
val items: List
) : BaseFieldValue(id, ViewType.CONTAINER) {
override fun bind(parent: ViewGroup, _binding: ViewDataBinding) {
val binding = _binding as PsItemContainerViewBinding
binding.fv = this
val view = binding.root as ViewGroup
items.forEach { field ->
val viewHolder = createViewHolderByType(parent, field.type.ordinal)
viewHolder.bind(field)
view.addView(viewHolder.itemView)
val lp = viewHolder.itemView.layoutParams as LinearLayout.LayoutParams
lp.weight = 1.0f
view.layoutParams = lp
}
}
}
Здесь container, для каждого добавляемого элемента, создает ViewHolder, «байндит» элемент и добавляет его в container.
Теперь, когда для каждого UI элемента есть xml файл и класс данных, и все необходимые поля связанны через data binding, настала очередь адаптера. Сначала ViewHolder:
internal class ViewHolder(
resId: Int,
val parent: ViewGroup,
var binding: Binding = DataBindingUtil.inflate(LayoutInflater.from(parent.context), resId, parent, false)
) : RecyclerView.ViewHolder(binding.root) {
init {
val fragment = parent.findFragment()
with(binding) {
lifecycleOwner = fragment.viewLifecycleOwner
}
}
fun bind(item: BaseFieldValue) {
item.bind(parent, binding)
}
}
Он устанавливает lifecycleOwner и вызывает функцию bind для FieldValue.
Собственно ViewsAdapter получается достаточно простым:
internal class ViewsAdapter : ListAdapter>(DiffCalback) {
object DiffCalback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: BaseFieldValue, newItem: BaseFieldValue): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: BaseFieldValue, newItem: BaseFieldValue): Boolean {
return oldItem == newItem
}
}
override fun getItemViewType(position: Int): Int {
return getItem(position).type.ordinal
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return createViewHolderByType(parent, viewType)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
internal enum class ViewType {
INPUT, CONTAINER, …
}
internal fun createViewHolderByType(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
ViewType.INPUT.ordinal -> ViewHolder(R.layout.ps_item_input_view, parent)
…
}
}
Ну и BaseFragment, от которого наследуются все Fragment-ы экранов:
internal abstract class BaseFragment : Fragment() {
protected abstract val layoutRes: Int
private var _binding: Binding? = null
val binding
get() = _binding!!
protected abstract val viewModel: WM
…
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = DataBindingUtil.inflate(inflater, layoutRes, container, false) ?: throw Exception(getString(R.string.ps_error_layout_id))
with(binding) {
lifecycleOwner = viewLifecycleOwner
setVariable(BR.vm, viewModel)
}
viewModel.onCreateView()
viewModel.action.observe(viewLifecycleOwner) {…}
…
return binding.root
}
…
}
ViewModel нужна здесь для вызова функций, связанных с жизненным циклом и для «observe» всего необходимого.
Собственно это вся обвязка. Дальше для каждого экрана создаются Fragment и ViewModel. Fragment очень простой:
internal class FineSearchFragment : BaseFragment() {
override val viewModel: FineSearchViewModel by viewModels()
override val layoutRes = R.layout.ps_fragment_form
}
Во ViewModel мы определяем наполнение нашего экрана. Вот так выглядит типичная форма:
internal class FineSearchViewModel @Inject constructor(
private val repository: ChargesRepository,
appContextProvider: AppContextProvider,
) : BaseViewModel(appContextProvider) {
override fun onCreate() {
super.onCreate()
val chargesData = repository.getData()
with(context.resources) {
addFields(listOf(
InputFieldValue(
id = R.id.ps_...,
title = getString(R.string.ps_...),
hint = getString(R.string.ps_...),
text = MutableLiveData(…),
inputType = INPUT_TYPE_TEXT,
formatter = TemplateFieldFormatter(AUTO_DOCUMENTS_TEMPLATE),
validator = NumberOrEmptyFieldValidator(VRC_LENGTH),
endDrawableRes = R.drawable…,
endDrawableColorRes = R.color…,
onIconClickAction = ::showAdvice,
data = VRC_ADVICE,
),
CheckboxFieldValue(
id = R.id.ps_...,
title = getString(R.string.ps_...),
selected = MutableLiveData(chargesData.saveDocuments),
),
))
addField(
ContainerFieldValue(
id = R.id.ps_container1,
items = listOf(
ButtonFieldValue(
text = MutableLiveData(getString(R.string.ps_.)),
horizontalMarginRes = R.dimen.ps_...,
action = ::onCancelButtonClick,
),
ButtonFieldValue(
text = MutableLiveData(getString(R.string.ps_.)),
horizontalMarginRes = R.dimen.ps_...,
action = ::onOkButtonClick,
),
),
),
isMainFields = false
)
}
}
}
Здесь addField / addFields ф-ции добавляющие элементы в список mainItems / bottomItems. Далее во ViewModel делаются необходимые сетевые запросы, заполняются / апдейтятся поля формы и т.д.
В общем удалось добиться того, чего хотелось: экран создается в декларативной манере (списком data class-ов с нужными данными), есть всего один xml файл формы и несколько xml файлов элементов, изменения вносятся в базовые файлы.
Надеюсь идея сможет кому-то пригодится.