Tạo Components Tùy Chỉnh trong Jetpack Compose
Mục lục- I. Quá trình Render Components
- 1.1 Kết hợp
- 1.2 Bố cục
- 1.3 Vẽ
- II. Kết hợp Tùy chỉnh
- III. Bố cục Tùy chỉnh
- 3.1 LayoutModifier (Tùy chỉnh View)
- 3.2 Layout (Tùy chỉnh ViewGroup)
- 3.3 Đo lường Đặc tính Nội tại
- 3.3.1 Sử dụng đo lường đặc tính nội tại của components có sẵn
- 3.3.2 Tùy chỉnh đo lường đặc tính nội tại
- 3.4 SubcomposeLayout
- IV. Vẽ Tùy chỉnh
- 4.1 Canvas Composable
- 4.2 DrawModifier
- 4.2.1 drawWithContent
- 4.2.2 drawBehind
- 4.2.3 drawWithCache
- 4.3 Tương thích với Native
I. QUÁ TRÌNH RENDER COMPONENTS
Trong hệ thống View truyền thống, quá trình render view có thể chia thành ba bước: Đo lường, Bố cục, Vẽ. Trong Compose, quá trình render component cũng có thể chia thành ba bước: Kết hợp, Bố cục, Vẽ.
- Kết hợp: Thực thi thân hàm Composable, tạo và duy trì cây LayoutNode.
- Bố cục: Đo lường kích thước rộng cao cho mỗi LayoutNode trong cây và hoàn thành việc sắp xếp vị trí.
- Vẽ: Vẽ tất cả LayoutNode lên màn hình thực. Đối với các component thông thường, quá trình tạo khung hình trải qua ba giai đoạn Kết hợp -> Bố cục -> Vẽ, tất nhiên cũng có những trường hợp ngoại lệ như LazyColumn, LazyRow, việc kết hợp các phần tử con của các component này có thể bị trì hoãn đến giai đoạn bố cục của chính component đó, là do việc kết hợp các phần tử con của các component này phụ thuộc vào một số thông tin mà component có thể cung cấp trong giai đoạn bố cục.
1.1 KẾT HỢP
Mục tiêu chính của giai đoạn kết hợp là tạo và duy trì cây LayoutNode. Khi chúng ta sử dụng setContent trong Activity, quá trình kết hợp đầu tiên sẽ bắt đầu, lúc này thân của tất cả các hàm Composable trong khối mã sẽ được thực thi, tạo ra cây LayoutNode tương ứng. Tương ứng với hệ thống View truyền thống, cây View cũng được xây dựng lần đầu trong setContentView. Trong Compose, nếu Composable phụ thuộc vào một trạng thái có thể thay đổi, khi trạng thái đó được cập nhật, Composable hiện tại sẽ được kích hoạt để thực hiện lại giai đoạn kết hợp, do đó cũng được gọi là tái kết hợp. Khi một component tái kết hợp, các Composable con sẽ được gọi lại lần lượt:
- Composable con được gọi sẽ so sánh các tham số hiện tại với tham số trong lần tái kết hợp trước đó. Nếu tham số thay đổi, hàm Composable sẽ tái kết hợp, cập nhật nút tương ứng trong cây LayoutNode, UI được cập nhật.
- Composable con được gọi, sau khi so sánh tham số, nếu không có thay đổi nào, sẽ bỏ qua lần thực thi hiện tại, được gọi là tái kết hợp thông minh. Nút tương ứng trong cây LayoutNode không thay đổi, UI không thay đổi.
- Nếu Composable con không được gọi trong lần tái kết hợp, nút tương ứng và các nút con của nó sẽ bị xóa khỏi cây LayoutNode, UI bị loại bỏ khỏi màn hình. Ngược lại, khi thêm mới cũng tương tự. Tóm lại, quá trình tái kết hợp có thể tự động duy trì cây LayoutNode, luôn giữ nó ở trạng thái view mới nhất. Trong khi đó, trong hệ thống View truyền thống, chúng ta chỉ có thể thao tác thủ công thêm/xóa ViewGroup để duy trì cây View, đây là sự khác biệt cơ bản giữa hai hệ thống view. Lưu ý: Việc Composable con có bỏ qua tái kết hợp hay không không chỉ phụ thuộc vào việc tham số có thay đổi mà còn phụ thuộc vào loại tham số có phải là Stable hay không.
1.2 BỐ CỤC
Giai đoạn bố cục được sử dụng để đo lường kích thước rộng cao và sắp xếp vị trí cho mỗi LayoutNode trong cây view. Khi các component tích hợp của Compose không đáp ứng được nhu cầu của chúng ta, chúng ta có thể thực hiện component đáp ứng nhu cầu của mình trong giai đoạn bố cục của component tùy chỉnh. Trong compose, mỗi LayoutNode sẽ tự đo lường (tương tự như MeasureSpec trong View truyền thống) dựa trên ràng buộc bố cục từ LayoutNode cha. Ràng buộc bố cục chứa chiều rộng và chiều cao tối đa và tối thiểu mà LayoutNode cha cho phép LayoutNode con. Khi LayoutNode cha muốn LayoutNode con đo lường với một giá trị cụ thể nào đó, giá trị tối đa và tối thiểu trong ràng buộc sẽ giống nhau. LayoutNode không được phép đo lường nhiều lần, trong Compose việc đo lường nhiều lần sẽ ném ra ngoại lệ. Cần lưu ý rằng một số trường hợp nhu cầu cần đo lường LayoutNode nhiều lần, Compose đã cung cấp cho chúng ta giải pháp là đo lường đặc tính nội tại và SubcomposeLayout.
1.3 VẼ
Giai đoạn vẽ chủ yếu là vẽ tất cả LayoutNode lên màn hình, cũng có thể tùy chỉnh giai đoạn vẽ này.
Hiểu được các quy trình trên, chúng ta có thể bắt đầu tùy chỉnh ba quy trình render này để tạo components Composable tùy chỉnh.
II. KẾT HỢP TÙY CHỈNH
Việc tùy chỉnh trong giai đoạn kết hợp là dễ hiểu nhất, thực chất đó là đóng gói Composable. Chúng ta biết rằng component Composable thực chất là một phương thức. Chúng ta đóng gói các phương thức khác nhau lại với nhau theo nhu cầu của mình để tiện sử dụng, đó là quá trình đó.
Một vài ví dụ: Hãy cùng thực hiện hiệu ứng sau: Biểu tượng ứng dụng, khi có tin nhắn chưa đọc, góc trên bên phải sẽ hiển thị một chấm nhỏ màu đỏ và hiển thị số lượng tin nhắn chưa đọc, khi không có tin nhắn chưa đọc thì không hiển thị chấm đỏ. Dưới đây là cách thực hiện nhu cầu này:
@Composable
fun IconWithNotification(iconResId: Int, unreadCount: Int) {
Box(modifier = Modifier.size(60.dp)) {
Image(
painter = painterResource(id = iconResId),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.size(50.dp)
.align(Alignment.Center)
)
// Tùy thuộc vào trạng thái để kết hợp các component khác nhau
if (unreadCount > 0) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(Color.Red)
.size(25.dp)
.align(Alignment.TopEnd)
) {
Text(
text = "$unreadCount",
color = Color.White,
modifier = Modifier
.wrapContentSize()
.align(Alignment.Center)
)
}
}
}
}
@Composable
fun DemoScreen() {
Box(contentAlignment = Alignment.Center) {
var messageCount by remember {
mutableIntStateOf(0)
}
Column {
IconWithNotification(R.mipmap.rc_3, messageCount)
Spacer(modifier = Modifier.height(20.dp))
Row {
Button(onClick = { messageCount = 0 }) {
Text(text = "Đánh dấu đã đọc")
}
Spacer(modifier = Modifier.width(10.dp))
Button(onClick = { messageCount++ }) {
Text(text = "Thêm tin nhắn")
}
}
}
}
}
Hiệu ứng như sau:
Component phụ thuộc vào trạng thái messageCount, khi messageCount khác 0, sẽ kết hợp Composable mới vào. Đây cũng chính là tư tưởng quan trọng của Jetpack Compose.
Nói một cách rộng rãi, mọi Composable có thể tái sử dụng mà bạn đóng gói đều có thể coi là Composable tùy chỉnh. Và về thời điểm gọi nó, đó chính là trong giai đoạn kết hợp.
III. BỐ CỤC TÙY CHỈNH
3.1 LayoutModifier (Tùy chỉnh View)
fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
// measurable: component con cần đo lường và đặt vị trí
// constraints: giới hạn tối thiểu và tối đa cho chiều rộng và chiều cao của component con
...
// đo lường component con
val placeable = measurable.measure(constraints)
// đặt chiều rộng và chiều cao của view
layout(width, height) {
...
// đặt vị trí hiển thị của component con
placeable.placeRelative(0, 0)
}
}
Ví dụ: Thực hiện chức năng padding Firstbaseline:
@Composable
fun Modifier.firstBaselineTop(firstBaselineToTop: Dp) = this.then(
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
// Tính toán giá trị padding cần đặt và hiệu suất với firstBaseline gốc
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
// Chiều cao của View (placeable.height là chiều cao gốc của view, bao gồm padding)
val height = placeable.height + placeableY
layout(placeable.width, height) {
placeable.placeRelative(
0, placeableY
)
}
}
)
Kế thừa từ LayoutModifier, ví dụ như Modifier.padding.
private class PaddingModifier(
val start: Dp = 0.dp,
val top: Dp = 0.dp,
val end: Dp = 0.dp,
val bottom: Dp = 0.dp,
val rtlAware: Boolean,
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val horizontal = start.roundToPx() + end.roundToPx()
val vertical = top.roundToPx() + bottom.roundToPx()
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
if (rtlAware) {
placeable.placeRelative(start.roundToPx(), top.roundToPx())
} else {
placeable.place(start.roundToPx(), top.roundToPx())
}
}
}
}
3.2 Layout (Tùy chỉnh ViewGroup)
@Composable
fun CustomColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// Không giới hạn thêm các component con, đo lường chúng với các ràng buộc đã cho
// Danh sách các component con đã đo lường
val placeables = measurables.map { measurable ->
// Đo lường mỗi component con
measurable.measure(constraints)
}
// Vị trí y của component con
var yPosition = 0
// Đặt kích thước của bố cục lớn nhất có thể
layout(constraints.maxWidth, constraints.maxHeight) {
// Đặt các component con trong bố cục cha
placeables.forEach { placeable ->
// Định vị component trên màn hình
placeable.placeRelative(x = 0, y = yPosition)
// Cộng dồn chiều cao của view hiện tại
yPosition += placeable.height
}
}
}
}
3.3 ĐO LƯỜNG ĐẶC TÍNH NỘI TẠI
Trước đó chúng ta đã đề cập đến nguyên lý bố cục của Compose, trong Compose mỗi LayoutNode không được phép đo lường nhiều lần, việc đo lường nhiều lần sẽ ném ra ngoại lệ khi chạy, nhưng trong nhiều trường hợp việc đo lường nhiều lần component con UI là có ý nghĩa. Giả sử có một yêu cầu như sau, mong muốn đường phân chia ở giữa có chiều cao bằng với một bên của văn bản hai bên.
Để thực hiện yêu cầu này, giả sử chúng ta có thể đo lường trước để có được thông tin chiều cao của các component văn bản hai bên, lấy giá trị lớn nhất làm chiều cao của component hiện tại, lúc này chỉ cần đặt chiều cao của đường phân chia để lấp đầy component cha là được.
Đo lường đặc tính nội tại cung cấp cho chúng khả năng đo lường trước tất cả các component con để xác định ràng buộc (constraints) của chính nó, và ảnh hưởng đến quá trình đo lường của component con trong giai đoạn đo lường chính thức.
3.3.1 SỬ DỤNG ĐO LƯỜNG ĐẶC TÍNH NỘI TẠI CỦA COMPONENTS CÓ SẴN
Điều kiện tiên quyết để sử dụng đo lường đặc tính nội tại là component cần hỗ trợ đo lường đặc tính nội tại, hiện tại nhiều component tích hợp đã triển khai đo lường đặc tính nội tại và có thể sử dụng trực tiếp. Hầu hết các component tích hợp đều được triển khai bằng LayoutComposable, LayoutComposable cần truyền vào một measurePolicy, mặc định chỉ cần triển khai measure, nhưng nếu muốn triển khai đo lường đặc tính nội tại, cần thêm việc ghi đè các phương thức Intrinsic series. Khả năng mà component cha cung cấp có thể được đảm nhận bởi component Row tích hợp, chỉ cần đặt chiều cao cho Row component là có thể sử dụng được. Sử dụng Modifier.height(IntrinsicSize.Min) có thể đặt chiều cao cho đo lường đặc tính nội tại.
@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
Row(modifier = modifier.height(IntrinsicSize.Min)) { // ở đây đã sử dụng đo lường đặc tính nội tại
Text(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.wrapContentWidth(Alignment.Start),
text = text1,
fontSize = 16.sp // kích thước font khác nhau, chiều cao khác nhau
)
Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp)) // chiều cao bằng chiều cao tối đa của bố cục cha
Text(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2,
fontSize = 30.sp // kích thước font khác nhau, chiều cao khác nhau
)
}
}
// sử dụng
Surface(border = BorderStroke(1.dp, Color.Blue) ) {
TwoTexts(text1 = "Xin chào Kotlin", text2 = "Hello World")
}
Điều đáng chú ý là ở đây chỉ sử dụng Modifier.height(IntrinsicSize.Min) để đặt chiều cao cho đo lường đặc tính nội tại, chiều rộng chưa được đặt, điều này có nghĩa là khi chiều rộng không bị giới hạn, sẽ tính toán giá trị tối thiểu cho chiều cao của bố cục cha dựa trên thông tin chiều rộng cao đã đo lường của component con. Tất nhiên cũng có thể đặt chiều rộng, điều này có nghĩa là khi chiều rộng bị giới hạn, sẽ tính toán giá trị tối thiểu cho chiều rộng của bố cục cha dựa trên thông tin chiều rộng cao đã đo lường của component con. Chúng ta chỉ có thể sử dụng IntrinsicSize.Min hoặc IntrinsicSize.Max với các component tích hợp đã hỗ trợ đo lường đặc tính nội tại, nếu không chương trình sẽ crash khi chạy.
3.3.2 TÙY CHỈNH ĐO LƯỜNG ĐẶC TÍNH NỘI TẠI
Trong ví dụ trên, chúng ta sử dụng đo lường đặc tính nội tại của Row component, đo lường trước các component con, và xác định chiều cao của Row component dựa trên chiều cao của các component con. Tuy nhiên, cách thức hoạt động cụ thể của nó được ẩn trong mã nguồn của Row component. Như đã đề cập trước đó, nếu muốn hỗ trợ đo lường đặc tính nội tại, cần thêm việc ghi đè các phương thức đo lường đặc tính nội tại Intrinsic series trong measurePolicy. Mở khai báo giao diện MeasurePolicy, chúng ta thấy các phương thức Intrinsic series có tổng cộng bốn, như sau:
@Stable
@JvmDefaultWithCompatibility
fun interface MeasurePolicy {
fun MeasureScope.measure(measurables: List<Measurable>,constraints: Constraints): MeasureResult
fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Int {
...
}
fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List<IntrinsicMeasurable>,width: Int): Int {
...
}
fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Int {
...
}
fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List<IntrinsicMeasurable>,width: Int): Int {
...
}
}
Khi sử dụng Modifier.width(IntrinsicSize.Max), trong giai đoạn đo lường sẽ gọi phương thức maxIntrinsicWidth, tương tự như vậy. Trước khi sử dụng đo lường đặc tính nội tại, cần xác định xem phương thức Intrinsic tương ứng đã được ghi đè hay chưa, nếu không được ghi đè thì sẽ crash. Vì vậy, để triển khai phương thức Intrinsic, khi khai báo Layout không thể sử dụng đơn giản chuyển đổi SAM, cần triển khai đúng giao diện MeasurePolicy, như sau:
@Composable
fun CustomColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content, measurePolicy = object :MeasurePolicy{
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
TODO("Chưa được triển khai")
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
TODO("Chưa được triển khai")
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
TODO("Chưa được triển khai")
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
TODO("Chưa được triển khai")
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
TODO("Chưa được triển khai")
}
})
}
Bây giờ chúng ta hãy tùy chỉnh Row component để đạt được hiệu quả tương tự như Row component chính thức. Vì yêu cầu của chúng ta chỉ sử dụng Modifier.height(IntrinsicSize.Min), nên chỉ cần ghi đè phương thức minIntrinsicHeight là đủ.
Trong phương thức minIntrinsicHeight đã ghi đè, chúng ta có thể nhận được handle đo lường trước intrinsicMeasurables của các component con. Cách sử dụng của nó hoàn toàn giống với measurables đã đề cập trước đó. Sau khi đo lường trước tất cả các component con, có thể tính toán giá trị chiều cao lớn nhất trong số chúng, giá trị này sẽ ảnh hưởng đến thông tin constraints mà component cha nhận được trong giai đoạn đo lường chính thức. Lúc này maxHeight và minHeight trong constraints sẽ được đặt thành giá trị trả về, chiều cao trong constraints là một giá trị xác định:
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
var maxHeight = 0
measurables.forEach {
// tìm ra chiều cao lớn nhất và gán cho maxHeight
maxHeight = it.minIntrinsicHeight(width).coerceAtLeast(maxHeight)
}
return maxHeight
}
Tiếp theo chúng ta đặt các component con trái phải trong đo lường, mã như sau:
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map { measurable ->
// đặt layoutId cho component có thể tìm trực tiếp component, tùy chỉnh quy tắc đo lường
//measurable.layoutId=="Divider"
measurable.measure(constraints)
}
var positionX = 0
return layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(positionX, 0)
positionX += placeable.width
}
}
}
Sau đó sử dụng thuộc tính đo lường nội tại tùy chỉnh của chúng ta, mã đầy đủ như sau:
@Composable
fun DemoScreen() {
CustomRow(modifier = Modifier.height(IntrinsicSize.Min)) { // sử dụng thuộc tính đo lường nội tại tùy chỉnh
Text(
text = "Xin chào Kotlin",
fontSize = 10.sp // kích thước font khác nhau, chiều cao khác nhau
)
Divider(
color = Color.Black, modifier = Modifier
.fillMaxHeight()
.width(1.dp)
//.layoutId("Divider")
)
Text(
text = "Xin chào Android",
fontSize = 18.sp // kích thước font khác nhau, chiều cao khác nhau
)
}
}
@Composable
fun CustomRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content, measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map { measurable ->
// đặt layoutId cho component có thể tìm trực tiếp component, tùy chỉnh quy tắc đo lường
//measurable.layoutId=="Divider"
measurable.measure(constraints)
}
var positionX = 0
return layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(positionX, 0)
positionX += placeable.width
}
}
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
var maxHeight = 0
measurables.forEach {
// tìm ra chiều cao lớn nhất và gán cho maxHeight
maxHeight = it.minIntrinsicHeight(width).coerceAtLeast(maxHeight)
}
return maxHeight
}
})
}
Bản chất của đo lường đặc tính nội tại là cho phép component cha lấy được thông tin chiều rộng cao của mỗi component con trước, sau đó ảnh hưởng đến thông tin constraints mà nó nhận được trong giai đoạn đo lường, từ đó gián tiếp ảnh hưởng đến quá trình đo lường của component con. Trong ví dụ trên, chúng ta đã đo lường trước chiều cao của các component văn bản, từ đó xác định thông tin constraints mà component cha nhận được trong giai đoạn đo lường, và chỉ định chiều cao cho đường phân chia dựa trên chiều cao đó.
3.4 SUBCOMPOSELAYOUT
SubcomposeLayout cho phép giai đoạn kết hợp của component con bị trì hoãn đến giai đoạn bố cục của component cha, cung cấp cho chúng ta khả năng đo lường tùy chỉnh mạnh mẽ hơn. Trước đó đã đề cập, bản chất của đo lường đặc tính nội tại là cho phép component cha lấy được thông tin chiều rộng cao của mỗi component con trước, sau đó ảnh hưởng đến thông tin constraints mà nó nhận được trong giai đoạn đo lường, từ đó gián tiếp ảnh hưởng đến quá trình đo lường của component con. Trong khi đó, sử dụng SubcomposeLayout, có thể trì hoãn giai đoạn kết hợp của một component con cho đến khi việc đo lường của các component con cùng cấp kết thúc, từ đó có thể tùy chỉnh thứ tự của giai đoạn kết hợp và bố cục giữa các component con, thay thế cho đo lường đặc tính nội tại.
Chúng ta sử dụng SubcomposeLayout để thực hiện ví dụ trong đo lường đặc tính nội tại ở trên. Trong ví dụ đo lường đặc tính nội tại ở trên, có thể đo lường trước chiều cao của văn bản hai bên, sau đó chỉ định chiều cao cho Divider, rồi mới tiến hành đo lường. Khác với đo lường đặc tính nội tại, trong suốt quá trình này component cha không tham gia. Tiếp theo chúng ta hãy xem component SubcomposeLayout được sử dụng như thế nào.
@Composable
@UiComposable
fun SubcomposeLayout(
state: SubcomposeLayoutState,
modifier: Modifier = Modifier,
measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult
) {...}
stateĐối tượng để theo dõi trạng thái của con kết hợp, bao gồm các nhãn đã phân bổ và thông tin đo lường. Tham số này cho phép truy cập hoặc sửa đổi trạng thái của con kết hợp.measurePolicyMột hàm lambda, định nghĩa chiến lược đo lường cho con kết hợp. Hàm này nhận SubcomposeMeasureScope và Constraints làm tham số, trả về đối tượng MeasureResult, biểu thị kết quả đo lường của con kết hợp.SubcomposeMeasureScopecung cấp một số hàm và thuộc tính tiện ích cho việc đo lường, trong khiConstraintsmô tả các yêu cầu đo lược của bố cục cha đối với con kết hợp. Thực tế SubcomposeLayout và component Layout khá giống nhau. Khác biệt là lúc này cần truyền vào một Lambda kiểu SubcomposeMeasureScope, mở khai báo giao thức có thể thấy trong đó chỉ có một (tên là subcompose).
interface SubcomposeMeasureScope : MeasureScope {
fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable>
}
subcompose sẽ tạo một LayoutNode dựa trên slotId và Composable được truyền vào để xây dựng Composition con, cuối cùng trả về tất cả các handle đo lường Measurable của LayoutNode con. Trong đó Composable là thông tin component con mà chúng ta đã khai báo. slotId được dùng để SubcomposeLayout theo dõi và quản lý Composition con mà chúng ta tạo ra, mỗi Composition cần có slotId duy nhất như một chỉ mục, mỗi Composition cần có slotId duy nhất để chỉ mục, tiếp theo chúng ta xem cách sử dụng trong ví dụ ở trên trên. Thực tế có thể chia tất cả các component cần đo lường thành hai phần là component văn bản và component phân cách. Vì component phân cách phụ thuộc vào component văn bản, khi khai báo component phân cách cần truyền vào một giá trị Int làm chiều cao đo lường. Đầu tiên chúng ta định nghĩa một Composable.
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit// truyền vào chiều cao
) {
SubcomposeLayout(modifier = modifier) { constraints ->
...
}
}
Đầu tiên có thể sử dụng subcompose để đo lường tất cả LayoutNode trong text, và tính toán chiều cao dựa trên kết quả đo lường.
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit// truyền vào chiều cao
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var maxHeight=0
var placeables = subcompose(slotId = "text",text).map {
val placeable = it.measure(constraints)
maxHeight = placeable.height.coerceAtLeast(maxHeight)
placeable
}
...
}
}
Vì đã tính toán được chiều cao tối đa của văn bản, tiếp theo có thể truyền chiều cao này vào component phân cách, hoàn thành giai đoạn kết hợp và tiến hành đo lường.
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit// truyền vào chiều cao
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var maxHeight=0
var placeables = subcompose(slotId = "text",text).map {
val placeable = it.measure(constraints)
maxHeight = placeable.height.coerceAtLeast(maxHeight)
placeable
}
val dividerPlaceable = subcompose(slotId ="divider"){
divider(maxHeight)
}.map {
it.measure(constraints.copy(minWidth = 0))
}
...
}
}
Giống như trong đo lường đặc tính nội tại, khi đo lường component Divider, vẫn cần sao chép một bản sao constraints và đặt minWidth thành 0, nếu không sửa đổi, chiều rộng của component Divider mặc định sẽ giống với chiều rộng của cả component. Tiếp theo lần lượt bố cục cho component văn bản và component phân cách.
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit// truyền vào chiều cao
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var maxHeight = 0
var placeables = subcompose(slotId = "text", text).map {
val placeable = it.measure(constraints)
maxHeight = placeable.height.coerceAtLeast(maxHeight)
placeable
}
// divider đặt chiều cao tối đa của văn bản
val dividerPlaceable = subcompose(slotId = "divider") {
divider(maxHeight)
}.map {
it.measure(constraints.copy(minWidth = 0))
}
// bố cục
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach {
it.placeRelative(0, 0)
}
val midPos = constraints.maxWidth / 2
dividerPlaceable.forEach {
it.placeRelative(midPos, 0)
}
}
}
}
Sử dụng component SubcomposeRow
@Composable
fun DemoScreen() {
SubcomposeRow(modifier = Modifier.fillMaxWidth(), text = {
Text(
text = "Xin chào Kotlin",
fontSize = 14.sp, // kích thước font khác nhau, chiều cao khác nhau
modifier = Modifier.wrapContentWidth(Alignment.Start)
)
Text(
text = "Xin chào Android",
fontSize = 18.sp, // kích thước font khác nhau, chiều cao khác nhau
modifier = Modifier.wrapContentWidth(Alignment.End)
)
}) { heightPx -> // sử dụng chiều cao
// chuyển px thành dp
val heightDp = with(LocalDensity.current) {
heightPx.toDp()
}
Divider(
color = Color.Black, modifier = Modifier
.height(heightDp) // sử dụng chiều cao
.width(1.dp)
)
}
}
SubcomposeLayout có tính linh hoạt cao hơn, nhưng về mặt hiệu năng không bằng Layout thông thường, vì giai đoạn kết hợp của component con cần bị trì hoãn đến giai đoạn bố cục của component cha mới có thể thực hiện, do đó cần tạo thêm một Composition con, vì vậy SubcomposeLayout có thể không phù hợp ở một số phần UI có yêu cầu hiệu năng cao.
IV. VẼ TÙY CHỈNH
Giai đoạn vẽ chủ yếu là vẽ tất cả LayoutNode lên màn hình, cũng có thể tùy chỉnh giai đoạn vẽ này. Nếu chúng ta đã quen thuộc với Canvas gốc của Android, việc chuyển sang Compose không tốn bất kỳ chi phí học tập nào.
4.1 CANVAS COMPOSABLE
Canvas Composable là một component đơn vị được cung cấp bởi chính thức chuyên dùng để vẽ tùy chỉnh. CanvasComposable chứa hai tham số, một là Modifier,另一个 là khối mã trong ngữ cảnh DrawScope.
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw)) // tất cả logic vẽ cuối cùng đều được传入 drawBehind() phương thức修饰符
Trong ngữ cảnh DrawScope, Compose cung cấp API vẽ cơ bản, như trong bảng sau:
| API | Mô tả |
|---|---|
| drawLine | Vẽ đường thẳng |
| drawRect | Vẽ hình chữ nhật |
| drawImage | Vẽ hình ảnh |
| drawRoundRect | Vẽ hình chữ nhật bo tròn |
| drawCircle | Vẽ hình tròn |
| drawOval | Vẽ hình elip |
| drawArc | Vẽ cung tròn |
| drawPath | Vẽ đường path |
| drawPoints | Vẽ điểm |
Ví dụ:
@Preview
@Composable
fun DrawColorRing() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
var radius = 300.dp
var ringWidth = 30.dp
Canvas(modifier = Modifier.size(radius)) {
this.drawCircle( // vẽ hình tròn
brush = Brush.sweepGradient(listOf(Color.Red, Color.Green, Color.Red), Offset(radius.toPx() / 2f, radius.toPx() / 2f)),
radius = radius.toPx() / 2f,
style = Stroke(
width = ringWidth.toPx()
)
)
}
}
}
4.2 DRAWMODIFIER
Lớp DrawModifier có ba phương thức修饰符, mỗi phương thức có công dụng riêng. drawWithContent cho phép nhà phát triển có thể tùy chỉnh cấp độ vẽ khi vẽ, Canvas Composable sử dụng drawBehind được dùng để tùy chỉnh vẽ nền cho component, trong khi drawWithCache cho phép nhà phát triển có thể mang theo cache khi vẽ.
4.2.1 DRAWWITHCONTENT
Trong mã nguồn triển khai của drawBehind, logic vẽ tùy chỉnh onDraw sẽ được truyền vào hàm khởi tạo chính của DrawBackgroundModifier. Trong phương thức draw đã ghi đè, đầu tiên gọi logic vẽ tùy chỉnh chúng ta truyền vào, sau đó gọi drawContent để vẽ nội dung component chính.
fun Modifier.drawBehind(
onDraw: DrawScope.() -> Unit
) = this then DrawBehindElement(onDraw) //DrawBehindElement
@OptIn(ExperimentalComposeUiApi::class)
private data class DrawBehindElement( //DrawBehindElement
val onDraw: DrawScope.() -> Unit
) : ModifierNodeElement<DrawBackgroundModifier>() {
override fun create() = DrawBackgroundModifier(onDraw) //DrawBackgroundModifier
...
}
@OptIn(ExperimentalComposeUiApi::class)
private class DrawBackgroundModifier( //DrawBackgroundModifier
var onDraw: DrawScope.() -> Unit
) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
onDraw() // vẽ nền trước
drawContent() // vẽ nội dung sau
}
}
4.2.2 DRAWBEHIND
drawBehind, vẽ ở phía sau. Cụ thể vẽ ở phía sau ai, cụ thể vẽ ở phía sau UI component mà nó修饰. Dựa trên phần giới thiệu trước, chúng ta có thể đoán rằng, thực chất không phải là chúng ta tự vẽ logic vẽ tùy chỉnh của mình trước, sau đó vẽ UI component chính chứ? Chúng ta xem mã nguồn có thể thấy.
fun Modifier.drawBehind(
onDraw: DrawScope.() -> Unit
) = this.then(
DrawBackgroundModifier(
onDraw = onDraw, // onDraw là logic vẽ tùy chỉnh của chúng ta
...
)
)
private class DrawBackgroundModifier(
val onDraw: DrawScope.() -> Unit,
...
) : DrawModifier, InspectorValueInfo(inspectorInfo) {
override fun ContentDrawScope.draw() {
onDraw() // vẽ logic vẽ tùy chỉnh của chúng ta trước
drawContent() // vẽ UI component chính sau
}
...
}
4.2.3 DRAWWITHCACHE
Đôi khi khi chúng ta vẽ một số hiệu ứng UI phức tạp, không mong muốn khi Recompose xảy ra tất cả các thể dụnh dùng để vẽ sẽ được xây dựng lại một lần (như Path), điều này có thể gây ra hiện tượng giật bộ nhớ. Trong Compose chúng ta thường nghĩ đến sử dụng remember để cache, tuy nhiên ngữ vực vẽ của chúng ta là DrawScope chứ không phải Composable, vì vậy không thể sử dụng remember, drawWithCache cung cấp khả năng này.
Mở khai báo drawWithCache có thể thấy, cần truyền vào một lambda kiểu CacheDrawScope, đáng chú ý là lúc này giá trị trả về phải là một DrawResult. Tiếp theo chúng ta xem CacheDrawScope đã giới hạn những API nào cho chúng ta.
Ha ha có thể thấy, trong CacheDrawScope, onDrawBehind, onDrawWithContent cung cấp giá trị trả về kiểu DrawResult, hai API này hoàn toàn tương đương với drawBehind và drawWithContent. Cách sử dụng thì không cần phải giải thích nhiều.
@Preview
@Composable
fun DrawBorder() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
var borderColor by mutableStateOf(Color.Red)
Card(
shape = RoundedCornerShape(0.dp)
,modifier = Modifier
.size(100.dp)
.drawWithCache {
Log.d("compose_study", "Đây sẽ không xảy ra Recompose")
var path = Path().apply {
moveTo(0f, 0f)
relativeLineTo(100.dp.toPx(), 0f)
relativeLineTo(0f, 100.dp.toPx())
relativeLineTo(-100.dp.toPx(), 0f)
relativeLineTo(0f, -100.dp.toPx())
}
onDrawWithContent {
Log.d("compose_study", "Đây sẽ xảy ra Recompose")
drawContent()
drawPath(
path = path,
color = borderColor,
style = Stroke(
width = 10f,
)
)
}
}
) {
Image(painter = painterResource(id = R.drawable.diana), contentDescription = "Diana")
}
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = {
borderColor = Color.Yellow
}) {
Text("Thay đổi màu")
}
}
}
}
4.3 TƯƠNG THÍCH VỚI NATIVE
Các API được cung cấp trong DrawScope chỉ là một đóng gói cấp cao, bên dưới vẫn sử dụng Canvas gốc của nền tảng để vẽ. Là một đóng gói cấp cao, để đảm bảo tính phổ biến của nền tảng, tất yếu sẽ dẫn đến việc mất đi một số API được cung cấp bởi API cụ thể của nền tảng. Ví dụ, trong Canvas gốc Android chúng ta có thể vẽ văn bản drawText, nhưng điều này không được cung cấp trong DrawScope. Trong DrawScope, chúng ta có thể truy cập thành viên drawContext, drawContext lưu trữ các thông tin sau:
- size: Kích thước vẽ
- canvas: Canvas đóng gói cấp cao của Compose
- transform: Bộ điều khiển transform, dùng để xoay, thu phóng và di chuyển
Có thể truy cập đến canvas gốc thông qua canvas.nativeCanvas, trên nền tảng Android nó tương ứng với AndroidCanvas, thông qua nativeCanvas này có thể gọi phương thức Canvas gốc của nền tảng. Vì vậy nếu bạn không thích sử dụng API cấp cao phổ biến nền tảng được cung cấp bởi DrawScope hoặc có nhu cầu, có thể trực tiếp sử dụng Canvas gốc của nền tảng, nhưng cái giá phải trả là sẽ mất đi tính phổ biến của nền tảng, với các nền tảng khác cần cung cấp các triển khai khác nhau, không thể cung cấp như một module phổ biến, nếu bạn chỉ phát triển cho nền tảng Android thì không cần phải quan tâm nhiều như vậy.