中文 | English
Verses is a minimalist, industrial-grade declarative UI engine for Android RecyclerView. It brings the expressive power of Jetpack Compose DSL to the mature and stable world of RecyclerView, enabling you to build complex, high-performance lists with 80% less code.
- 🚀 Performance Peak: Built on
ListAdapterandAsyncListDifferwith optimized background diffing. - 🛡️ Industrial-Grade Safety: Instance-local factories and thread-safe ViewType generation to prevent Context leaks.
- ✨ Compose-like Syntax: Write UI, not boilerplate. No more manual
AdapterorViewHoldersubclasses. - 🧩 Extreme Flexibility: Native support for
ViewBinding, programmaticCustom Views, andcontentTypestyling. - 📦 Transparent Optimization: Context-scoped shared pools for efficient view recycling across fragments/activities.
dependencies {
implementation("io.github.woniu0936:verses:1.1.0")
}Verses provides a unified DSL to handle all your list requirements.
recyclerView.composeVerticalGrid(
spanCount = 2,
spacing = 16.dp, // Internal item spacing
contentPadding = 20.dp // Outer list padding
) {
// A. Single ViewBinding Item (Full Span)
item("header_1", ItemHeaderBinding::inflate, fullSpan = true) {
tvTitle.text = "Comprehensive Demo"
}
// B. Custom View Item (Programmatic)
item("header_2", create = { context -> MyCustomHeader(context) }) {
// 'this' is MyCustomHeader
setTitle("Section A")
}
// C. Standard List (ViewBinding) with Best Practices
items(
items = userList,
inflate = ItemUserBinding::inflate,
key = { it.id },
span = 1,
// ✅ Root Click: Use 'onClick' parameter (Zero allocation)
onClick = { user -> toast("Clicked ${user.name}") },
// ✅ Child Click: Use 'onCreate' for one-time initialization
onCreate = {
btnFollow.setOnClickListener {
val user = itemData<User>() // Lazy access data
viewModel.follow(user)
}
}
) { user ->
// onBind: Only update visual state
tvName.text = user.name
btnFollow.text = if (user.isFollowed) "Unfollow" else "Follow"
}
// D. Multi-Type rendering with logic
items(feedList, key = { it.id }) { feed ->
when (feed) {
is Banner -> render(ItemBannerBinding::inflate, fullSpan = true) {
ivBanner.load(feed.url)
}
// Use 'contentType' to differentiate styles for the same Binding class
is Ad -> render(ItemPostBinding::inflate, contentType = "ad_style") {
tvContent.text = "Sponsored: ${feed.text}"
root.setBackgroundColor(Color.YELLOW)
}
is Post -> render(
inflate = ItemPostBinding::inflate,
onClick = { toast("Post: ${feed.text}") }
) {
tvContent.text = feed.text
}
}
}
// E. Horizontal Nested List (Automatic Pool Optimization)
item("horizontal_list", ItemHorizontalListBinding::inflate, fullSpan = true) {
rvNested.composeRow(spacing = 8.dp, horizontalPadding = 16.dp) {
items(categories, key = { it.id }, inflate = ItemCategoryBinding::inflate) { cat ->
tvCategory.text = cat.name
}
}
}
}We have aligned our API naming with Jetpack Compose (removing the "Lazy" prefix) to minimize cognitive load.
| Native RecyclerView | Orientation | Verse API | Jetpack Compose Equivalent |
|---|---|---|---|
Linear (LinearLayoutManager) |
Vertical | composeColumn |
LazyColumn |
Linear (LinearLayoutManager) |
Horizontal | composeRow |
LazyRow |
Grid (GridLayoutManager) |
Vertical | composeVerticalGrid |
LazyVerticalGrid |
Grid (GridLayoutManager) |
Horizontal | composeHorizontalGrid |
LazyHorizontalGrid |
Staggered (StaggeredGridLayoutManager) |
Vertical | composeVerticalStaggeredGrid |
LazyVerticalStaggeredGrid |
Staggered (StaggeredGridLayoutManager) |
Horizontal | composeHorizontalStaggeredGrid |
LazyHorizontalStaggeredGrid |
Verses provides a robust diagnostic system to help you debug complex list behaviors and track production errors.
Initialize Verses in your Application class to enable global features.
Verses.initialize(this) {
debug(true) // Enable internal lifecycle/diff logging
logTag("MyApp") // Custom Logcat tag
logToFile(true) // Persistence for diagnostic sharing
// Production Error Telemetry
onError { throwable, message ->
// Bridge to Sentry / Crashlytics
FirebaseCrashlytics.getInstance().recordException(throwable)
}
}VersesConfig config = new VersesConfig.Builder()
.debug(true)
.logToFile(true)
.onError((throwable, msg) -> { /* Handle error */ })
.build();
Verses.initialize(context, config);When a user reports a bug, you can use the built-in utility to let them share the diagnostic log:
// Get the raw intent for maximum customization
val shareIntent = Verses.getShareLogIntent(context)
startActivity(Intent.createChooser(shareIntent, "Share Log"))
// OR use the helper provided in verses-sample:
// ShareUtils.shareLogFile(context)Verses introduces model-driven architecture and asynchronous pre-inflation to achieve 60 FPS even with extremely complex layouts.
For complex business logic that needs to be decoupled from the DSL, you can implement VerseModel directly.
class MyCustomModel(id: Any, data: MyData) : ViewBindingModel<ItemUserBinding, MyData>(id, data) {
override fun inflate(inflater: LayoutInflater, parent: ViewGroup) =
ItemUserBinding.inflate(inflater, parent, false)
override fun bind(binding: ItemUserBinding, item: MyData) {
binding.tvName.text = item.name
}
override fun getSpanSize(totalSpan: Int, position: Int) = 1
}Eliminate CreateViewHolder lag by pre-inflating views during idle time (e.g., while waiting for a network response).
// Pre-inflate 5 instances of each type to the global pool
VersePreloader.preload(
context = this,
models = listOf(
MyCustomModel("template", MyData()),
// ... other templates
),
countPerType = 5
)To enable VersePreloader for items created via DSL, you must provide the layoutRes parameter.
recyclerView.composeColumn {
items(
items = userList,
inflate = ItemUserBinding::inflate,
layoutRes = R.layout.item_user, // Required for async preload
key = { it.id }
) { user ->
tvName.text = user.name
}
}Verses automatically uses a Global Shared RecycledViewPool. This ensures that nested RecyclerViews (like horizontal lists inside a vertical list) share ViewHolders, drastically reducing memory usage and inflation overhead.
Verses automatically cleans up when the View is detached or the Activity is destroyed. To manually wipe all registries (e.g., on Logout):
VerseAdapter.clearRegistry()The onBind and onClick logic updates rely on data changes. If your data's equals returns true, the UI will not re-bind. Use data.copy() if you need to force a refresh.
Copyright 2025 Woniu0936
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

