Update 2021: See JetpackComposeCards for a Jetpack Compose implementation of the same design system.
A proof of concept for a composition-over-inheritance RecyclerView that uses Card Components and JSON-driven styles. It's built with Hannes Dorfmann's library of the same name.
Adapter Delegates are an inversion control pattern for the RecylerView designed to help you add reusable cards to a list instead of defining a fix set of cards to an Adapter that leads to a subclass hell.
This sample uses Adapter Delegates to create a visually robust feed with a few goals:
- Composition over Inheritance in a ReyclerView
- Separate Layout, Style, and Text Style
- Compose card style with a JSON model
Following these principles, we will get 15 different card variants with just three Adapter Delegates.
The main objective of the Adapter Delegate system is to favor composition over inheritence when using the RecyclerView. We will create Cards instead of Adapters.
The standard RecyclerView Adapter's performance is great, but it can quickly grow unweildy if your Design team wants a feed with more than just a standard material card.
In this example, there are three cards (View Types) that require multiple when statements across onCreateViewHolder
, onBindViewHolder
, and getItemViewType
.
Even worse, you have to make sure your when case in onCreateViewHolder
matches the case in onBindViewHolder
otherwise you might end up with a mismatched implementation.
This solution doesn't scale well when we have 10 different View Types with unique on bind logic for each case.
class FeedAdapter : ListAdapter<FeedItem, CardViewHolder>(FeedItemDiffCallback()) {
companion object {
const val VIEW_TYPE_MATERIAL_CARD = 1
const val VIEW_TYPE_THUMBNAIL_CARD = 2
const val VIEW_TYPE_VISUAL_CARD = 3
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_MATERIAL_CARD -> CardViewHolder(inflater.inflate(R.layout.card_material, parent, false))
VIEW_TYPE_THUMBNAIL_CARD -> CardViewHolder(inflater.inflate(R.layout.card_thumbnail, parent, false))
VIEW_TYPE_VISUAL_CARD -> CardViewHolder(inflater.inflate(R.layout.card_visual, parent, false))
else -> CardViewHolder(inflater.inflate(R.layout.card_material, parent, false))
}
}
override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
return when (viewType) {
VIEW_TYPE_MATERIAL_CARD -> // Do something custom for the Material View Type
VIEW_TYPE_THUMBNAIL_CARD -> // Do something custom for the Thumbnail View Type
VIEW_TYPE_VISUAL_CARD -> // // Do something custom for Visual View Type
else -> // Define your fallback case
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position).layoutKey) {
"material" -> VIEW_TYPE_MATERIAL_CARD
"thumbnail" -> VIEW_TYPE_THUMBNAIL_CARD
"visual" -> VIEW_TYPE_VISUAL_CARD
else -> VIEW_TYPE_MATERIAL_CARD
}
}
}
Ideally, we want to harness the performance of the RecyclerView Adapter with something that scales well when we have 10 or more cards in our feed.
Each Adapter Delegate implements the RecyclerView Adapter methods of onCreateViewHolder
and onBindViewHolder
to compose a card.
In this sense, an Adapater Delegate is a Card Component.
class MaterialCardAdapterDelegate() : AbstractCardAdapterDelegate() {
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
return CardViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.card_material, parent, false))
}
override fun isForViewType(items: List<FeedItem>, position: Int): Boolean {
return items[position].layoutKey == "material"
}
override fun onBindViewHolder(
items: List<FeedItem>,
position: Int,
holder: RecyclerView.ViewHolder,
payloads: MutableList<Any>
) {
super.onBindViewHolder(items, position, holder, payloads)
}
}
class ThumbnailCardAdapterDelegate() : AbstractCardAdapterDelegate() {
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
return CardViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.card_thumbnail, parent, false))
}
override fun isForViewType(items: List<FeedItem>, position: Int): Boolean {
return items[position].layoutKey == "thumbnail"
}
override fun onBindViewHolder(
items: List<FeedItem>,
position: Int,
holder: RecyclerView.ViewHolder,
payloads: MutableList<Any>
) {
super.onBindViewHolder(items, position, holder, payloads)
}
}
class VisualCardAdapterDelegate() : AbstractCardAdapterDelegate() {
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
return CardViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.card_visual, parent, false))
}
override fun isForViewType(items: List<FeedItem>, position: Int): Boolean {
return items[position].layoutKey == "visual"
}
override fun onBindViewHolder(
items: List<FeedItem>,
position: Int,
holder: RecyclerView.ViewHolder,
payloads: MutableList<Any>
) {
super.onBindViewHolder(items, position, holder, payloads)
}
}
class FeedAdapterDelegatesManager() : ListDelegationAdapter<List<FeedItem>>() {
companion object {
const val VIEW_TYPE_MATERIAL_CARD = 1
const val VIEW_TYPE_THUMBNAIL_CARD = 2
const val VIEW_TYPE_VISUAL_CARD = 3
}
init {
delegatesManager.addDelegate(VIEW_TYPE_MATERIAL_CARD,
MaterialCardAdapterDelegate()
)
delegatesManager.addDelegate(VIEW_TYPE_THUMBNAIL_CARD,
ThumbnailCardAdapterDelegate()
)
delegatesManager.addDelegate(VIEW_TYPE_VISUAL_CARD,
VisualCardAdapterDelegate()
)
delegatesManager.fallbackDelegate =
MaterialCardAdapterDelegate()
}
}
By using CardViewHolder
and resetting its default values, we can even reuse inflated views across multiple View Types (thumbnail vs. visual) to achieve faster scroll performance.
By separating layout, styles, and text styles across three dimensions; it is possible to create 15 card variants with just three RecyclerView Adapter Delegates.
Layout is best conceptualized as the abstract positioning of views in a container. Our layouts are concerned with what views go where without knowing what our final card will look like.
- Material Card Layout
- Thumbnail Card Layout
- Visual Card Layout
- 3:2 Image
- Overline Text View
- Title Text View
- Body Text View
- 1:1 Image
- Overline Text View
- Title Text View
- Body Text View
- 4:2 Image
- Overlay Gradient
- Overlay Title Text View
The styles are concerned with the relationship between views in a layout; i.e. margin, padding, or line height properties. By keep text styles in separate dimension, styles can be simplified and reused across many layouts easily.
<style name="CustomComponent.Overline">
<item name="android:layout_marginTop">@dimen/material_component_vertical_padding</item>
</style>
<style name="CustomComponent.Headline">
<item name="android:layout_marginTop">@dimen/material_component_vertical_padding</item>
</style>
<style name="CustomComponent.Headline.Overlay">
<item name="android:layout_marginBottom">16dp</item>
</style>
<style name="CustomComponent.Body">
<item name="android:layout_marginTop">@dimen/material_component_vertical_padding</item>
<item name="lastBaselineToBottomHeight">20dp</item>
</style>
The text styles make up our base Typography stylesheet and override Android's TextAppearance attributes.
<style name="Body" parent="TextAppearance.MaterialComponents.Body1">
<item name="android:textColor">?android:textColorSecondary</item>
</style>
<style name="Body.Inverse" parent="Body">
<item name="android:textColor">@android:color/white</item>
</style>
<style name="Body.Sans" parent="Body">
<item name="fontFamily">@font/quattrocentosans_regular</item>
</style>
<style name="Body.Serif" parent="Body">
<item name="fontFamily">@font/merriweather_regular</item>
</style>
Using our text styles and typography style sheet, we can also define a set of composite text styles (pairings of multiple fonts, colors, ect.).
Using a layoutKey
and a textStyleKey
, we can compose our cards on the fly with a combination layout and composite text style.
{
"id": 1,
"layoutKey": "material",
"textStyleKey": "italic",
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"overline": "Birds of Paradise"
}
The isForViewType
method defines the condition when we would like to use a specific card layout.
override fun isForViewType(items: List<FeedItem>, position: Int): Boolean {
return items[position].layoutKey == "visual"
}
fun setCompositeTextAppearance(bodyTextView: TextView?, titleTextView: TextView, textStyleKey: String) {
when (textStyleKey) {
"material" -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H5)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body) }
}
"light" -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H5_Serif_Light)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body_Sans) }
}
"visual" -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H6_Sans_Bold_Inverse)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body_Sans) }
}
"typeset" -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H5_Serif)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body_Serif) }
}
"header" -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H5_Sans_Bold)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body_Sans) }
}
"italic" -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H5_SerifItalic)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body_Serif) }
}
else -> {
TextViewCompat.setTextAppearance(titleTextView, R.style.H5)
bodyTextView?.let { TextViewCompat.setTextAppearance(it, R.style.Body) }
}
}
}