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

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

Комментарии (1)


  1. IL_Agent
    30.06.2024 09:28

    В общем удалось добиться того, чего хотелось: экран создается в декларативной манере (списком data class-ов с нужными данными), есть всего один xml файл формы и несколько xml файлов элементов, изменения вносятся в базовые файлы.

    Звучит как обычная работа с ресайклером