Backend-Driven UI with widgets

Consider the features of this approach and our implementation using widgets, their concept, advantages and differences from other views in Android.



Backend-Driven UI - an approach that allows you to create UI-components based on server response. The API description should contain the types of components and their properties, and the application should display the necessary components depending on the types and properties. In general, the logic of the components can be laid down, and for a mobile application they are a black box, since each component can have logic independent of the rest of the application and can be configured arbitrarily by the server, depending on the required business logic. That is why this approach is often used in banking applications: for example, when you need to display a translation form with a large number of dynamically defined fields. The application does not know in advance the composition of the form and the order of the fields in it, therefore,this approach is the only way to write code without crutches. In addition, it adds flexibility: from the server side, you can change the form at any time, and the mobile application will be ready for this.

Use case




The following types of components are presented above:

  1. List of accounts available for transfer;
  2. The name of the type of translation;
  3. Field for entering a phone number (it has a mask for entering and contains an icon for selecting contacts from the device);
  4. Field for entering the transfer amount.

Also on the form, any number of other components embedded in the business logic and determined at the design stage is possible. Information about each component that comes in the response from the server must meet the requirements, and each component must be expected by the mobile application to process it correctly.



Different input fields have different masks and validation rules; the button may have a shimmer animation at boot time; a widget for selecting a charge-off account can have animation when scrolling, and so on.

All UI components are independent of each other, and the logic can be taken out into separate views with different areas of responsibility - let's call them widgets. Each widget receives its configuration in the server response and encapsulates the logic of display and data processing.

When implementing the screen, RecyclerView is best suited, the elements of which will contain widgets. The ViewHolder of each unique list item will initialize the widget and give it the data it needs to display.

Widget concept


Let's consider widgets in more detail. At its core, a widget is a custom view "at maximum speed." An ordinary custom view can also contain data and the logic of their display, but the widget implies something more - it has a presenter, a screen model and has its own DI-scope.

Before diving into the details of the implementation of widgets, we discuss their advantages:

  • , , «» , — UI- , .
  • , , .
  • , — : , , .


To implement scalable widgets, we use the base classes for the most commonly used ViewGroups, which have their own DI scopes. All base classes, in turn, are inherited from the common interface, which contains everything necessary for initializing widgets.

The easiest case for using widgets is static views, specified directly in the layout. After implementing the widget classes, you can safely add it to the XML layout, without forgetting to specify its id in the layout (based on the id, a widget's DI scope will be formed).

In this article, we consider dynamic widgets in more detail, since the case of the translation form described above with an arbitrary set of fields is conveniently solved with their help.

Any widget, both static and dynamic, in our implementation is almost no different from ordinary view in terms of MVP. Typically, 4 classes are needed to implement a widget:

  1. View class, where layout layout and content displaying occurs;

    class TextInputFieldWidget @JvmOverloads constructor(
            context: Context,
            attrs: AttributeSet? = null
    ) : CoreFrameLayoutView(context, attrs) {
    @Inject
    lateinit var presenter: TextInputFieldPresenter
    init {
         inflate(context, R.layout.view_field_text_input, this)
      }
    }
    

  2. A class for the presenter, where the basic logic of the widget is described, for example:

    1. loading data and transmitting it for a render;
    2. Subscribing to various events and emit events of widget input changes;

    @PerScreen
    class TextInputFieldPresenter @Inject constructor(
            basePresenterDependency: BasePresenterDependency,
            rxBus: RxBus
    ) : BaseInputFieldPresenter<TextInputFieldWidget>(
           basePresenterDependency, rxBus
    ) {
    private val sm = TextInputFieldScreenModel()
    ...
    }
    

    In our implementation, the RxBus class is a PublishSubject-based bus for sending events and subscribing to them.
  3. A class for the screen model, with the help of which the presenter receives data and transfers it for rendering in a view (in terms of the Presentation Model pattern);

    class TextInputFieldScreenModel : ScreenModel() {
    	val value = String = “”
    	val hint = String = “”
    	val errorText = String = “”
    }
    

  4. A configurator class for implementing DI, with the help of which dependencies for the widget are delivered that have the desired scope, and the presenter is injected into its view.

    class TextInputFieldWidgetConfigurator : WidgetScreenConfigurator() {
    	// logic for components injection
    }
    


The only difference between widgets and our implementation of full-fledged screens (Activity, Fragment) is that the widget does not have many life cycle methods (onStart, onResume, onPause). It has only the onCreate method, which shows that the widget has currently created its scope, and the scoop is destroyed in the onDetachedFromWindow method. But for convenience and consistency, the widget’s presenter gets the same lifecycle methods as the rest of the screens. These events are automatically transmitted to him from the parent. It should be noted that the base class of the widget presenter is the same base class of presenters of other screens.

Using dynamic widgets


Let's move on to the implementation of the case described at the beginning of the article.

  1. In the screen presenter, the data for the translation form is loaded, the data is transmitted to the view for rendering. At this stage, it doesn’t matter to us whether the view of the activity screen is a fragment or widget. We are only interested in having RecyclerView and rendering a dynamic form with it.

    // TransferFormPresenter
    private val sm = TransferFormScreenModel()
    private fun loadData() {
    	loadDataDisposable.dispose()
      	loadDataDisposable = subscribe(
                  observerDataForTransfer().io(), 
                  { data -> 
                          sm.data = data
                          view.render(sm)
                  },
                  { error -> /* error handling */ }
      	)
     }
    

  2. Form data is transferred to the list adapter and rendered using widgets that are in the ViewHolder for each unique form element. The desired ViewHolder for rendering the component is determined based on predefined types of form components.

    // TransferFormView
    fun render(sm: TransferFormScreenModel) {
        //      
        //   EasyAdapter [3]
        val list = ItemList.create()
        //       Controller,
        //       
    
        sm.data
            .filter { transferField -> transferField.visible }
            .forEach { transferField ->
                when (transferField.type) {
                    TransferFieldType.PHONE_INPUT -> {
                        list.add(
                            PhoneInputFieldData(transferField),
                            phoneInputController
                        )
                    }
                    TransferFieldType.MONEY -> {
                        list.add(
                            MoneyInputFieldData(transferField),
                            moneyInputController
                        )
                    }
                    TransferFieldType.BUTTON -> {
                        list.add(
                            ButtonInputFieldData(transferField),
                            buttonController
                        )
                    }
                    else -> {
                        list.add(
                            TextInputFieldData(transferField),
                            textInputController
                        )
                    }
                }
            }
            //     RecyclerView
            adapter.setItems(list)
    }  
    

  3. The widget is initialized in the bind method of ViewHolder. In addition to transmitting data for the render, it is also important to set a unique id for the widget, on the basis of which its DI scope will be formed. In our case, each element of the form had a unique id, which was responsible for the appointment of input and came in response in addition to the type of element (types can be repeated on the form).

    // ViewHolder
    override fun bind(data: TransferFieldUi) {
    	// get initialize params from given data
    	itemView.findViewById(R.id.field_tif).initialize(...)
    }
    

  4. The initialize method initializes the widget view data, which is then transmitted to the presenter using the onCreate life cycle method, where the field values ​​are set to the widget model and its render.

    // TextInputFieldWidget
    fun initialize(
           id: String = this.id,
           value: String = this.value,
           hint: String = this.hint,
           errorText: String = this.errorText
    ) {
           this.id = id
           this.value = value
           this.hint = hint
           this.errorText = errorText
    }
        
    override fun onCreate() {
           presenter.onCreate(value, hint, errorText)
           // other logic...
    }
    // TextInputFieldPresenter
    fun onCreate(value: String, hint: String, errorText: String) {
           sm.value = value
           sm.hint = hint
           sm.errorText = errorText
           view.render(sm)
    }
    


Underwater rocks


As can be seen from the description, the implementation is very simple and intuitive. However, there are nuances that need to be considered.

Consider the life cycle of widgets


Since the base classes of widgets are inheritors of the commonly used ViewGroups, we also know the life cycle of widgets. Typically, widgets are initialized in the ViewHolder by calling a special method where the data is transferred, as shown in the previous paragraph. Other initialization takes place in onCreate (for example, setting click listeners) - this method is called after onAttachedToWindow using a special delegate that controls the key entities of the widget logic.

// CoreFrameLayoutView (      ViewGroup)
public abstract class CoreFrameLayoutView
          extends FrameLayout implements CoreWidgetViewInterface {
@Override
protected void onAttachedToWindow() {
   super.onAttachedToWindow();
   if (!isManualInitEnabled) {
        widgetViewDelegate = createWidgetViewDelegate();
        widgetViewDelegate.onCreate();
   }
}

public void onCreate() {
    //empty. define in descendant class if needed
}

// WidgetViewDelegate
public class WidgetViewDelegate {
public void onCreate() {
   // other logic of widget initialization
   coreWidgetView.onCreate();
}

Always clean the listeners


If there are dependent fields on the form, we may need onDetachedFromWindow. Consider the following case: the translation form has many fields, among which there is a drop-down list. Depending on the value selected in the list, an additional form input field may become visible or an existing one may disappear.
dropdown value for choosing the type of translationVisibility of the payment period input fieldphone number input field visibility
transfer by phone numberfalsetrue
paymenttruefalse

In the case described above, it is very important to clear all widget listeners in the onDetachedFromWindow method, since if you add the widget to the list again, all listeners will be reinitialized.

// TextInputFieldWidget
override fun onCreate() {
     initListeners()
}

override fun onDetachedFromWindow() {
      super.onDetachedFromWindow()
      clearListeners()
}

Handle widget event subscriptions correctly


The presentation of the screen containing the widgets should be notified of the changes in the input of each widget. The most obvious implementation of each widget using emit events and subscribing to all events with a screen presenter. The event must contain the widget id and its data. It is best to implement this logic so that the current input values ​​are saved in the screen model and when you click on the button, the finished data is sent in the request. With this approach, it is easier to implement form validation: it occurs when a button is clicked, and if there were no errors, the request is sent with the form data saved in advance.

// TextInputFieldWidget
private val defaultTextChangedListener = object : OnMaskedValueChangedListener {
        override fun onValueChanged(value: String) {
            presenter.onTextChange(value, id)
        }
}

// Events.kt
sealed class InputValueType(val id: String)

class TextValue(id: String, val value: String) : InputValueType(id)

class DataEvent(val data: InputValueType)

// TextInputFieldPresenter -  
fun onTextChange(value: String, id: String) {
	rxBus.emitEvent(DataEvent(data = TextValue(id = id, value = value)))
}

// TransferFormPresenter -  
private fun subscribeToEvents() {
	subscribe(rxBus.observeEvents(DataEvent::class.java))
        {
            handleValue(it.data) // handle data
        }
}

private fun handleValue(value: InputValueType) {
	 val id = value.id
	 when (value) {
		 // handle event using its type, saving event value using its id
	 	 is TextValue -> {
       		 	 sm.fieldValuesMap[id] = value.value
       	 	 }
		 else -> {
			// handle other events
		 }
 	 }
}
// TransferScreenModel
class TransferScreenModel : ScreenModel() {
 	 // map for form values: key = input id
	 val fieldValuesMap: MutableMap<String, String> = mutableMapOf()
}

There is a second implementation option, in which events from widgets with their data come only after clicking on the button, and not as you type, that is, we collect all the data immediately before sending the request. With this option, there will be much fewer events, but it is worth noting that this implementation may turn out to be nontrivial in practice, and additional logic will be needed to check whether all events have been received.

Unify All Requirements


I would like to once again note that the described case is possible only after coordinating the requirements with the backend.

What requirements need to be unified:

  • Field types. Each field should be expected by the mobile application for its correct display and processing.
  • — , , , .
  • , .
  • . , : , , — , -, .



This is necessary in order for the component received in the response to be known to the mobile application for the correct display and processing of logic.

The second nuance is that the components of the form themselves are generally independent of each other, however, some scenarios are possible when, for example, the visibility of one element depends on the state of another, as described above. To implement this logic, it is necessary that the dependent elements always come together, and the response must contain a description of the logic, which components depend on each other and how. And of course, all this should be agreed with the server team before starting development.

Conclusion


Thus, when implementing even such a standard case as filling in a dynamic list, you can always not stop at already existing solutions. For us, this was a new concept, which allowed us to select atomic pieces of logic and representation from huge screens and we managed to get a working extensible solution that is easy to maintain due to the similarity of widgets with other views. In our implementation, widgets were developed in terms of the RxPM pattern - after adding binders, it became even more convenient to use widgets, but this is a completely different story.

useful links


  1. Android application development framework Surf
  2. Widget module
  3. Our implementation of simple render of complex lists
  4. PresentationModel pattern

All Articles