RVcompose

Introduction: Extensible Kotlin DSL for building dynamic reusable UI components with RecyclerView
More: Author   ReportBugs   
Tags:

Codacy Badge Download License

RVcompose: an easy-to-use, extensible Kotlin DSL for building dynamic reusable UI components with RecycerView


Why

A lot of applications follow a reusable UI pattern of design. I realized most design can be divided into reusable component. Instead of having to design the layout of different screens, I resulted in designing layout for specific UI requirment for reusability . I have tried this approach in a couple of applications (see example). It works great and allows me to build and add a new screen faster. It also helped improved updating views with changes in Models. It also fixes the issue with nested scroll performance when you combine things

RVcompose is an underlying library that provides a structure for designing other applications using this pattern.

Gradle Dependency

Add this to your module's build.gradle file:

dependencies {

  implementation 'xyz.belvi:rvcompose:rvcomposelibrary:1.0.3'
}

The Basics

First, a layout:

<LinearLayout ...>

  <TextView 
     android:id="@+id/text_name"
     ... />    

  <TextView 
     android:id="@+id/text_age"
     ... />

</LinearLayout>

Second, a View Holder:

data class InputField(
    var hint: String = "",
    var text: String = ""
) :
    Field( R.layout.item_input) {


    override fun getValue(): String {
        return text
    }

    override fun bind(
        itemView: View,
        uiComposeAdapter: UIComposeAdapter,
        position: Int,
        event: (field: Field) -> Unit
    ) {
        itemView.item_field.setText(text)
        itemView.item_field.hint = hint

    }

    override fun hasValidData(): Boolean {
        return validation?.invoke() ?: kotlin.run { if (required) !text.isBlank() else true }
    }

}

Finally, you can begin using the DSL API:

val rv = recycler.compose {
            withLayoutManager(LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false))
            withField<InputField> {
                hint = "Customer Email"
                key = "email"
                required = true
                validation = { Patterns.EMAIL_ADDRESS.matcher(this.text).matches() }

            }
            fieldEvent { uiComposeAdapter, field, position ->

            }
        }

There are two main components of RVcompose:

The Model that describe how your views should be displayed in the RecyclerView.

The Adapter where the models are used to describe what items to show , with what data and interaction between models.

Creating Models

Models are created by extending Field

data class NoteField(
    override val layout: Int = R.layout.item_input_note,
    var hint: String="",
    var text: String = ""
) :
    Field() {
    override fun getValue(): String {

    }

    override fun bind(
        itemView: View,
        uiComposeAdapter: UIComposeAdapter,
        position: Int,
        event: (field: Field) -> Unit
    ) {


    }

    override fun hasValidData(): Boolean {

    }
}

Understanding the fields in a Model

layout The layout to be inflated. It is the only required field when Fieid is extended

data class AdditemField(
    var text: String = ""
) :
    Field(R.layout.item_invoice_add_new_item)

this example shows executing email validation on InputField.

Validation This is important when building Forms or if you need to validated entry in the model.

rvField<InputField> {
                hint = "Customer Email"
                validation = { android.util.Patterns.EMAIL_ADDRESS.matcher(this.text).matches() }
            }

this example shows executing email validation on InputField.

Key for referencing a model from the adapter.

rvField<InputField> {
                hint = "Customer Email"
                key = "EMAIL_KEY"
                validation = { android.util.Patterns.EMAIL_ADDRESS.matcher(this.text).matches() }
            }

required for marking a field as required. validation is invoked ony if required is true

rvField<InputField> {
                hint = "Customer Email"
                required = true
                key = "EMAIL_KEY"
                validation = { android.util.Patterns.EMAIL_ADDRESS.matcher(this.text).matches() }
            }

errormessage message to be displayed when validate fails.

rvField<InputField>
    {
        hint = "Customer Email"
        required = true
        key = "EMAIL_KEY"
        validation = {
            val isEmail = android.util.Patterns.EMAIL_ADDRESS.matcher(this.text).matches()
            errorMessage = if (isEmail) "" else "enter a valid email address"
            isEmail
        }
    }

datasource Datasource of this model. You can choose to pass some data as paramerter to your model. A datasource can be an database object or any object that has information for updating your view

rvField<InputField>
    {
        hint = "Customer Email"
        required = true
        datasource = Person()
        key = "EMAIL_KEY"
        validation = {
            val isEmail = android.util.Patterns.EMAIL_ADDRESS.matcher(this.text).matches()
            errorMessage = if (isEmail) "" else "enter a valid email address"
            isEmail
        }
    }

matchSearch() Function for adding your model to a search result. UIComposeAdapter has model search implementation. To ensure your a model is considered while performing a search, override this function.

override fun matchSearch(text: String): Boolean {
        return this.text.contains(text)
    }

getValue(): Any Override the method to set the value the model should return.

data class InvoiceDateField(
    var hint: String ="",
    var date: Calendar = Calendar.getInstance()
) :
    Field( R.layout.item_invoice_date) {

    @SuppressLint("SimpleDateFormat")
    override fun getValue(): String {
        return SimpleDateFormat("yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'").format(Date(date.timeInMillis))
    }

These fields are the basic requirements. You can extend Field and build upon the implmentation


Building Models

Models are built in 2 ways when setting up rvCompose.

1. withField

Used this when initialising rvCompose. This usecase applies if views are statics or known during compile time.


 val rv = recycler.compose {
            withLayoutManager(LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false))
            withField<InputField> {
                hint = "Customer Email"
                key = "EMAIL_KEY"
                required = true
                validation = { Patterns.EMAIL_ADDRESS.matcher(this.text).matches() }
            }
            withField<InputField> {
                hint = "Customer Name"
                key = "NAME_KEY"
                required = true
                validation = { this.text.isNotBlank()}
            }
            fieldClicked { uiComposeAdapter, field, position ->

            }
        }

2. rvField

There are cases that requires you to build your models on runtime and update the views. In cases like this, I suggest building a factory to handle this:

object CustomerFactory {
    fun sampleUI(): MutableList<Field> {
        return mutableListOf<Field>().withFields {

            this += rvField<InputField> {
                hint = "Customer Email"
                key = "email"
                required = true
                validation = { android.util.Patterns.EMAIL_ADDRESS.matcher(this.text).matches() }
            }
            this += rvField<InvoiceDateField> {
                hint = "Invoice Date"
                date = Calendar.getInstance()

            }
            this += rvField<SpinnerField> {
                items = mutableListOf("Pay with Cash", "Pay with Card", "Pay some other time")
            }
            this += rvField<InvoiceDateField> {
                hint = "Due Date"
                date = Calendar.getInstance()

            }
            this += rvField<AdditemField> {
                text = "Add Item"
            }

            this += rvField<InvoiceReceiptField> {
                totalDue = 0.0
            }
            this += rvField<NoteField> {
               hint="additional information for customer"
            }
            this+= rvField<ActionField> {
                text = "Create Invoice"
            }

        }


    }


}

this factory returns a list of models MutableList<Field> the ui is updated with :

rv.getAdapter().updateFields(CustomerFactory.sampleUI())

Interaction between Models and Updating Models.

interaction between Fields can happen in bind(). This provide UIComposeAdapter that you can use to retrieve a model by key or index, update the model and reflect changes on view by any of RecyclerView notifyAdapter functions.

override fun bind(
        itemView: View,
        uiComposeAdapter: UIComposeAdapter,
        position: Int,
        event: (field: Field) -> Unit
    ) {

        itemView.btn_action_field.text = text
        itemView.btn_action_field.setOnClickListener {
            (uiComposeAdapter.fieldWithKey("email") as? InputField)?.let {
                it.text = "button clicked"
                uiComposeAdapter.notifyItemChanged(uiComposeAdapter.fieldIndexWithKey(key))
            }

        }

    }

Handling Event

To receive field event on fieldEvent, a Field needs to call event(this) on onclick of the view on. See this:

override fun bind(
        itemView: View,
        uiComposeAdapter: UIComposeAdapter,
        position: Int,
        event: (field: Field) -> Unit
    ) {
        itemView.btn_action_field.text = text
        itemView.btn_action_field.setOnClickListener {
          // you can also perform actions here
          event(this) // do this so that it can be received on `fieldEvent`
        }

    }

You can also handle event for all Models in fieldEvent

val rv = recycler.compose {
            withLayoutManager(LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false))
            fieldEvent { uiComposeAdapter, field, position ->
                // event is received perform action 
            }
        }

UICompose Functions

UIComposeAdapter provides functions for interacting and updating the models.

fieldWithKey() retrieve a Model from the adapter with a key.

fieldIndex() retrieve a Model from the adapter with an index (if the index is known)

fieldIndexWithKey() retrieve a Model Index from the adapter with Key

isFormValid() Run validation check on Models in the adapter

formWarning() returns an error message for field with failed validation

formData() Returns Hashmap for models in the adapter.

 val rv = recycler.compose {
            withLayoutManager(LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false))
            fieldEvent { uiComposeAdapter, field, position ->
                if (field is ActionField && uiComposeAdapter.isFormValid()) {
                    (uiComposeAdapter.fieldWithKey("email") as InputField).let {
                        it.text = "example@example.com"
                        uiComposeAdapter.notifyItemChanged(uiComposeAdapter.fieldIndexWithKey(key = it.key))
                    }

                } else {
                    Toast.makeText(this@MainActivity, uiComposeAdapter.formWarning(), Toast.LENGTH_LONG).show()
                }
            }
        }

Applications Developed with RVCompose

Traction App

Sample

Contribution

Pull requests are welcome! We'd love help improving this library. Feel free to browse through open issues to look for things that need work. If you have a feature request or bug, please open a new issue so we can track it.

Support Me
Apps
About Me
Google+: Trinea trinea
GitHub: Trinea