1. Trước khi bắt đầu
Điều kiện tiên quyết
- Có kinh nghiệm tạo ứng dụng Android.
- Có kinh nghiệm về Jetpack Compose.
Những gì bạn cần
Kiến thức bạn sẽ học được
- Kiến thức cơ bản về bố cục thích ứng và Navigation 3
- Cách triển khai thao tác kéo và thả
- Cách hỗ trợ các phím tắt
- Cách bật trình đơn theo bối cảnh
2. Bắt đầu thiết lập
Để bắt đầu, hãy làm theo các bước sau:
- Mở Android Studio
- Nhấp vào File (Tệp) > New (Mới) >
Project from Version control
- Dán URL:
https://github.com/android/socialite.git
- Nhấp vào
Clone
Chờ cho đến khi dự án tải xong.
- Mở Terminal và chạy:
$ git checkout codelab-adaptive-apps-start
- Chạy một quy trình đồng bộ hoá Gradle
Trong Android Studio, hãy chọn File (Tệp) > Sync Project with Gradle Files (Đồng bộ hoá dự án với các tệp Gradle)
- (không bắt buộc) Tải trình mô phỏng máy tính lớn xuống
Trong Android Studio, hãy chọn Tools (Công cụ) > Device Manager (Trình quản lý thiết bị) > + > Create Virtual Device (Tạo thiết bị ảo) > New hardware profile (Hồ sơ phần cứng mới)
Chọn Loại thiết bị: Desktop (Máy tính)
Kích thước màn hình: 14 inch
Độ phân giải: 1920 x 1080 pixel
Nhấp vào Finish (Hoàn tất).
- Chạy ứng dụng trên máy tính bảng hoặc trình mô phỏng máy tính
3. Tìm hiểu về ứng dụng mẫu
Trong hướng dẫn này, bạn sẽ làm việc với một ứng dụng trò chuyện mẫu có tên là Socialite được tạo bằng Jetpack Compose.
Trong ứng dụng này, bạn có thể trò chuyện với nhiều loài động vật và chúng sẽ trả lời tin nhắn của bạn theo cách riêng.
Hiện tại, đây là một ứng dụng dành riêng cho thiết bị di động và không được tối ưu hoá cho các thiết bị lớn như máy tính bảng hoặc máy tính.
Chúng tôi sẽ điều chỉnh ứng dụng này cho phù hợp với màn hình lớn và thêm một số tính năng để cải thiện trải nghiệm trên mọi kiểu dáng thiết bị.
Hãy bắt đầu!
4. Kiến thức cơ bản về bố cục thích ứng + Navigation 3
$ git checkout codelab-adaptive-apps-step-1
Hiện tại, dù không gian màn hình rộng đến đâu, ứng dụng luôn chỉ hiển thị duy nhất một ngăn tại một thời điểm.
Chúng ta sẽ khắc phục vấn đề đó bằng cách sử dụng adaptive layouts
. Phương thức này hiển thị một hoặc nhiều ngăn tuỳ thuộc vào kích thước cửa sổ hiện tại. Trong lớp học lập trình này, chúng ta sẽ sử dụng bố cục thích ứng để tự động hiển thị màn hình chat list
và chat detail
cạnh nhau, khi có đủ không gian cửa sổ.
Bố cục thích ứng được thiết kế để tích hợp liền mạch vào mọi ứng dụng.
Trong hướng dẫn này, chúng ta sẽ tập trung vào cách sử dụng các bố cục này với thư viện Navigation 3 (Điều hướng 3). Đây là thư viện mà chúng ta dùng để tạo ứng dụng Socialite.
Kiến thức cơ bản về Navigation 3
Để hiểu về Navigation 3, hãy bắt đầu với một số thuật ngữ:
- NavEntry – Một số nội dung hiển thị trong ứng dụng mà người dùng có thể di chuyển đến. Nội dung này chỉ được xác định bằng một khoá. NavEntry không nhất thiết phải hiển thị kín hết cửa sổ hiện có của ứng dụng. Có thể hiển thị đồng thời nhiều NavEntry (nói thêm sau).
- Khoá – Giá trị nhận dạng riêng biệt của một NavEntry. Các khoá được lưu trữ trong ngăn xếp lui.
- Ngăn xếp lui – Một ngăn xếp gồm các khoá đại diện cho các phần tử NavEntry hiển thị trước đó hoặc đang được hiển thị. Để di chuyển, hãy đẩy khoá vào hoặc lấy khoá ra khỏi ngăn xếp.
Trong Socialite, danh sách trò chuyện là màn hình đầu tiên chúng ta muốn hiển thị khi người dùng khởi chạy ứng dụng. Do đó, chúng ta tạo ngăn xếp lui và khởi chạy ngăn xếp đó bằng khoá đại diện cho màn hình đó.
Main.kt
// Create a new back stack
val backStack = rememberNavBackStack(ChatsList)
...
// Navigate to a particular chat
backStack.add(ChatThread(chatId = chatId))
...
// Navigate back
backStack.removeLastOrNull()
Triển khai Navigation 3
Chúng ta sẽ triển khai Navigation 3 ngay trong thành phần kết hợp của điểm vào Main
.
Bỏ ghi chú lệnh gọi hàm MainNavigation
để kết nối logic điều hướng.
Bây giờ, hãy bắt đầu xây dựng cơ sở hạ tầng điều hướng.
Trước tiên, hãy tạo ngăn xếp lui. Đây là nền tảng của Navigation 3.
NavDisplay
Đến đây thì chúng ta đã tìm hiểu một số khái niệm về Navigation 3. Vậy thì, làm cách nào thư viện biết được đối tượng nào đại diện cho ngăn xếp lui và làm sao để chuyển đổi các phần tử của ngăn xếp lui đó thành giao diện người dùng thực tế?
Hãy tìm hiểu về NavDisplay
. Đây là thành phần giúp kết hợp mọi thứ lại và hiển thị ngăn xếp lui. Thành phần này cần có một số tham số quan trọng. Hãy cùng xem xét từng thành phần một nhé.
Tham số 1 – Ngăn xếp lui
NavDisplay
cần có quyền truy cập vào ngăn xếp lui để hiển thị nội dung trong đó. Hãy truyền giá trị này vào.
Tham số 2 – EntryProvider
EntryProvider
là một biểu thức lambda dùng để biến đổi các khoá trong ngăn xếp lui thành nội dung có thể kết hợp trong giao diện người dùng. Tham số này lấy một khoá và trả về một NavEntry
có chứa nội dung cần hiển thị và siêu dữ liệu về cách hiển thị nội dung đó (sẽ nói thêm về điều này sau).
NavDisplay
sẽ gọi biểu thức lambda này bất cứ khi nào cần lấy nội dung cho một khoá nhất định — ví dụ: khi một khoá mới được thêm vào ngăn xếp lui.
Hiện tại, nếu nhấp vào biểu tượng Timeline (Dòng thời gian) trong Socialite, chúng ta sẽ thấy thông báo "Unknown back stack key: Timeline" (Khoá không xác định trong ngăn xếp lui: Dòng thời gian).
Lý do là mặc dù khoá Timeline (Dòng thời gian) được thêm vào ngăn xếp lui, nhưng do chưa biết cách hiển thị khoá này nên EntryProvider
sẽ quay lại phương thức triển khai mặc định. Điều tương tự cũng xảy ra khi chúng ta nhấp vào biểu tượng Settings (Cài đặt). Hãy khắc phục vấn đề đó bằng cách đảm bảo EntryProvider
xử lý đúng cách các khoá Timeline (Dòng thời gian) và Settings (Cài đặt) trong ngăn xếp lui.
Tham số 3 — SceneStrategy
Tham số quan trọng tiếp theo của NavDisplay
là SceneStrategy
. Chúng ta sẽ dùng tham số này khi muốn hiển thị nhiều phần tử NavEntry
cùng một lúc. Mỗi chiến lược sẽ xác định xem nhiều phần tử NavEntry
sẽ hiển thị cạnh nhau hoặc xếp chồng lên nhau như thế nào.
Ví dụ: nếu chúng ta sử dụng DialogSceneStrategy
và đánh dấu một số NavEntry
bằng siêu dữ liệu đặc biệt, siêu dữ liệu đó sẽ xuất hiện dưới dạng một hộp thoại ở đầu nội dung hiện tại thay vì chiếm toàn bộ màn hình.
Trong trường hợp này, chúng ta sẽ sử dụng một SceneStrategy khác – ListDetailSceneStrategy
. Tham số này được dùng cho bố cục danh sách-chi tiết tiêu chuẩn.
Trước tiên, hãy thêm tham số này vào hàm khởi tạo NavDisplay
.
sceneStrategy = rememberListDetailSceneStrategy(),
Bây giờ, chúng ta cần đánh dấu ChatList
NavEntry
làm ngăn danh sách và ChatThread
NavEntry làm ngăn chi tiết để chiến lược có thể xác định thời điểm cả hai phần tử NavEntry này đều nằm trong ngăn xếp lui. Cả hai phần tử này sẽ hiển thị cạnh nhau.
Bước tiếp theo, hãy đánh dấu ChatsList
NavEntry
làm ngăn danh sách.
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatsList -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.listPane(),
) {
...
}
...
}
}
Tương tự, hãy đánh dấu ChatThread
NavEntry
làm ngăn chi tiết.
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatThread -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.detailPane(),
) {
...
}
...
}
}
Và như vậy, chúng ta đã tích hợp thành công bố cục thích ứng vào ứng dụng của mình.
5. Kéo và thả
$ git checkout codelab-adaptive-apps-step-2
Ở bước này, chúng ta sẽ thêm tính năng hỗ trợ kéo và thả để người dùng có thể kéo hình ảnh từ ứng dụng Tệp vào Socialite.
Mục tiêu của chúng ta là bật tính năng kéo và thả trong khu vực message list
do thành phần kết hợp MessageList
xác định, nằm trong tệp ChatScreen.kt
.
Trong Jetpack Compose, tính năng hỗ trợ kéo và thả được triển khai bằng đối tượng sửa đổi dragAndDropTarget
. Chúng ta áp dụng tính năng này cho các thành phần kết hợp cần chấp nhận các mục được thả.
Modifier.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
// condition to accept dragged item
},
target = // DragAndDropTarget
)
Đối tượng sửa đổi có hai tham số.
- Tham số đầu tiên,
shouldStartDragAndDrop
, giúp thành phần kết hợp có thể lọc các sự kiện kéo và thả. Trong trường hợp này, chúng ta chỉ muốn chấp nhận hình ảnh và bỏ qua tất cả các loại dữ liệu khác. - Tham số thứ hai,
target
, là một lệnh gọi lại giúp xác định logic dùng để xử lý các sự kiện kéo và thả đã chấp nhận.
Trước tiên, hãy bắt đầu bằng việc thêm dragAndDropTarget
vào thành phần kết hợp MessageList
.
.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
event.mimeTypes().any { it.startsWith("image/") }
},
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
TODO("Not yet implemented")
}
}
}
),
Đối tượng gọi lại target
cần triển khai phương thức onDrop()
. Phương thức này lấy DragAndDropEvent
làm đối số.
Phương thức này được gọi khi người dùng thả một mục vào thành phần kết hợp. Phương thức này trả về true
nếu mục đã được xử lý; false
nếu mục bị từ chối.
Mỗi DragAndDropEvent
chứa một đối tượng ClipData
, trong đó đóng gói dữ liệu đang được kéo.
Dữ liệu bên trong ClipData
là một mảng gồm các đối tượng Item
. Vì có thể kéo nhiều mục cùng một lúc, nên mỗi Item
đại diện cho một trong các mục đó.
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (clipData != null && clipData.itemCount > 0) {
repeat(clipData.itemCount) { i ->
val item = clipData.getItemAt(i)
// TODO: Implement Item handling
}
return true
}
return false
}
}
}
Item
có thể chứa dữ liệu ở dạng URI, văn bản hoặc Intent
.
Trong trường hợp này, vì chỉ chấp nhận hình ảnh nên chúng ta sẽ tìm kiếm một URI cụ thể.
Nếu Item
chứa một URI, chúng ta cần:
- Yêu cầu cấp quyền kéo và thả để truy cập vào URI
- Xử lý URI (trong trường hợp này là bằng cách gọi hàm
onMediaItemAttached()
đã triển khai) - Thu hồi quyền
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
&& clipData != null && clipData.itemCount > 0) {
repeat(clipData.itemCount) { i ->
val item = clipData.getItemAt(i)
val passedUri = item.uri?.toString()
if (!passedUri.isNullOrEmpty()) {
val dropPermission = activity
.requestDragAndDropPermissions(
event.toAndroidDragEvent()
)
try {
val mimeType = context.contentResolver
.getType(passedUri.toUri()) ?: ""
onMediaItemAttached(MediaItem(passedUri, mimeType))
} finally {
dropPermission.release()
}
}
}
return true
}
return false
}
Tại thời điểm này, tính năng kéo và thả đã được triển khai đầy đủ và bạn có thể kéo ảnh từ ứng dụng Files vào Socialite.
Để giao diện được đẹp mắt hơn, hãy thêm một đường viền dễ nhìn thấy để làm nổi bật khu vực có thể chấp nhận các mục được thả.
Để thêm đường viền, chúng ta có thể dùng các hook bổ sung tương ứng với nhiều giai đoạn của phiên kéo và thả:
onStarted()
: Gọi khi một phiên kéo và thả bắt đầu vàDragAndDropTarget
này đủ điều kiện để nhận các mục. Tại đây, bạn có thể chuẩn bị trạng thái giao diện người dùng cho phiên làm việc sắp tới.onEntered()
: Sẽ kích hoạt khi một mục được kéo vào phạm vi củaDragAndDropTarget
này.onMoved()
: Sẽ gọi khi mục được kéo di chuyển trong phạm vi củaDragAndDropTarget
này.onExited()
: Sẽ gọi khi mục được kéo di chuyển ra ngoài phạm vi củaDragAndDropTarget
này.onChanged()
: Sẽ gọi khi có thay đổi trong phiên kéo và thả trong phạm vi của mục tiêu này — ví dụ: nếu nhấn hoặc nhả phím bổ trợ.onEnded()
: Sẽ gọi khi phiên kéo và thả kết thúc. MọiDragAndDropTarget
mà trước đó đã nhận được một sự kiệnonStarted
sẽ nhận được giá trị này. Được dùng khi đặt lại trạng thái giao diện người dùng.
Để thêm đường viền dễ nhìn thấy, chúng ta cần làm như sau:
- Tạo một biến boolean ở dạng đã ghi nhớ được đặt thành
true
khi thao tác kéo và thả bắt đầu và đặt lại thànhfalse
khi thực hiện xong thao tác đó. - Áp dụng một đối tượng sửa đổi cho thành phần kết hợp
MessageList
hiển thị đường viền khi biến này làtrue
override fun onEntered(event: DragAndDropEvent) {
super.onEntered(event)
isDraggedOver = true
}
override fun onEnded(event: DragAndDropEvent) {
super.onExited(event)
isDraggedOver = false
}
6. Phím tắt
$ git checkout codelab-adaptive-apps-step-3
Khi dùng ứng dụng nhắn tin trên máy tính, người dùng mong muốn thấy các phím tắt quen thuộc, chẳng hạn như gửi tin nhắn bằng phím Enter.
Trong bước này, chúng ta sẽ thêm hành vi đó vào ứng dụng.
Các sự kiện bàn phím trong Compose sẽ được xử lý bằng đối tượng sửa đổi.
Có 2 đối tượng sửa đổi chính:
onPreviewKeyEvent
– chặn sự kiện bàn phím trước khi sự kiện đó được phần tử được lấy tiêu điểm xử lý. Trong quá trình triển khai, chúng ta sẽ quyết định xem có truyền sự kiện đi nữa hay không hay xử lý luôn.onKeyEvent
– chặn sự kiện bàn phím sau khi sự kiện đó được phần tử được lấy tiêu điểm xử lý. Đối tượng sửa đổi này chỉ kích hoạt nếu các trình xử lý khác không xử lý sự kiện.
Trong trường hợp của chúng ta, việc sử dụng onKeyEvent
trên TextField
sẽ không mang lại hiệu quả vì trình xử lý mặc định sẽ xử lý sự kiện nhấn phím Enter và di chuyển con trỏ sang dòng mới.
.onPreviewKeyEvent { keyEvent ->
//TODO: implement key event handling
},
Biểu thức lambda ở bên trong đối tượng sửa đổi sẽ được gọi hai lần cho mỗi thao tác nhấn phím — khi người dùng nhấn và nhả phím.
Chúng ta có thể xác định điều này bằng cách kiểm tra thuộc tính type
của đối tượng KeyEvent
. Đối tượng sự kiện cũng hiển thị các cờ cho đối tượng sửa đổi, bao gồm:
isAltPressed
isCtrlPressed
isMetaPressed
isShiftPressed
Việc trả về true
từ biểu thức lambda sẽ cho Compose biết rằng mã của chúng ta đã xử lý sự kiện nhấn phím và ngăn hành vi mặc định, chẳng hạn như chèn một dòng mới.
Bây giờ, hãy triển khai đối tượng sửa đổi onPreviewKeyEvent
. Kiểm tra xem sự kiện này có phải là kết quả khi nhấn phím Enter mà không dùng phím bổ trợ shift, alt, ctrl hoặc meta nào hay không. Sau đó, hãy gọi hàm onSendClick()
.
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown
&& keyEvent.isShiftPressed == false
&& keyEvent.isAltPressed == false
&& keyEvent.isCtrlPressed == false
&& keyEvent.isMetaPressed == false) {
onSendClick()
true
} else {
false
}
},
7. Trình đơn theo bối cảnh
$ git checkout codelab-adaptive-apps-step-4
Trình đơn theo bối cảnh là một phần quan trọng trong giao diện người dùng thích ứng.
Trong bước này, chúng ta sẽ thêm trình đơn bật lên Reply (Trả lời) xuất hiện khi người dùng nhấp chuột phải vào một tin nhắn.
Có nhiều cử chỉ được hỗ trợ sẵn, ví dụ: đối tượng sửa đổi clickable
giúp dễ dàng phát hiện một lượt nhấp.
Đối với các cử chỉ tuỳ chỉnh, chẳng hạn như nhấp chuột phải, chúng ta có thể dùng đối tượng sửa đổi pointerInput
. Đối tượng này giúp chúng ta truy cập vào các sự kiện con trỏ thô và có toàn quyền kiểm soát việc phát hiện cử chỉ.
Trước tiên, hãy thêm giao diện người dùng để phản hồi thao tác nhấp chuột phải. Trong trường hợp này, chúng ta muốn hiển thị DropdownMenu
với một mục duy nhất là: nút Reply (Trả lời). Chúng ta sẽ cần 2 biến ở dạng remember
:
rightClickOffset
lưu trữ vị trí của lượt nhấp để chúng ta có thể di chuyển nút Reply (Trả lời) gần con trỏisMenuVisible
kiểm soát việc hiện hoặc ẩn nút Reply (Trả lời)
Giá trị của các biến này sẽ được cập nhật trong quá trình xử lý cử chỉ nhấp chuột phải.
Chúng ta cũng cần gói thành phần kết hợp của tin nhắn trong Box
để DropdownMenu
có thể xuất hiện ở lớp trên.
@Composable
internal fun MessageBubble(
...
) {
var rightClickOffset by remember { mutableStateOf<DpOffset>(DpOffset.Zero) }
var isMenuVisible by remember { mutableStateOf(false) }
val density = LocalDensity.current
Box(
modifier = Modifier
.pointerInput(Unit) {
// TODO: Implement right click handling
}
.then(modifier),
) {
AnimatedVisibility(isMenuVisible) {
DropdownMenu(
expanded = true,
onDismissRequest = { isMenuVisible = false },
offset = rightClickOffset,
) {
DropdownMenuItem(
text = { Text("Reply") },
onClick = {
// Custom Reply functionality
},
)
}
}
MessageBubbleSurface(
...
) {
...
}
}
}
Bây giờ, hãy triển khai đối tượng sửa đổi pointerInput
. Trước tiên, chúng ta thêm awaitEachGesture
để bắt đầu một phạm vi mới mỗi khi người dùng bắt đầu một cử chỉ mới. Bên trong phạm vi đó, chúng ta cần:
- Nhận sự kiện con trỏ tiếp theo –
awaitPointerEvent()
cung cấp một đối tượng đại diện cho sự kiện con trỏ - Lọc thao tác nhấn chuột phải thuần tuý – chúng ta sẽ kiểm tra để đảm bảo chỉ nút phụ được nhấn
- Ghi lại vị trí nhấp – lấy vị trí theo pixel và chuyển đổi thành
DpOffset
để vị trí trình đơn không phụ thuộc vào DPI - Hiện trình đơn – đặt
isMenuVisible
=true
và lưu trữ giá trị bù trừ đểDropdownMenu
bật lên ngay tại vị trí con trỏ - Xử lý sự kiện – gọi
consume()
trên cả thao tác nhấn và lượt nhả tương ứng để ngăn các trình xử lý khác phản ứng
.pointerInput(Unit) {
awaitEachGesture { // Start listening for pointer gestures
val event = awaitPointerEvent()
if (
event.type == PointerEventType.Press
&& !event.buttons.isPrimaryPressed
&& event.buttons.isSecondaryPressed
&& !event.buttons.isTertiaryPressed
// all pointer inputs just went down
&& event.changes.fastAll { it.changedToDown() }
) {
// Get the pressed pointer info
val press = event.changes.find { it.pressed }
if (press != null) {
// Convert raw press coordinates (px) to dp for positioning the menu
rightClickOffset = with(density) {
isMenuVisible = true // Show the context menu
DpOffset(
press.position.x.toDp(),
press.position.y.toDp()
)
}
}
// Consume the press event so it doesn't propagate further
event.changes.forEach {
it.consume()
}
// Wait for the release and consume it as well
waitForUpOrCancellation()?.consume()
}
}
}
8. Xin chúc mừng
Xin chúc mừng! Bạn đã di chuyển thành công ứng dụng sang Navigation 3 và thêm:
- Bố cục thích ứng
- Thao tác kéo và thả
- Phím tắt
- Trình đơn theo bối cảnh
Đó là nền tảng vững chắc để xây dựng một ứng dụng thích ứng hoàn toàn!
Tìm hiểu thêm