[KtJC] KTJC 再探,数据绑定与常用 UI

Posted by rarnu on 12-18,2019

上一篇讲了 KtJC 的入门(点此进入),这一篇就讲一些好玩的吧,毕竟真要上项目了就完全不是那些 Demo 里面搞的东西了,虽然官方一再强调千万不要用来做为生产用 :(

一、ListView

可能你直接就发现了,在 JC 里面是没有 ListView 的,要实现的话只能自己去搭,先套一个垂直滚动容器,再套一个 Column,然后再按列表内容来套一大堆的声明,代码类似于这样:

VerticalScroller {
    Column {
        (0 until 20).forEachIndexed { index, _ ->
            FlexRow(crossAxisAlignment = CrossAxisAlignment.Center) {
                expanded(1.0f) {
                    Text("Item $itemIndex")
                }
                inflexible {
                    Button(
                        "Button $itemIndex",
                        style = ContainedButtonStyle(),
                        onClick = { }
                    )
                }
            }
            Divider(color = Color.Blue, height = 1.dp) 
        }
    }
}

总感觉很怪,嵌套层次过深,我希望将它写得更简单并且好理解一些,如:

@Composable
fun ListView(
    itemCount: Int,
    children: @Composable() (index: Int) -> Unit) {
    VerticalScroller {
        Column {
            for (index in 0 until itemCount) {
                children(index)
            }
        }
    }
}

这样我就可以直接使用 ListView 组件了:

ListView(itemCount = 20) { index ->
    FlexRow(crossAxisAlignment = CrossAxisAlignment.Center) {
        expanded(1.0f) {
            Text("Item $index")
        }
        inflexible {
            Button("Button $index",
                style = ContainedButtonStyle(),
                onClick = { }
            )
        }
    }               
    Divider(color = Color.LightGray, height = 1.dp)
}

二、数据绑定

在上面的代码中,用了一个循环来代替真实的数据列表,我们也可以很轻松的将真实数据绑定上去。在 JC 中已经提供了简便的数据绑定方案:

@Model
class State(
    val list: MutableList<String> = mutableListOf()
)
val state = State(list = mutableListOf(
    "a", "b","c","d","e","f","g"
))

看出来了没,只要把数据标识为 @Model,然后在界面的任意地方使用它,都可以实现数据绑定,当数据被修改时,界面也会自动刷新。

所以现在可以把上面的 ListView 代码改成这样了:

ListView(itemCount = state.list.size) { index ->
    FlexRow(crossAxisAlignment = CrossAxisAlignment.Center) {
        expanded(1.0f) {
            Text("Item ${state.list[index]}")
        }
        inflexible {
            Button("Button ${state.list[index]}",
                style = ContainedButtonStyle(),
                onClick = { }
            )
        }
    }               
    Divider(color = Color.LightGray, height = 1.dp)
}

三、GridView

之前就说了没有 ListView,一试之下果然也没有 GridView,查一了圈文档发现只能用 Table 来实现,非常的蛋疼,代码像这样:

Table(columns = 8) {
    repeat(8) { i ->
        tableRow {
            repeat(8) { j ->
                // Cell
                Text("${i * 8 + j}")
            }
        }
    }
}

可以看到,其中还必须使用 tableRow 来标识出每一行,这个实现与 Android 原生的 GridView 差得太远了!

所以也必须找一个办法来解决之,所幸知道以上代码后,要搞定并不难。

首先我们需要一个将 itemCount 转换为二维数组的方法,即需要知道一个 itemCount 可以被渲染成多少行,以及每一行内有多少列:

fun Int.toGridData(columns: Int = 1) = mutableListOf<List<Int>>().apply {
    var count = 0
    var sub = mutableListOf<Int>()
    for (item in 0 until this@toGridData) {
        if (count == columns) {
            add(sub.toList())
            sub = mutableListOf()
            sub.add(item)
            count = 1
            continue
        }
        sub.add(item)
        count++
    }
    add(sub.toList())
}.toList()

随手写一下就这样吧,最终得到一个二维数组。然后就可以完成 GridView 了:

@Composable
fun GridView(
    columns: Int,
    itemCount: Int,
    alignment: (columnIndex: Int) -> Alignment = { Alignment.TopLeft },
    columnWidth: (columnIndex: Int) -> TableColumnWidth = { TableColumnWidth.Flex(1f) },
    children: @Composable() (index: Int) -> Unit) {

    VerticalScroller {
        Table(columns = columns, alignment = alignment, columnWidth = columnWidth) {
            val gridIndex = itemCount.toGridData(columns)
            gridIndex.forEach { list ->
                tableRow {
                    list.forEach { index ->
                        children(index)
                    }
                }
            }
        }
    }
}

此时要生成一个 GridView 就变得简单了:

GridView(columns = 3, itemCount = state.list.size) { index ->                    
    Text(text = state.list[index])                
}

四、组件位于 Activity 底部

到目前为止,还没有找到如何使组件对于页面做底部对齐的方法,唯有自己计算,计算方法如下:

@Composable
fun sampleUI(ctx: Context?, safeHeight: Dp = Dp(0f)) {
    val hhDp = if (safeHeight == Dp(0f)) { ctx?.safeHeightDp() ?: Dp(0f) } else safeHeight
    MaterialTheme {
        Column {
            Container(height = 40.dp, alignment = Alignment.CenterLeft, expanded = true) {
                Text(text = "times: ${state.count}")
            }
            // 这个 Container 撑满剩余空间
            Container(height = hhDp - 80.dp, expanded = true) {
            }
            // 这个 Container 底部对齐
            Container(height = 40.dp, alignment = Alignment.BottomCenter, expanded = true) {
                Button(text = "Click",
                    onClick = {
                        ctx?.toast("${state.itemIndex}", dark = false)
                    }
                )
            }
        }
    }
}

关键来了,这个 safeHeightDp 怎么来的呢?

fun Context.safeHeightDp(): Dp {
    val nav = if (hasNavigationBar()) navigationBarHeight() else 0
    val space = UI.height - actionBarHeight() - statusBarHeight() - nav
    return Dp(space.px2dip())
}

由此我们就可以算出用于撑开空间的 Container 有多高了。

对于这类 Activity,JC 无法在预览时就计算出高度,只能手动传一个,否则高度会变 0,经过实际测试,预览界面的 safeHeight 值为 603.dp,一个挺奇怪的数字,记住就好了,这样就可以正常的预览界面了:

@Preview
@Composable
fun samplePreview() {
    sampleUI(null, safeHeight = 603.dp)
}

简单探了一些,下面该探的可能就是下刷上滑刷新这类的实现了,要实际用于项目还需要很多的储备,慢慢探完吧。本篇先到此结束了 :)