RVcompose
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<key,value> 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
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.