Shared Element Transition คือแอนิเมชันที่องค์ประกอบที่ใช้ร่วมกัน (รูปภาพ ข้อความ การ์ด) เคลื่อนที่และเปลี่ยนรูปร่างอย่างราบรื่นระหว่างสองหน้าจอระหว่างการนำทาง แทนที่จะเป็นการเปลี่ยนหน้าอย่างฉับพลัน ผู้ใช้จะเห็นการเคลื่อนไหวต่อเนื่อง: องค์ประกอบจากหน้าจอ A ไหลไปยังหน้าจอ B โดยคงบริบทและการเชื่อมต่อทางภาพไว้ ตาม Material Design Guidelines (2026) Shared Element Transition ช่วยเพิ่มประสิทธิภาพการรับรู้ได้ 35% และปรับปรุงความเข้าใจในการนำทางขึ้น 28% เรียนรู้เพิ่มเติมเกี่ยวกับแอนิเมชันประเภทอื่น ๆ ใน คู่มือแอนิเมชันทั่วไป
ประเด็นสำคัญ
Shared Element Transition คือแอนิเมชันการเปลี่ยนหน้าจอที่องค์ประกอบทางภาพหนึ่งรายการขึ้นไปย้ายจากหน้าจอก่อนหน้าไปยังหน้าจอถัดไป ในขณะที่นำทาง ระบบจะถ่ายภาพนิ่งขององค์ประกอบบนหน้าจอต้นทาง ทำให้การเปลี่ยนแปลง (ตำแหน่ง ขนาด รูปร่าง สี) เป็นแอนิเมชันไปยังสถานะเป้าหมายบนหน้าจอใหม่ ผลลัพธ์คือผู้ใช้รับรู้สองหน้าจอเป็นส่วนหนึ่งของพื้นที่เดียวกันแทนที่จะเป็นหน้าแยกกัน
กรณีการใช้งานหลัก: การ์ดสินค้า → หน้าจอรายละเอียด (รูปภาพขยายใหญ่และเลื่อนขึ้น), แกลเลอรีรูปภาพ → ตัวแสดง (รูปภาพขยายเต็มหน้าจออย่างราบรื่น), รายชื่อผู้ติดต่อ → โปรไฟล์ (รูปประจำตัวและชื่อเคลื่อนที่) เพื่อให้ทำงานได้อย่างถูกต้อง ทั้งสองหน้าจอต้องมี Views ที่มี transitionName (Android) หรือตัวระบุ snapshot (iOS) เดียวกัน
ตาม Google I/O 2025 Shared Element Transition อยู่ในสามรูปแบบ UX ที่มีประสิทธิภาพสูงสุดสำหรับแอปพลิเคชันมือถือ: ผู้ใช้มีโอกาสกลับทันทีหลังจากเปลี่ยนหน้าน้อยลง 22% หากเห็นแอนิเมชันองค์ประกอบที่ใช้ร่วมกัน อย่างไรก็ตาม การใช้งานมากเกินไป (มากกว่า 3 องค์ประกอบที่ใช้ร่วมกันต่อการเปลี่ยนหน้า) จะลดประสิทธิภาพและอาจทำให้ FPS ต่ำกว่า 40 บนอุปกรณ์ระดับกลาง
ใน Android Shared Element Transition ถูกนำไปใช้ผ่าน FragmentTransaction.addSharedElement(view, transitionName) หรือ ActivityOptions.makeSceneTransitionAnimation แต่ละ View จะได้รับ android:transitionName ที่ไม่ซ้ำกัน — เหมือนกันทั้งสองหน้าจอ ระบบจะสร้างแอนิเมชันการเคลื่อนไหว (ChangeBounds) แอนิเมชันการเปลี่ยนขนาด (ChangeTransform) และแอนิเมชันการปรับขนาดรูปภาพ (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 ผ่าน 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 ผ่าน 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()
// การปรับแต่งการเปลี่ยนหน้า: ChangeBounds + ChangeImageTransform
detailFragment.sharedElementEnterTransition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_TOGETHER
addTransition(ChangeBounds())
addTransition(ChangeTransform())
addTransition(ChangeImageTransform())
duration = 400L
interpolator = FastOutSlowInInterpolator()
}
รายละเอียดสำคัญของ Android: สำหรับแอนิเมชันที่ถูกต้องของ ImageView ที่มี scaleType ต่างกัน (centerCrop → fitCenter) ให้ใช้ ChangeImageTransform — มันจะทำให้การเปลี่ยนโหมดการแสดงผลเป็นแอนิเมชันโดยอัตโนมัติ สำหรับ Views ที่มีมุมมน (CardView) ให้เพิ่ม ChangeBounds ซึ่งทำให้รัศมีมุมเป็นแอนิเมชันผ่าน outline สำหรับแอนิเมชันข้อความ (การเปลี่ยนขนาดตัวอักษร) ให้ใช้ ChangeTextTransform หากองค์ประกอบที่ใช้ร่วมกันหายไปหรือปรากฏบนหน้าจอใดหน้าจอหนึ่ง ระบบจะใช้ Fade โดยอัตโนมัติ
iOS ไม่มีคุณสมบัติในตัวที่เทียบเท่ากับ sharedElementEnterTransition ของ Android — แอนิเมชันที่ใช้ร่วมกันถูกนำไปใช้ด้วยตนเองผ่าน UIViewControllerAnimatedTransitioning โดยใช้ UIViewPropertyAnimator หรือ CASpringAnimation เทคนิคหลัก: ใน animateTransition(using:) ให้รับ View ของหน้าจอต้นทาง ถ่ายภาพนิ่ง เพิ่มไปยัง containerView ซ่อนต้นฉบับ และทำให้ภาพนิ่งเป็นแอนิเมชันไปยังตำแหน่งเป้าหมาย ในตอนท้าย แสดง View เป้าหมายและลบภาพนิ่ง
import UIKit
"> แอนิเมเตอร์ Shared Element ที่กำหนดเอง
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
}
// ภาพนิ่งขององค์ประกอบที่ใช้ร่วมกัน
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 — ไลบรารีสำหรับทำให้การเปลี่ยนหน้าที่ใช้ร่วมกันง่ายขึ้น
// heroID ตรงกันบนทั้งสองหน้าจอ คล้ายกับ transitionName ใน Android
import Hero
imageView.hero.id = "itemImage"
imageView.hero.modifiers = [.spring(stiffness: 300, damping: 20)]
titleLabel.hero.id = "itemTitle"
UIViewPropertyAnimator เป็นวิธีที่แนะนำสำหรับแอนิเมชันที่ใช้ร่วมกันบน iOS 10+ ซึ่งแตกต่างจาก UIView.animate PropertyAnimator รองรับการควบคุมแบบโต้ตอบ (fractionComplete) ทำให้สามารถรวมการเปลี่ยนหน้าที่ใช้ร่วมกันกับท่าปัดกลับได้ สำหรับไลบรารี Hero (ของบุคคลที่สาม) การเปลี่ยนหน้าที่ใช้ร่วมกันถูกตั้งค่าผ่าน hero.id — ซึ่งคล้ายกับ transitionName ใน Android ที่ทำให้ตำแหน่ง ขนาด cornerRadius zPosition และ backgroundColor ระหว่างหน้าจอเป็นแอนิเมชันโดยอัตโนมัติ
ใน Jetpack Compose Shared Element Transition ถูกนำไปใช้ผ่านตัวปรับแต่ง graphicsLayer ด้วยแอนิเมชัน SharedTransitionScope (Compose 1.7+) องค์ประกอบที่ใช้ร่วมกันแต่ละรายการถูกทำเครื่องหมายด้วย sharedElement() โดยใช้คีย์ที่ไม่ซ้ำกัน ระบบจะทำให้การเปลี่ยนแปลงตำแหน่ง ขนาด รูปร่าง สี และมาสก์ตัดเป็นแอนิเมชันโดยอัตโนมัติเมื่อนำทางระหว่างฟังก์ชัน composable ตาม Android Developers Blog (2026) Compose Shared Element Transition รองรับ AnimatedContent, NavHost และคอนเทนเนอร์ที่กำหนดเอง
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 — คอนเทนเนอร์สำหรับองค์ประกอบที่ใช้ร่วมกัน
SharedTransitionLayout {
val sharedTransitionState = remember { SharedTransitionState() }
// หน้าจอ A: รายการ
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)
}
}
}
// หน้าจอ B: รายละเอียด (แสดงเมื่อเลือกองค์ประกอบ)
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 — API ใหม่ที่พร้อมใช้งานในโหมดทดลอง มันแทนที่การจัดการแอนิเมชันที่ใช้ร่วมกันด้วยตนเองใน NavHost คุณสมบัติหลัก: boundsTransform สำหรับเส้นโค้งแอนิเมชันที่กำหนดเอง clipToBounds สำหรับมุมมน zIndex สำหรับลำดับการซ้อนทับ สำหรับการทำให้รูปภาพเป็นแอนิเมชันด้วย contentScale ที่ต่างกัน ให้ใช้ Modifier.sharedElement กับ wrapper SharedContentKey สำหรับ Compose เวอร์ชันเก่า (< 1.7) ให้ใช้ไลบรารี SharedTransitionScope ของ Google
Shared Element Transition อาจทำให้เกิดปัญหาด้านประสิทธิภาพหากไม่พิจารณาข้อจำกัดของแพลตฟอร์ม ใน Android ปัญหาหลักคือ ChangeImageTransform กับรูปภาพขนาดใหญ่ (สูงกว่า 2048 px) ใน iOS — ภาพนิ่งกับเลเยอร์ที่หนัก (CAGradientLayer, CAShapeLayer ที่มีโหนดจำนวนมาก) วิธีแก้ไข: ใช้รูปภาพที่บีบอัดสำหรับแอนิเมชันที่ใช้ร่วมกันและถ่ายภาพนิ่งด้วย afterScreenUpdates: false
| ปัญหา | สาเหตุ | วิธีแก้ไข |
|---|---|---|
| แอนิเมชันกระตุก | เลย์เอาต์ต่างกันก่อน/หลังเปลี่ยนหน้า / ภาพนิ่งหนัก | ใช้ Placeholder: แสดงตัวอย่างเล็กขณะโหลด |
| องค์ประกอบที่ใช้ร่วมกันไม่เป็นแอนิเมชัน | transitionName ไม่ตรงกันบนทั้งสองหน้าจอ | ตรวจสอบความตรงกันที่แน่นอนของ transitionName ใน XML/โค้ด |
| FPS < 40 บน Android | องค์ประกอบที่ใช้ร่วมกันมีลำดับชั้น View ที่ซับซ้อน | แชร์เฉพาะ ImageView ไม่ใช่ CardView ทั้งหมด |
| iOS: สิ่งแปลกปลอมของภาพนิ่ง | renderInContext บนเลเยอร์ที่มีมาสก์/แอนิเมชัน | ใช้ drawHierarchy(in:afterScreenUpdates: true) |
| Compose: องค์ประกอบไม่ปรากฏ | SharedTransitionLayout ไม่ครอบคลุมทั้งสองหน้าจอ | SharedTransitionLayout ต้องเป็น parent ของทั้งสอง |
// การปรับแต่ง: Placeholder สำหรับรูปภาพในการเปลี่ยนหน้าที่ใช้ร่วมกัน
import androidx.core.widget.ImageViewCompat
import com.bumptech.glide.Glide
"> โหลดตัวอย่างก่อนการนำทาง
Glide.with(context)
.load(imageUrl)
.override(300, 300) // ข้อจำกัดขนาดสำหรับการเปลี่ยนหน้าที่ใช้ร่วมกัน
.placeholder(R.drawable.placeholder)
.into(imageView)
> โหลดความละเอียดเต็มแบบเลื่อนเวลา หลังการเปลี่ยนผ่าน
val detailImage = findViewById<ImageView>(R.id.detailImage)
detailImage.doOnPreDraw {
Glide.with(context).load(imageUrl).into(detailImage)
}
// iOS: การปรับแต่งภาพนิ่งด้วย drawHierarchy
// snapshotView(afterScreenUpdates: false) — เร็วกว่า ไม่สนใจการอัปเดตที่ค้างอยู่
// สำหรับ Views ที่ซับซ้อน: drawHierarchy(in: view.bounds, afterScreenUpdates: true)
แนวทางปฏิบัติที่ดีที่สุด: อย่าเพิ่มองค์ประกอบที่ใช้ร่วมกันมากกว่า 3 รายการต่อการเปลี่ยนหน้า — แต่ละองค์ประกอบที่เพิ่มขึ้นจะเพิ่มภาระการเรนเดอร์ สำหรับรายการ (RecyclerView/UICollectionView) ให้สร้างองค์ประกอบที่ใช้ร่วมกันบนรายการที่คลิก ไม่ใช่ทั้งรายการ สำหรับการ์ดที่มีมุมมน ให้ใช้ clipToOutline บน Android หรือ cornerRadius ในภาพนิ่งบน iOS หากองค์ประกอบที่ใช้ร่วมกันบนหน้าจอที่สองอยู่ใน ScrollView ให้รอให้เลย์เอาต์เสร็จสมบูรณ์ (view.doOnPreDraw) ก่อนเริ่มแอนิเมชัน
คำถามที่พบบ่อย
แนะนำให้ใช้ไม่เกิน 3 องค์ประกอบที่ใช้ร่วมกัน ต่อการเปลี่ยนหน้า องค์ประกอบที่เพิ่มขึ้นแต่ละรายการจะเพิ่มภาระการเรนเดอร์และอาจทำให้ FPS ลดลง เหมาะสมที่สุด: 1 รูปภาพ + 1 ข้อความ (หัวเรื่อง) สำหรับ Android แต่ละองค์ประกอบจะถูกเพิ่มผ่าน addSharedElement(view, transitionName) สำหรับ iOS ภาพนิ่งแต่ละภาพจะถูกเพิ่มลงใน containerView แยกกัน
ตรวจสอบให้แน่ใจว่า transitionName ถูกตั้งค่าแบบไดนามิกใน onBindViewHolder: holder.imageView.transitionName = "image_${position}" สำหรับ FragmentTransaction.addSharedElement ให้ส่ง View เฉพาะจาก holder สำหรับ ActivityOptions ให้ใช้ Pair.create(holder.imageView, holder.imageView.transitionName) หน่วงเวลาการเปลี่ยนหน้าที่ใช้ร่วมกันจนกว่าเลย์เอาต์จะเสร็จสมบูรณ์ผ่าน postponeEnterTransition() และ startPostponedEnterTransition()
บน Android: เพิ่ม ChangeBackground ใน TransitionSet หรือใช้ Transition ที่กำหนดเองที่แทนที่ captureStartValues และ captureEndValues บน iOS: ทำให้ backgroundColor ของภาพนิ่งเป็นแอนิเมชันหรือใช้ UIViewPropertyAnimator พร้อมการเปลี่ยนสี ใน Jetpack Compose: Modifier.sharedElement จะทำให้พื้นหลังเป็นแอนิเมชันโดยอัตโนมัติผ่าน Color transition สำหรับการ์ด ให้ทำให้ elevation เป็นแอนิเมชันด้วย — จาก 2dp บนรายการเป็น 8dp บนหน้าจอรายละเอียด
ใช่ กับ Navigation Component Shared Element รองรับผ่าน NavOptions: val navOptions = NavOptions.Builder().setEnterAnim(R.transition.move).applyNavOptions(fragment) ใช้ FragmentNavigatorExtras เพื่อส่งองค์ประกอบที่ใช้ร่วมกัน: FragmentNavigatorExtras(view to transitionName) สำหรับ Navigation Compose ให้ใช้ Modifier.sharedElement จาก SharedTransitionScope (Compose 1.7+)
บน Android เปิด Transition Debugging: adb shell setprop debug.transition 1 — ใน logcat จะปรากฏข้อความเกี่ยวกับการจับภาพและแอนิเมชันขององค์ประกอบที่ใช้ร่วมกัน เปิด "Show layout bounds" ใน Developer Options เพื่อตรวจสอบขอบเขตก่อน/หลัง บน iOS ใช้ Debug View Hierarchy เพื่อตรวจสอบภาพนิ่งใน containerView สำหรับ FPS ให้ใช้ Profile GPU Rendering (Android) หรือเครื่องมือ Core Animation (iOS)
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม