
Привет всем! Хочу поделится идеей создания 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 файлов элементов, изменения вносятся в базовые файлы.
Надеюсь идея сможет кому-то пригодится.
