主题
第 6 章 指令与列表 — vfor / vif / List / PageList 长列表实战
学习目标:掌握 Kuikly 的「条件渲染」和「列表渲染」两大场景;学会用
List/PageList写出 60fps 的长列表;理解列表组件的「复用机制」是怎么省内存、省 CPU 的。
6.1 两大渲染范式
┌──────────────────────────────────────────────────┐
│ "动态渲染"两件事 │
├──────────────────────────────────────────────────┤
│ │
│ 1. 条件渲染 │
│ "满足条件就显示,不满足就不显示" │
│ 例:登录态显示「我的」,未登录显示「登录」 │
│ │
│ 2. 列表渲染 │
│ "把一组数据映射成一组 UI 节点" │
│ 例:商品列表、消息流、瀑布流 │
└──────────────────────────────────────────────────┘Kuikly 用 Kotlin 的语言能力(if / forEach)天然支持这两件事,不需要额外的指令。但对于长列表,提供了专门的 List / PageList / WaterfallList 组件,性能更好。
6.2 条件渲染(vif 风格)
6.2.1 用 if 表达式
最简单:
kotlin
override fun body(): ViewBuilder {
val ctx = this
return {
if (ctx.loggedIn) {
Text { attr { text("Hi, ${ctx.nickname}") } }
} else {
View {
event { click { /* 跳转登录 */ } }
Text { attr { text("点击登录") } }
}
}
}
}📌 attr 块里任何 Kotlin 流程控制都能用 ——
if/else、when、for、while、?.let { },跟普通 Kotlin 代码无缝。
6.2.2 用 when 表达式
多分支用 when 更清晰:
kotlin
when (ctx.status) {
"loading" -> ProgressBar { attr { color(Color.GRAY) } }
"error" -> Text { attr { text("加载失败"); color(Color.RED) } }
"empty" -> Text { attr { text("暂无数据") } }
"success" -> renderContent(ctx).invoke(this)
}6.2.3 短路写法:?.let
数据存在才渲染:
kotlin
ctx.user?.let { user ->
Text { attr { text(user.name) } }
}6.2.4 vif 注意事项
| 行为 | Kuikly 处理 |
|---|---|
| 条件由 false → true | 创建组件、挂载到 BuildTree |
| 条件由 true → false | 销毁组件、从 BuildTree 移除 |
| 组件状态保留? | 不保留。条件切换 = 完全销毁重建 |
⚠️ 如果你需要"隐藏但保留状态",用
attr { visibility(false) }或attr { opacity(0f) },不要用 if 切换。
6.3 列表渲染(vfor 风格)
6.3.1 用 forEach
kotlin
val cities = listOf("北京", "上海", "广州", "深圳", "杭州")
cities.forEach { name ->
Text { attr { text(name); fontSize(14f) } }
}6.3.2 带 index 的版本
kotlin
cities.forEachIndexed { idx, name ->
Text { attr { text("${idx + 1}. $name") } }
}6.3.3 嵌套列表
kotlin
val groups = listOf("前端" to listOf("Vue", "React"), "后端" to listOf("Java", "Go"))
groups.forEach { (groupName, items) ->
Text { attr { text(groupName); fontSize(16f); fontWeightBold() } }
items.forEach { item ->
Text { attr { text(" • $item"); fontSize(14f) } }
}
}6.3.4 forEach 的局限:长列表不行
forEach 在 attr 块里执行 = 一次性创建所有子节点。10 个 ok,1000 个就完蛋:
┌─────────────────────────────────────────────────────┐
│ forEach 渲染 1000 条数据: │
├─────────────────────────────────────────────────────┤
│ - 1000 个组件全部走 BuildTree → RenderTree │
│ - 创建 1000 个原生 View 节点 │
│ - 内存占用 ~50MB+ │
│ - 首屏渲染卡 1-2 秒 │
│ - 即使滚出屏幕,依然在内存里 │
└─────────────────────────────────────────────────────┘结论:forEach 适合 <20 条 短列表(且不会快速增减)。再多必须用专门的 List。
6.4 List 组件 — 长列表性能秘诀
6.4.1 List 是什么
List 是 Kuikly 提供的「虚拟列表」组件 —— 同时具备滚动 + 复用 + 懒加载能力:
┌──────────────────────────────────────────────────────┐
│ List 的工作原理 │
├──────────────────────────────────────────────────────┤
│ │
│ 屏幕高度 600px,每个 item 80px → 一屏 7-8 个 │
│ │
│ 实际创建的原生 View 只有 ~12 个(一屏 + 缓冲区) │
│ ↑ │
│ 滚动时:上方滑出屏幕的 View 被回收,复用到下方 │
│ │
│ 数据有 10000 条?只有 12 个 View 实例! │
│ 内存占用 ~5MB(vs Scroller 的 500MB+) │
└──────────────────────────────────────────────────────┘6.4.2 基础用法
kotlin
data class Article(val id: Int, val title: String, val author: String)
private val articles = observableListOf<Article>()
override fun body(): ViewBuilder {
val ctx = this
return {
List {
attr { flex(1f) } // 占满屏幕
// ★ 用 vfor 表达式:framework 帮你管理复用
vfor({ ctx.articles }) { article ->
articleCard(article).invoke(this)
}
}
}
}
private fun articleCard(article: Article): ViewBuilder = {
View {
attr {
height(80f)
backgroundColor(Color.WHITE)
padding(16f)
marginBottom(8f)
}
Text { attr { text(article.title); fontSize(15f); fontWeightBold() } }
Text { attr { text("@${article.author}"); fontSize(12f); marginTop(4f); color(Color.GRAY) } }
}
}6.4.3 vfor 是什么
vfor 是 Kuikly 提供的「列表迭代指令」,专门给 List / WaterfallList 用,不能在普通 View 里用。
kotlin
vfor({ ctx.articles }) { item ->
// 这里描述每个 item 的 UI
}它跟 forEach 的区别:
| 维度 | forEach | vfor |
|---|---|---|
| 调用时机 | attr 块执行时(一次性) | List 滚动时按需调用 |
| 创建节点 | 全部 | 只创建可视区附近 |
| 复用机制 | 无 | 有(回收 + 复用) |
| 内存 | 跟数据量线性相关 | 跟可视区相关 |
| 用在哪 | 普通容器 | List / PageList / WaterfallList |
6.4.4 列表事件
kotlin
List {
attr { flex(1f) }
event {
scrollEnd { e ->
// 滚到底部?拉下一页
if (e.isAtBottom) loadMore()
}
dragBegin { /* 用户开始拖 */ }
dragEnd { /* 用户停止拖 */ }
}
vfor({ ctx.articles }) { /* ... */ }
}6.4.5 下拉刷新 + 上拉加载
kotlin
@Page("FeedPage")
internal class FeedPage : Pager() {
private val items = observableListOf<Item>()
private var refreshing by observable(false)
private var loadingMore by observable(false)
private var page = 1
override fun body(): ViewBuilder {
val ctx = this
return {
List {
attr { flex(1f) }
event {
scrollEnd { e ->
if (e.offsetY + e.viewSize.height >= e.contentSize.height - 100
&& !ctx.loadingMore) {
ctx.loadMore()
}
}
}
// 下拉刷新条
Refresh {
attr { refreshing(ctx.refreshing) }
event { refresh { ctx.refresh() } }
}
vfor({ ctx.items }) { item ->
itemRow(item).invoke(this)
}
// 加载更多 footer
if (ctx.loadingMore) {
View {
attr { height(50f); allCenter() }
Text { attr { text("加载中…"); color(Color.GRAY); fontSize(12f) } }
}
}
}
}
}
private fun refresh() {
refreshing = true
page = 1
// 模拟请求
items.clear()
items.addAll(fetchData(page))
refreshing = false
}
private fun loadMore() {
loadingMore = true
page++
items.addAll(fetchData(page))
loadingMore = false
}
}6.5 PageList 组件 — 整页翻动
PageList 跟 List 几乎一模一样,区别是整页对齐滚动(类似抖音上下滑):
kotlin
PageList {
attr {
flex(1f)
flexDirectionColumn() // 也可以 row(横向轮播)
pageItemSize(screen.height) // 每"页"高度 = 屏幕高
}
vfor({ ctx.videos }) { video ->
videoPlayer(video).invoke(this)
}
}应用场景:抖音 / 小红书短视频流、引导页、轮播图。
6.6 WaterfallList — 瀑布流
kotlin
WaterfallList {
attr {
flex(1f)
columnCount(2) // 2 列
columnSpacing(8f) // 列间距
rowSpacing(8f)
}
vfor({ ctx.notes }) { note ->
noteCard(note).invoke(this) // 高度可变
}
}适用:小红书首页、Pinterest 风格图库。
6.7 完整实战:消息列表
把这一章学的全用上:
kotlin
data class Message(val id: Int, val avatar: String, val name: String, val content: String, val time: String, val unread: Int)
@Page("MessagePage")
internal class MessagePage : Pager() {
private val messages = observableListOf<Message>()
private var loading by observable(true)
override fun created() {
super.created()
// 模拟加载
messages.addAll(mockMessages(50))
loading = false
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr { backgroundColor(Color(0xFFF5F5F7L)) }
// 顶栏
View {
attr {
height(44f); flexDirectionRow(); allCenter()
backgroundColor(Color.WHITE)
}
Text { attr { text("消息"); fontSize(16f); fontWeightBold() } }
}
// 内容
when {
ctx.loading -> {
View {
attr { flex(1f); allCenter() }
Text { attr { text("加载中…"); color(Color.GRAY) } }
}
}
ctx.messages.isEmpty() -> {
View {
attr { flex(1f); allCenter() }
Text { attr { text("暂无消息"); color(Color.GRAY) } }
}
}
else -> {
List {
attr { flex(1f); paddingTop(8f) }
vfor({ ctx.messages }) { msg ->
View {
attr {
flexDirectionRow()
padding(12f)
backgroundColor(Color.WHITE)
marginBottom(1f)
}
event {
click { /* 进入详情 */ }
}
Image {
attr {
src(msg.avatar)
size(48f, 48f)
borderRadius(24f)
backgroundColor(Color(0xFFE0E0E0L))
marginRight(12f)
}
}
View {
attr { flex(1f); justifyContentCenter() }
View {
attr { flexDirectionRow(); justifyContentSpaceBetween() }
Text { attr { text(msg.name); fontSize(15f); fontWeightBold() } }
Text { attr { text(msg.time); fontSize(11f); color(Color.GRAY) } }
}
Text {
attr {
text(msg.content); fontSize(13f); color(Color.GRAY)
marginTop(4f); numberOfLines(1); textOverFlowEllipsis()
}
}
}
if (msg.unread > 0) {
View {
attr {
minWidth(18f); height(18f); allCenter()
paddingHorizontal(6f); borderRadius(9f)
backgroundColor(Color.RED); marginLeft(8f)
}
Text {
attr {
text(if (msg.unread > 99) "99+" else "${msg.unread}")
fontSize(10f); color(Color.WHITE)
}
}
}
}
}
}
}
}
}
}
}
}6.8 列表性能优化 5 条军规
┌──────────────────────────────────────────────────────┐
│ 长列表性能黄金法则 │
├──────────────────────────────────────────────────────┤
│ │
│ 1. >20 条数据 → 必用 List / PageList / WaterfallList │
│ 2. item UI 抽成独立 ViewBuilder 函数 │
│ 3. item 高度尽量固定(变高度更慢) │
│ 4. 复杂 item 用「平铺布局」而非深层嵌套 │
│ 5. 图片用 Image 组件(自带懒加载 + 缓存) │
│ │
└──────────────────────────────────────────────────────┘6.8.1 反例:嵌套太深
kotlin
// ❌ 不好:4 层嵌套,每个 item 创建 10+ 节点
View {
View {
View {
View {
Text { ... }
}
}
}
}
// ✅ 好:扁平化,每个 item 创建 4-5 节点
View {
attr { flexDirectionRow() }
Image { ... }
View { Text { ... }; Text { ... } }
}6.8.2 不要在 item 里用 forEach 循环创建子组件
kotlin
// ❌ 不好:每个 item 又有 forEach,复用复杂
List {
vfor({ groups }) { group ->
View {
group.items.forEach { item -> // ← item 内部又一个循环
Text { attr { text(item) } }
}
}
}
}
// ✅ 好:拆成两层 List
List {
vfor({ flatItems }) { item -> // 提前把数据拍平
Text { attr { text(item) } }
}
}6.8.3 关注 vfor 的 key(如果版本支持)
部分版本 vfor 支持 key 参数,告诉框架「哪两个 item 是"同一个"」,复用更精确:
kotlin
vfor({ ctx.items }, key = { it.id }) { item ->
// ...
}📌 用 stable id 做 key,避免 list 顺序调整时大量 item 重建。这跟 React 的
key概念一样。
6.9 章末小结
★ 第 6 章渲染知识图谱 ★
│
┌─────────────────────┼────────────────────┐
│ │ │
┌────▼─────┐ ┌──────▼───────┐ ┌─────▼──────┐
│ 条件渲染 │ │ 短列表 │ │ 长列表 │
├──────────┤ ├──────────────┤ ├────────────┤
│ if / when│ │ forEach │ │ List │
│ ?.let │ │ <20 条 │ │ vfor │
│ visibility│ │ 全部创建 │ │ 复用机制 │
└──────────┘ └──────────────┘ └────────────┘
│
┌────────────────┼─────────────┐
│ │ │
┌─────▼─────┐ ┌────▼─────┐ ┌────▼──────┐
│ List │ │ PageList │ │WaterfallList│
│ 普通滚动 │ │ 整页翻动 │ │ 瀑布流 │
└───────────┘ └──────────┘ └────────────┘🎤 6.10 章末面试题(10 道高频题)
Q1. Kuikly 怎么做条件渲染?跟 Vue 的 v-if 有什么区别?
答:直接用 Kotlin 的 if/when/?.let:
kotlin
if (ctx.loggedIn) Text { ... } else Button { ... }
when (ctx.status) { ... }
ctx.user?.let { Text { ... } }跟 Vue v-if 区别:v-if 是 Vue 模板语法的「指令」,需要框架解析;Kuikly 直接用 Kotlin 语言能力,没有自定义指令,更原生 / 更灵活。
Q2. forEach 跟 vfor 的本质区别是什么?
答:执行时机和复用机制:
| 维度 | forEach | vfor |
|---|---|---|
| 调用时机 | attr 块执行时(一次性) | List 按需(滚动时) |
| 创建节点 | 全部 | 仅可视区 |
| 复用 | ❌ | ✅ |
forEach 是 Kotlin 标准库方法,跟 Kuikly 无关;vfor 是 Kuikly 内置的迭代指令,专门给虚拟列表用。
Q3. 长列表为什么不能用 Scroller + forEach?
答:因为 没有复用。Scroller 一次性挂载所有子组件,1000 条 = 1000 个原生 View。这会导致:
- 首屏渲染慢(创建大量 View)
- 内存占用大(每个 View 占几 KB - 几十 KB)
- 滚动卡顿(每帧都要重新计算上千个节点的位置)
正确做法:用 List + vfor,框架会根据可视区做「只创建当前可见 ± 缓冲区,离开就回收」。
Q4. List 的复用机制是怎么实现的?
答:核心思路是「View 池(Recycler Pool)」:
- 用户向下滚 → 顶部 item 滑出可视区 → 该 item 的原生 View 加入回收池
- 底部要展示新 item → 从回收池里取一个 View → 调 vfor 闭包获取新 item 数据 → 把 View 的属性更新成新数据
这跟 Android 的 RecyclerView、iOS 的 UITableView 思路完全一致。核心收益:N 条数据只需要 ~10 个 View 实例。
Q5. 用 if 隐藏组件会保留它的状态吗?
答:不会。if (false) 时组件被完全销毁,内部所有 observable 字段、动画状态、滚动位置全部丢失。
如果想保留状态,用 attr { visibility(false) } 或 attr { opacity(0f) },组件依然挂载,只是不可见。
Q6. 下拉刷新和上拉加载怎么写?
答:
- 下拉刷新:用
Refresh子组件,绑定refreshing状态 +refresh事件 - 上拉加载:监听 List 的
scrollEnd事件,判断是否到底部,是就触发加载
详细代码见 6.4.5。
Q7. PageList 跟 List 有啥区别?
答:
- List:自由滚动,停在哪算哪
- PageList:每"页"对齐(自动停到 item 边界),常用作抖音上下滑、引导页、轮播图
PageList 实际就是 List + pagingEnable(true) + pageItemSize(...) 的预设配置。
Q8. 瀑布流(小红书首页那种 2 列)怎么写?
答:用 WaterfallList:
kotlin
WaterfallList {
attr { columnCount(2); columnSpacing(8f); rowSpacing(8f) }
vfor({ ctx.notes }) { note -> noteCard(note).invoke(this) }
}它的复用机制跟 List 一样,但布局算法是"贪心填短列"。
Q9. List 的 item 高度可以变吗?
答:可以。Kuikly 的 List 支持「异构 item」(不同 item 不同高度),框架会按需测量。
但性能上:高度可变 < 高度固定。如果你能预先知道 item 高度(比如固定 80px 的列表),首屏会快不少。
Q10. vfor 的 key 是干什么的?
答:告诉框架「哪两个 item 是同一个对象」,这样在数据增删时能精准复用 / 销毁,而不是粗暴地按位置匹配。
kotlin
vfor({ ctx.items }, key = { it.id }) { item -> ... }跟 React 的 key 一个意思:
- 没 key:列表插一个到中间 → 后面所有 item 重新创建
- 有 stable key:插一个到中间 → 只创建那一个,其他 item 原地复用
下一站 → 第 7 章 · 跨端架构原理 KMP + 两棵树 + Bridge →
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
kotlin
/**
* 第 6 章配套代码 · 消息列表(List + vfor + 下拉刷新 + 上拉加载)
*
* 文件位置:shared/src/commonMain/kotlin/com/example/kuikly/pages/MessagePage.kt
*
* 本示例展示长列表的"标配能力":
* - List + vfor 长列表复用机制
* - Refresh 下拉刷新
* - scrollEnd 上拉加载更多
* - 状态机 loading/empty/success 三态切换
*/
package com.example.kuikly.pages
import com.tencent.kuikly.core.annotations.Page
import com.tencent.kuikly.core.base.Color
import com.tencent.kuikly.core.base.ViewBuilder
import com.tencent.kuikly.core.directives.vfor
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.reactive.collection.observableListOf
import com.tencent.kuikly.core.reactive.handler.observable
import com.tencent.kuikly.core.views.Image
import com.tencent.kuikly.core.views.List
import com.tencent.kuikly.core.views.Refresh
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
data class Message(
val id: Int,
val avatar: String,
val name: String,
val content: String,
val time: String,
val unread: Int,
)
@Page("MessagePage")
internal class MessagePage : Pager() {
private val messages = observableListOf<Message>()
private var loading by observable(true)
private var refreshing by observable(false)
private var loadingMore by observable(false)
private var page = 1
private var nextId = 1
override fun created() {
super.created()
// 模拟首屏加载
messages.addAll(mockData(page = 1, count = 20))
loading = false
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr { backgroundColor(Color(0xFFF5F5F7L)) }
// ─── 顶栏 ─────────────────────────
View {
attr {
height(48f)
flexDirectionRow()
allCenter()
backgroundColor(Color.WHITE)
}
Text {
attr {
text("消息")
fontSize(17f)
fontWeightBold()
color(Color.BLACK)
}
}
}
// ─── 内容区:状态机切换 ──────────
when {
ctx.loading -> renderLoading().invoke(this)
ctx.messages.isEmpty() -> renderEmpty().invoke(this)
else -> renderList(ctx).invoke(this)
}
}
}
/** ── 状态 1:加载中 ── */
private fun renderLoading(): ViewBuilder = {
View {
attr { flex(1f); allCenter() }
Text {
attr {
text("⏳ 加载中…")
fontSize(14f)
color(Color(0xFF9E9E9EL))
}
}
}
}
/** ── 状态 2:空数据 ── */
private fun renderEmpty(): ViewBuilder = {
View {
attr { flex(1f); allCenter() }
Text {
attr {
text("📭")
fontSize(40f)
marginBottom(8f)
}
}
Text {
attr {
text("暂无消息")
fontSize(14f)
color(Color(0xFF9E9E9EL))
}
}
}
}
/** ── 状态 3:列表 ── */
private fun renderList(ctx: MessagePage): ViewBuilder = {
List {
attr {
flex(1f)
paddingTop(8f)
}
event {
scrollEnd { e ->
// 触底判断
val nearBottom = e.offsetY + e.viewSize.height >=
e.contentSize.height - 100f
if (nearBottom && !ctx.loadingMore) {
ctx.loadMore()
}
}
}
// ★ 下拉刷新 header
Refresh {
attr { refreshing(ctx.refreshing) }
event { refresh { ctx.refresh() } }
Text {
attr {
text(if (ctx.refreshing) "刷新中…" else "↓ 下拉刷新")
fontSize(12f)
color(Color(0xFF9E9E9EL))
textAlignCenter()
paddingVertical(12f)
}
}
}
// ★ vfor 渲染列表
vfor({ ctx.messages }) { msg ->
renderMessageRow(msg).invoke(this)
}
// ★ 加载更多 footer
if (ctx.loadingMore) {
View {
attr {
height(50f)
allCenter()
}
Text {
attr {
text("加载更多…")
color(Color(0xFF9E9E9EL))
fontSize(12f)
}
}
}
}
}
}
/** ── 单条消息行(抽出来便于复用 + 阅读)── */
private fun renderMessageRow(msg: Message): ViewBuilder = {
View {
attr {
flexDirectionRow()
padding(12f)
backgroundColor(Color.WHITE)
marginBottom(1f) // 1px 分割线效果
}
event { click { /* TODO 进入聊天详情 */ } }
// 头像
Image {
attr {
src(msg.avatar)
size(48f, 48f)
borderRadius(24f)
backgroundColor(Color(0xFFE0E0E0L))
marginRight(12f)
}
}
// 中间:名字 + 内容
View {
attr { flex(1f); justifyContentCenter() }
View {
attr {
flexDirectionRow()
justifyContentSpaceBetween()
alignItemsCenter()
}
Text {
attr {
text(msg.name)
fontSize(15f)
fontWeightBold()
color(Color.BLACK)
}
}
Text {
attr {
text(msg.time)
fontSize(11f)
color(Color(0xFF9E9E9EL))
}
}
}
Text {
attr {
text(msg.content)
fontSize(13f)
color(Color(0xFF757575L))
marginTop(4f)
numberOfLines(1)
textOverFlowEllipsis()
}
}
}
// 未读红点
if (msg.unread > 0) {
View {
attr {
minWidth(18f)
height(18f)
allCenter()
paddingHorizontal(6f)
borderRadius(9f)
backgroundColor(Color(0xFFFF3B30L))
marginLeft(8f)
alignSelfCenter()
}
Text {
attr {
text(if (msg.unread > 99) "99+" else "${msg.unread}")
fontSize(10f)
color(Color.WHITE)
fontWeightBold()
}
}
}
}
}
}
// ─── 业务方法 ───────────────────────
private fun refresh() {
refreshing = true
page = 1
nextId = 1
messages.clear()
messages.addAll(mockData(page = 1, count = 20))
refreshing = false
}
private fun loadMore() {
loadingMore = true
page++
messages.addAll(mockData(page = page, count = 20))
loadingMore = false
}
private fun mockData(page: Int, count: Int): List<Message> {
return (1..count).map { i ->
Message(
id = nextId++,
avatar = "https://example.com/avatar/$i.png",
name = "用户$nextId",
content = "第 $page 页的第 $i 条消息内容…",
time = "${(11 + i % 12)}:${"%02d".format(i % 60)}",
unread = if (i % 5 == 0) i % 100 else 0,
)
}
}
}kotlin
/**
* 第 6 章配套代码 · 瀑布流(小红书风格 2 列)
*
* 文件位置:shared/src/commonMain/kotlin/com/example/kuikly/pages/WaterfallNotesPage.kt
*
* 演示 WaterfallList 用法 + 高度可变的 item。
*/
package com.example.kuikly.pages
import com.tencent.kuikly.core.annotations.Page
import com.tencent.kuikly.core.base.Color
import com.tencent.kuikly.core.base.ViewBuilder
import com.tencent.kuikly.core.directives.vfor
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.reactive.collection.observableListOf
import com.tencent.kuikly.core.views.Image
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
import com.tencent.kuikly.core.views.WaterfallList
data class Note(
val id: Int,
val coverUrl: String,
val coverHeight: Float, // 不同图片高度不同 → 瀑布流效果
val title: String,
val author: String,
val likes: Int,
)
@Page("WaterfallNotesPage")
internal class WaterfallNotesPage : Pager() {
private val notes = observableListOf<Note>()
override fun created() {
super.created()
// 模拟数据:每个 note 高度 100~300 之间随机
repeat(30) { i ->
notes.add(
Note(
id = i + 1,
coverUrl = "https://example.com/note/${i + 1}.jpg",
coverHeight = (100..300).random().toFloat(),
title = "笔记 #${i + 1} · " + listOf("旅行", "美食", "穿搭", "健身", "读书").random(),
author = "用户${i + 1}",
likes = (10..9999).random(),
)
)
}
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr { backgroundColor(Color(0xFFF5F5F7L)) }
// 顶栏
View {
attr {
height(48f); flexDirectionRow(); allCenter()
backgroundColor(Color.WHITE)
}
Text {
attr {
text("发现")
fontSize(17f)
fontWeightBold()
}
}
}
// 瀑布流
WaterfallList {
attr {
flex(1f)
columnCount(2) // 2 列
columnSpacing(8f) // 列间距
rowSpacing(8f) // 行间距
paddingHorizontal(8f)
paddingVertical(8f)
}
vfor({ ctx.notes }) { note ->
renderNoteCard(note).invoke(this)
}
}
}
}
/** 单个笔记卡片:高度可变(封面图高度不同) */
private fun renderNoteCard(note: Note): ViewBuilder = {
View {
attr {
backgroundColor(Color.WHITE)
borderRadius(8f)
}
event { click { /* TODO 进入详情 */ } }
// 封面图(高度可变 → 瀑布流的"灵魂")
Image {
attr {
src(note.coverUrl)
width(100f)
height(note.coverHeight)
backgroundColor(Color(0xFFE0E0E0L))
borderRadius(8f)
}
}
// 标题
Text {
attr {
text(note.title)
fontSize(13f)
color(Color.BLACK)
padding(8f)
numberOfLines(2)
lineHeight(18f)
}
}
// 作者 + 点赞
View {
attr {
flexDirectionRow()
alignItemsCenter()
paddingHorizontal(8f)
paddingBottom(8f)
justifyContentSpaceBetween()
}
Text {
attr {
text(note.author)
fontSize(11f)
color(Color(0xFF9E9E9EL))
}
}
View {
attr { flexDirectionRow(); alignItemsCenter() }
Text {
attr {
text("♡")
fontSize(11f)
color(Color(0xFF9E9E9EL))
marginRight(2f)
}
}
Text {
attr {
text("${note.likes}")
fontSize(11f)
color(Color(0xFF9E9E9EL))
}
}
}
}
}
}
}