Skip to content

Experiment #3: Adapter DSL with reusable UI components

Adriel Café edited this page Aug 24, 2020 · 4 revisions

In the previous experiment, we talked about high-level UI components, not designed for reuse. Not let's talk about little pieces of UI that can be easily re-used.

Reusable UI components are common in declarative UI frameworks like Jetpack Compose and Swift UI. Based on two excellent articles (1, 2), I tried to achieve a similar result with very little code.

All sections of the app reuse UI components made by this experiment. For example, some of the UI components in the settings section (image below) are being reused in other places.

Full implementation on SettingsSection.kt

So how does it works? It's pretty simple: each UI component is an item on the RecyclerView's adapter. The key to making them reusable is to keep them simple and have no logic inside.

Step 1: adapter wrapper

I'm using FastAdapter to handle the adapter and avoid some boilerplate code. It also has an experimental DSL that we gonna use.

// Top-level extension function
typealias Adapter = FastAdapter<GenericItem>

fun adapter(init: Adapter.() -> Unit): Lazy<Adapter> =
    lazy {
        genericFastAdapter {
            init()
        }
    }

// UI component
class SettingsSection(private val binding: SectionSettingsBinding) {

    private val settingsAdapter by adapter {
        // Add items here
    }

    init {
        setupViews()
    }

    private fun setupViews() {
        // Set the adapter as usual
        binding.settingsList.adapter = settingsAdapter
    }
}

Nothing really important so far, we just created an extension function that lazily starts the FastAdapter.

Step 2: base item

To avoid repeating the same code for all items, we have this generic item() function that creates our BindingItem and adds it to the adapter.

typealias AdapterItem = ItemAdapter<GenericItem>

fun <B : ViewBinding> AdapterItem.item(
    @LayoutRes layoutRes: Int,
    inflate: (LayoutInflater, ViewGroup?, Boolean) -> B,
    bind: B.() -> Unit
) {
  object : AbstractBindingItem<B>() {
      override val type = layoutId

      override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?): B =
          inflate(inflater, parent, false)

      override fun bindView(binding: B, payloads: List<Any>) =
          binding.bind()
  }.also {
      add(it)
  }
}

Full implementation on adapter.kt

About the arguments:

  • layoutId: each UI component should have its own layout
  • inflate: lambda with same signature as ViewBinding.inflate() so that we can use function reference
  • bind: lambda with receiver were we can access the ViewBinding instance and setup our UI

Step 3: basic item

Now let's create a very simple item that shows a text:

// Top-level extension function
fun AdapterItem.text(@StringRes titleRes: Int) =
    item(R.layout.adapter_text, AdapterTextBinding::inflate) {
        root.text = context.getString(titleRes)
    }

// UI component
class SettingsSection(private val binding: SectionSettingsBinding) {

    private val settingsAdapter by adapter {
        text(R.string.settings_frequency)
    }
}

Full implementation on items.kt

The output:

As you can see, we're composing functions like it's done by declarative UI frameworks.

Step 4: complex item

Let's create an interactive item: a checkbox!

// Top-level extension function
fun AdapterItem.checkBox(
    @StringRes titleRes: Int,
    selected: () -> Boolean = { false },
    onChange: (Boolean) -> Unit
) {
    item(R.layout.adapter_checkbox, AdapterCheckboxBinding::inflate) {
        title.text = context.getString(titleRes)

        selector.apply {
            setOnCheckedChangeListener(null)
            isChecked = selected()
            setOnCheckedChangeListener { _, isChecked -> onChange(isChecked) }
        }
    }
}

// UI component
class SettingsSection(private val binding: SectionSettingsBinding) {

    private val settingsAdapter by adapter {
        checkBox(
            titleRes = R.string.settings_show_notification,
            selected = { currentSettings.autoWallpaper.showNotification },
            onChange = { selected -> /* Update settings */ }
        )
    }

    private fun onSettingsChanged() {
        settingsAdapter.notifyAdapterDataSetChanged()
    }
}

The output:

What's new here: we can now define whether the item is selected or not and listen to the changes. Note that the item doesn't hold any logic, it just uses the result of lambdas.

Conclusion

With only FastAdapter and two extension functions, it's possible to create any type of UI component that can be reused throughout the app.

Reusable UI components are amazing and definitely the future of mobile development. While Jetpack Compose ins't ready for production, I'll use what I learned from this experiment on future projects.