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 файл формы:

…     <data>         <variable name="vm" type="…BaseViewModel" />     </data>      <androidx.constraintlayout.widget.ConstraintLayout         android:layout_width="match_parent"         android:layout_height="match_parent" >          <androidx.recyclerview.widget.RecyclerView             android:id="@+id/rv_form"             android:layout_width="0dp"             android:layout_height="0dp"             app:adapter="@{vm.rvMainAdapter}"             app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"             app:listData="@{vm.mainItems}"             …          />          <androidx.recyclerview.widget.RecyclerView             android:id="@+id/rv_bottom"             android:layout_width="0dp"             android:layout_height="wrap_content"             android:layout_marginBottom="@dimen/ps_button_bottom_margin"             app:adapter="@{vm.rvBottomAdapter}"             app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"             app:listData="@{vm.bottomItems}"             …          />          <ProgressBar …

RecyclerView понадобилось два, один для основных элементов формы и другой для кнопки / кнопок внизу экрана и связанных с ней UI элементов. С помощью data binding-а форма связывалась с ViewModel и получала все необходимые элементы и все их изменения (для этого эл-ты были обернуты в LiveData).

Далее BaseViewModel:

internal abstract class BaseViewModel(appContextProvider: AppContextProvider) : ViewModel() {     val rvMainAdapter = MutableLiveData<ListAdapter<*, *>>(ViewsAdapter())     val rvBottomAdapter = MutableLiveData<ListAdapter<*, *>>(ViewsAdapter())     val mainItems = MutableLiveData<List<BaseFieldValue>>()     val bottomItems = MutableLiveData<List<BaseFieldValue>>() … }  @BindingAdapter("adapter") internal fun setRecyclerViewAdapter(recyclerView: RecyclerView, adapter: ListAdapter<*, *>) {     recyclerView.adapter = adapter }  @BindingAdapter("listData") internal fun bindRecyclerView(recyclerView: RecyclerView, data: List<BaseFieldValue>?) {     (recyclerView.adapter as ListAdapter<BaseFieldValue, *>).submitList(data) }

В нем находятся адаптеры и списки UI элементов для RecyclerView, которые “байндяться” к форме.

 Теперь о самих UI элементах. Для каждого элемента создается свой xml файл. Приведу пример наиболее сложного элемента InputView:

<data>     <variable name="fv" type="…InputFieldValue" /> </data>  <...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<String?> = MutableLiveData(null),     var error: MutableLiveData<String> = 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 у меня вертикальная, т.е. элементы следуют друг за другом сверху вниз, а иногда в форме надо расположить несколько элементов горизонтально).

<data>     <variable name="fv" type="...ContainerFieldValue" /> </data>  <LinearLayout     android:id="@+id/container"     android:layout_width="match_parent"     android:layout_height="wrap_content"     android:orientation="horizontal" />  ----  internal data class ContainerFieldValue(     @IdRes override val id: Int = View.generateViewId(),     val items: List<BaseFieldValue> ) : 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<Binding : ViewDataBinding>(     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<Fragment>()          with(binding) {             lifecycleOwner = fragment.viewLifecycleOwner         }     }      fun bind(item: BaseFieldValue) {         item.bind(parent, binding)     } }

Он устанавливает lifecycleOwner и вызывает функцию bind для FieldValue.

Собственно ViewsAdapter получается достаточно простым:

internal class ViewsAdapter : ListAdapter<BaseFieldValue, ViewHolder<out ViewDataBinding>>(DiffCalback) {     object DiffCalback : DiffUtil.ItemCallback<BaseFieldValue>() {         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<out ViewDataBinding> {         return createViewHolderByType(parent, viewType)     }      override fun onBindViewHolder(holder: ViewHolder<out ViewDataBinding>, position: Int) {         holder.bind(getItem(position))     } }  internal enum class ViewType {     INPUT, CONTAINER, … }  internal fun createViewHolderByType(parent: ViewGroup, viewType: Int): ViewHolder<out ViewDataBinding> {     return when (viewType) {         ViewType.INPUT.ordinal -> ViewHolder<PsItemInputViewBinding>(R.layout.ps_item_input_view, parent)         …    } }

Ну и BaseFragment, от которого наследуются все Fragment-ы экранов:

internal abstract class BaseFragment<Binding: ViewDataBinding, WM: BaseViewModel> : 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<PsFragmentFormBinding, FineSearchViewModel>() {     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 файлов элементов, изменения вносятся в базовые файлы.

Надеюсь идея сможет кому-то пригодится.


ссылка на оригинал статьи https://habr.com/ru/articles/825554/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *