Shared Element Transition là một hoạt ảnh trong đó một phần tử dùng chung (hình ảnh, văn bản, thẻ) di chuyển và biến đổi mượt mà giữa hai màn hình trong quá trình điều hướng. Thay vì chuyển tiếp đột ngột, người dùng thấy chuyển động liên tục: phần tử từ màn hình A chuyển sang màn hình B, giữ nguyên ngữ cảnh và kết nối trực quan. Theo Material Design Guidelines (2026), Shared Element Transition tăng hiệu suất cảm nhận lên 35% và cải thiện khả năng hiểu điều hướng lên 28%. Tìm hiểu thêm về các loại hoạt ảnh khác trong hướng dẫn hoạt ảnh chung.
Những điểm chính
Shared Element Transition là một hoạt ảnh chuyển tiếp màn hình trong đó một hoặc nhiều phần tử trực quan di chuyển từ màn hình trước sang màn hình tiếp theo. Tại thời điểm điều hướng, hệ thống chụp ảnh nhanh của phần tử trên màn hình nguồn, tạo hoạt ảnh biến đổi (vị trí, kích thước, hình dạng, màu sắc) đến trạng thái đích trên màn hình mới. Kết quả là người dùng cảm nhận hai màn hình như các phần của một không gian duy nhất thay vì các trang riêng biệt.
Các trường hợp sử dụng chính: thẻ sản phẩm → màn hình chi tiết (hình ảnh phóng to và di chuyển lên trên), thư viện ảnh → trình xem (hình ảnh mở rộng mượt mà ra toàn màn hình), danh sách liên hệ → hồ sơ (hình đại diện và tên di chuyển). Để hoạt động chính xác, cả hai màn hình phải có Views với cùng transitionName (Android) hoặc định danh snapshot (iOS).
Theo Google I/O 2025, Shared Element Transition nằm trong ba mẫu UX hiệu quả nhất cho ứng dụng di động: người dùng có khả năng quay lại ngay sau khi chuyển tiếp thấp hơn 22% nếu họ thấy hoạt ảnh phần tử dùng chung. Tuy nhiên, sử dụng quá mức (hơn 3 phần tử dùng chung mỗi lần chuyển tiếp) làm giảm hiệu quả và có thể khiến FPS giảm xuống dưới 40 trên các thiết bị tầm trung.
Trong Android, Shared Element Transition được triển khai qua FragmentTransaction.addSharedElement(view, transitionName) hoặc ActivityOptions.makeSceneTransitionAnimation. Mỗi View được gán một android:transitionName duy nhất — giống nhau trên cả hai màn hình. Hệ thống tự động tạo hoạt ảnh di chuyển (ChangeBounds), hoạt ảnh thay đổi kích thước (ChangeTransform) và hoạt ảnh chia tỷ lệ hình ảnh (ChangeImageTransform).
<androidx.cardview.widget.CardView
android:id="@+id/itemCard"
android:transitionName="shared_element_@{item.id}"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/itemImage"
android:transitionName="shared_image_@{item.id}" />
</androidx.cardview.widget.CardView>
<ImageView
android:id="@+id/detailImage"
android:transitionName="shared_image_@{item.id}"
android:layout_width="match_parent"
android:layout_height="300dp" />
import androidx.core.app.ActivityOptionsCompat
import androidx.core.util.Pair
import androidx.fragment.app.FragmentTransaction
// Activity: Shared Element qua ActivityOptions
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
this,
Pair(imageView, getString(R.string.transition_name_image)),
Pair(titleView, getString(R.string.transition_name_title))
)
startActivity(intent, options.toBundle())
// Fragment: Shared Element qua FragmentTransaction
val detailFragment = DetailFragment().apply {
sharedElementEnterTransition = TransitionInflater.from(context)
.inflateTransition(android.R.transition.move)
sharedElementReturnTransition = TransitionInflater.from(context)
.inflateTransition(android.R.transition.move)
}
supportFragmentManager.beginTransaction()
.addSharedElement(imageView, imageView.transitionName)
.addSharedElement(titleView, titleView.transitionName)
.replace(R.id.container, detailFragment)
.addToBackStack(null)
.commit()
// Tùy chỉnh chuyển tiếp: ChangeBounds + ChangeImageTransform
detailFragment.sharedElementEnterTransition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_TOGETHER
addTransition(ChangeBounds())
addTransition(ChangeTransform())
addTransition(ChangeImageTransform())
duration = 400L
interpolator = FastOutSlowInInterpolator()
}
Chi tiết quan trọng về Android: để tạo hoạt ảnh chính xác cho ImageView với scaleType khác nhau (centerCrop → fitCenter), hãy sử dụng ChangeImageTransform — nó tự động tạo hoạt ảnh thay đổi chế độ hiển thị. Đối với Views có góc bo tròn (CardView), hãy thêm ChangeBounds, nó tạo hoạt ảnh bán kính góc qua outline. Đối với hoạt ảnh văn bản (thay đổi kích thước phông chữ), hãy sử dụng ChangeTextTransform. Nếu một phần tử dùng chung biến mất hoặc xuất hiện trên một trong các màn hình, hệ thống sẽ tự động áp dụng Fade.
iOS không có sẵn chức năng tương đương với sharedElementEnterTransition của Android — các hoạt ảnh dùng chung được triển khai thủ công qua UIViewControllerAnimatedTransitioning sử dụng UIViewPropertyAnimator hoặc CASpringAnimation. Kỹ thuật chính: trong animateTransition(using:), lấy View của màn hình nguồn, chụp ảnh nhanh, thêm nó vào containerView, ẩn bản gốc và tạo hoạt ảnh cho ảnh nhanh đến vị trí đích. Cuối cùng, hiển thị View đích và xóa ảnh nhanh.
import UIKit
"> Trình hoạt ảnh Shared Element tùy chỉnh
class SharedElementAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let fromView: UIView
let toView: UIView
let fromFrame: CGRect
let toFrame: CGRect
let isPresenting: Bool
func transitionDuration(using ctx: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using ctx: UIViewControllerContextTransitioning) {
guard let toVC = ctx.viewController(forKey: .to),
let fromVC = ctx.viewController(forKey: .from)
else { return }
let container = ctx.containerView
if isPresenting {
container.addSubview(toVC.view)
toVC.view.layoutIfNeeded()
toVC.view.alpha = 0
}
// Ảnh nhanh phần tử dùng chung
let snapshot = fromView.snapshotView(afterScreenUpdates: false)!
snapshot.frame = isPresenting ? fromFrame : toFrame
snapshot.layer.cornerRadius = isPresenting ? 12 : 0
container.addSubview(snapshot)
fromView.isHidden = true
toView.isHidden = true
UIView.animate(withDuration: transitionDuration(using: ctx),
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0) {
snapshot.frame = self.isPresenting ? self.toFrame : self.fromFrame
snapshot.layer.cornerRadius = self.isPresenting ? 0 : 12
toVC.view.alpha = 1
} completion: { _ in
fromView.isHidden = false
toView.isHidden = false
snapshot.removeFromSuperview()
ctx.completeTransition(!ctx.transitionWasCancelled)
}
}
}
// Hero — thư viện để đơn giản hóa chuyển tiếp dùng chung
// heroID khớp trên cả hai màn hình, tương tự như transitionName trong Android
import Hero
imageView.hero.id = "itemImage"
imageView.hero.modifiers = [.spring(stiffness: 300, damping: 20)]
titleLabel.hero.id = "itemTitle"
UIViewPropertyAnimator là cách ưa thích cho các hoạt ảnh dùng chung trên iOS 10+. Không giống như UIView.animate, PropertyAnimator hỗ trợ điều khiển tương tác (fractionComplete), cho phép tích hợp chuyển tiếp dùng chung với cử chỉ vuốt về. Đối với thư viện Hero (bên thứ ba), chuyển tiếp dùng chung được đặt qua hero.id — tương tự như transitionName trong Android, tự động tạo hoạt ảnh cho vị trí, kích thước, cornerRadius, zPosition và backgroundColor giữa các màn hình.
Trong Jetpack Compose, Shared Element Transition được triển khai qua bộ sửa đổi graphicsLayer với hoạt ảnh SharedTransitionScope (Compose 1.7+). Mỗi phần tử dùng chung được đánh dấu bằng sharedElement() với một khóa duy nhất. Hệ thống tự động tạo hoạt ảnh cho các thay đổi vị trí, kích thước, hình dạng, màu sắc và mặt nạ cắt khi điều hướng giữa các hàm composable. Theo Android Developers Blog (2026), Compose Shared Element Transition hỗ trợ AnimatedContent, NavHost và các vùng chứa tùy chỉnh.
import androidx.compose.animation.core.spring
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
// SharedTransitionLayout — vùng chứa cho các phần tử dùng chung
SharedTransitionLayout {
val sharedTransitionState = remember { SharedTransitionState() }
// Màn hình A: danh sách
LazyColumn {
items(items) { item ->
Card(
modifier = Modifier.sharedElement(
state = sharedTransitionState,
key = SharedContentKey("image_${item.id}"),
boundsTransform = { _, _ ->
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
}
)
) {
AsyncImage(model = item.imageUrl, contentDescription = null)
}
}
}
// Màn hình B: chi tiết (hiển thị khi chọn một phần tử)
AnimatedContent(
targetState = selectedItem,
transitionSpec = {
EnterTransition.None using SizeTransform(clip = false)
}
) { item ->
item?.let {
Image(
modifier = Modifier.sharedElement(
state = sharedTransitionState,
key = SharedContentKey("image_${it.id}")
),
painter = rememberAsyncImagePainter(it.imageUrl),
contentDescription = null,
contentScale = ContentScale.FillWidth
)
}
}
}
Compose 1.7+ SharedTransitionScope — một API mới có sẵn trong chế độ thử nghiệm. Nó thay thế việc quản lý hoạt ảnh dùng chung thủ công trong NavHost. Các tính năng chính: boundsTransform cho đường cong hoạt ảnh tùy chỉnh, clipToBounds cho góc bo tròn, zIndex cho thứ tự xếp chồng. Để tạo hoạt ảnh cho hình ảnh với contentScale khác nhau, hãy sử dụng Modifier.sharedElement với trình bao bọc SharedContentKey. Đối với các phiên bản Compose cũ hơn (< 1.7), hãy sử dụng thư viện SharedTransitionScope của Google.
Shared Element Transition có thể gây ra vấn đề hiệu suất nếu không xem xét các giới hạn của nền tảng. Trong Android, vấn đề chính là ChangeImageTransform với hình ảnh lớn (trên 2048 px). Trong iOS — ảnh nhanh với các lớp nặng (CAGradientLayer, CAShapeLayer với nhiều nút). Giải pháp: sử dụng hình ảnh nén cho hoạt ảnh dùng chung và chụp ảnh nhanh với afterScreenUpdates: false.
| Vấn đề | Nguyên nhân | Giải pháp |
|---|---|---|
| Hoạt ảnh giật | Bố cục khác nhau trước/sau khi chuyển tiếp / ảnh nhanh nặng | Sử dụng Placeholder: hiển thị bản xem trước nhỏ trong khi tải |
| Phần tử dùng chung không tạo hoạt ảnh | transitionName không khớp trên cả hai màn hình | Kiểm tra sự khớp chính xác của transitionName trong XML/mã |
| FPS < 40 trên Android | Phần tử dùng chung chứa hệ thống phân cấp View phức tạp | Chỉ chia sẻ ImageView, không phải toàn bộ CardView |
| iOS: tạo tác ảnh nhanh | renderInContext trên lớp có mặt nạ/hoạt ảnh | Sử dụng drawHierarchy(in:afterScreenUpdates: true) |
| Compose: phần tử không xuất hiện | SharedTransitionLayout không bao bọc cả hai màn hình | SharedTransitionLayout phải là cha của cả hai |
// Tối ưu hóa: Placeholder cho hình ảnh trong chuyển tiếp dùng chung
import androidx.core.widget.ImageViewCompat
import com.bumptech.glide.Glide
"> Tải preview trước khi điều hướng
Glide.with(context)
.load(imageUrl)
.override(300, 300) // Giới hạn kích thước cho chuyển tiếp dùng chung
.placeholder(R.drawable.placeholder)
.into(imageView)
> Tải full-resolution trễ sau khi chuyển
val detailImage = findViewById<ImageView>(R.id.detailImage)
detailImage.doOnPreDraw {
Glide.with(context).load(imageUrl).into(detailImage)
}
// iOS: tối ưu hóa ảnh nhanh với drawHierarchy
// snapshotView(afterScreenUpdates: false) — nhanh hơn, bỏ qua các cập nhật đang chờ
// Đối với Views phức tạp: drawHierarchy(in: view.bounds, afterScreenUpdates: true)
Các phương pháp tốt nhất: không thêm quá 3 phần tử dùng chung mỗi lần chuyển tiếp — mỗi phần tử bổ sung làm tăng tải kết xuất. Đối với danh sách (RecyclerView/UICollectionView), hãy tạo phần tử dùng chung trên mục được nhấp, không phải toàn bộ danh sách. Đối với thẻ có góc bo tròn, sử dụng clipToOutline trên Android hoặc cornerRadius trong ảnh nhanh trên iOS. Nếu phần tử dùng chung trên màn hình thứ hai nằm trong ScrollView, hãy đợi hoàn tất bố cục (view.doOnPreDraw) trước khi bắt đầu hoạt ảnh.
Các câu hỏi thường gặp
Khuyến nghị không sử dụng quá 3 phần tử dùng chung cho mỗi lần chuyển tiếp. Mỗi phần tử bổ sung làm tăng tải kết xuất và có thể gây giảm FPS. Tối ưu: 1 hình ảnh + 1 văn bản (tiêu đề). Đối với Android, mỗi phần tử được thêm qua addSharedElement(view, transitionName). Đối với iOS, mỗi ảnh nhanh được thêm vào containerView riêng biệt.
Đảm bảo transitionName được đặt động trong onBindViewHolder: holder.imageView.transitionName = "image_${position}". Đối với FragmentTransaction.addSharedElement, hãy chuyển View cụ thể từ holder. Đối với ActivityOptions, hãy sử dụng Pair.create(holder.imageView, holder.imageView.transitionName). Trì hoãn chuyển tiếp dùng chung cho đến khi hoàn tất bố cục qua postponeEnterTransition() và startPostponedEnterTransition().
Trên Android: thêm ChangeBackground vào TransitionSet hoặc sử dụng Transition tùy chỉnh ghi đè captureStartValues và captureEndValues. Trên iOS: tạo hoạt ảnh cho backgroundColor của ảnh nhanh hoặc sử dụng UIViewPropertyAnimator với thay đổi màu sắc. Trong Jetpack Compose: Modifier.sharedElement tự động tạo hoạt ảnh cho nền qua chuyển tiếp Color. Đối với thẻ, cũng tạo hoạt ảnh cho elevation — từ 2dp trong danh sách đến 8dp trên màn hình chi tiết.
Có, với Navigation Component, Shared Element được hỗ trợ qua NavOptions: val navOptions = NavOptions.Builder().setEnterAnim(R.transition.move).applyNavOptions(fragment). Sử dụng FragmentNavigatorExtras để truyền các phần tử dùng chung: FragmentNavigatorExtras(view to transitionName). Đối với Navigation Compose, hãy sử dụng Modifier.sharedElement từ SharedTransitionScope (Compose 1.7+).
Trên Android, bật Transition Debugging: adb shell setprop debug.transition 1 — trong logcat sẽ xuất hiện thông báo về việc chụp và tạo hoạt ảnh phần tử dùng chung. Bật "Show layout bounds" trong Developer Options để kiểm tra ranh giới trước/sau. Trên iOS, sử dụng Debug View Hierarchy để kiểm tra ảnh nhanh trong containerView. Đối với FPS, sử dụng Profile GPU Rendering (Android) hoặc công cụ Core Animation (iOS).
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm