Shared Element Transition — animacja, w której wspólny element (obraz, tekst, karta) płynnie przemieszcza się i transformuje między dwoma ekranami podczas nawigacji. Zamiast ostrego przejścia użytkownik widzi ciągły ruch: element z ekranu A «przepływa» na ekran B, zachowując kontekst i wizualne powiązanie. Według Material Design Guidelines (2026), Shared Element Transition zwiększa postrzeganą wydajność o 35% i poprawia zrozumienie nawigacji o 28%. Więcej o innych rodzajach animacji przeczytasz w ogólnym przewodniku po animacji.
Najważniejsze
Shared Element Transition — to animacja zmiany ekranów, w której jeden lub kilka wizualnych elementów «przemieszcza się» z poprzedniego ekranu na następny. W momencie nawigacji system robi snapshot elementu na ekranie źródłowym, animuje jego transformację (pozycja, rozmiar, kształt, kolor) do docelowego stanu na nowym ekranie. W rezultacie użytkownik postrzega dwa ekrany jako części jednej przestrzeni, a nie jako osobne strony.
Główne scenariusze użycia: karta produktu → szczegółowy ekran (obraz powiększa się i przechodzi na górę), galeria obrazów → podgląd (obraz płynnie rozszerza się na cały ekran), lista kontaktów → profil (awatar i nazwa przemieszczają się). Do poprawnego działania oba ekrany muszą mieć View z tym samym transitionName (Android) lub identyfikatorem snapshot (iOS).
Według Google I/O 2025, Shared Element Transition znajduje się w trójce najskuteczniejszych wzorców UX dla aplikacji mobilnych: użytkownicy o 22% rzadziej wracają natychmiast po przejściu, jeśli widzieli animację wspólnego elementu. Jednak nadmierne użycie (więcej niż 3 shared elementy na przejście) zmniejsza efekt i może spowodować spadek FPS poniżej 40 na urządzeniach średniej klasy.
W Androidzie Shared Element Transition jest realizowany przez FragmentTransaction.addSharedElement(view, transitionName) lub ActivityOptions.makeSceneTransitionAnimation. Każdemu View przypisuje się unikalny android:transitionName — taki sam na obu ekranach. System automatycznie tworzy animację przemieszczania (ChangeBounds), zmiany rozmiaru (ChangeTransform) i skalowania obrazu (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 przez 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 przez 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()
// Dostosowanie przejścia: ChangeBounds + ChangeImageTransform
detailFragment.sharedElementEnterTransition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_TOGETHER
addTransition(ChangeBounds())
addTransition(ChangeTransform())
addTransition(ChangeImageTransform())
duration = 400L
interpolator = FastOutSlowInInterpolator()
}
Ważne szczegóły Android: do poprawnej animacji ImageView z różnym scaleType (centerCrop → fitCenter) używaj ChangeImageTransform — automatycznie animuje zmianę sposobu wyświetlania. Dla View z zaokrąglonymi rogami (CardView) dodaj ChangeBounds, który animuje promień zaokrąglenia przez outline. Do animacji tekstu (zmiana rozmiaru czcionki) używaj ChangeTextTransform. Jeśli shared element znika lub pojawia się na jednym z ekranów — system automatycznie zastosuje Fade.
W iOS nie ma wbudowanego odpowiednika Androida sharedElementEnterTransition — shared-animacje są implementowane ręcznie przez UIViewControllerAnimatedTransitioning z użyciem UIViewPropertyAnimator lub CASpringAnimation. Główna technika: w animateTransition(using:) pobierz View ekranu źródłowego, zrób jego snapshot, dodaj do containerView, ukryj oryginał i animuj snapshot do docelowej pozycji. Po zakończeniu pokaż docelowe View i usuń snapshot.
import UIKit
"> Niestandardowy animator 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
}
// Snapshot wspólnego elementu
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 — biblioteka do upraszczania shared transitions
// heroID jest zgodny na obu ekranach, analogicznie do transitionName w Androidzie
import Hero
imageView.hero.id = "itemImage"
imageView.hero.modifiers = [.spring(stiffness: 300, damping: 20)]
titleLabel.hero.id = "itemTitle"
UIViewPropertyAnimator — preferowany sposób do shared-animacji na iOS 10+. W przeciwieństwie do UIView.animate, PropertyAnimator obsługuje interaktywną kontrolę (fractionComplete), co pozwala zintegrować shared transition z gestem swipe-back. Dla biblioteki Hero (zewnętrzna) shared transition ustawia się przez hero.id — to odpowiednik transitionName z Androida, który automatycznie animuje pozycję, rozmiar, cornerRadius, zPosition i backgroundColor między ekranami.
W Jetpack Compose Shared Element Transition jest realizowany przez modyfikator graphicsLayer z animacją SharedTransitionScope (Compose 1.7+). Każdy shared element jest oznaczony przez sharedElement() z unikalnym kluczem. System automatycznie animuje zmianę pozycji, rozmiaru, kształtu, koloru i maski przycinania podczas nawigacji między funkcjami composable. Według Android Developers Blog (2026), Compose Shared Element Transition obsługuje AnimatedContent, NavHost i niestandardowe kontenery.
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 — kontener dla shared elementów
SharedTransitionLayout {
val sharedTransitionState = remember { SharedTransitionState() }
// Ekran A: lista
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)
}
}
}
// Ekran B: szczegóły (pokazywane przy wyborze elementu)
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 — nowe API dostępne w trybie eksperymentalnym. Zastępuje ręczne zarządzanie shared-animacjami w NavHost. Kluczowe możliwości: boundsTransform dla niestandardowej krzywej animacji, clipToBounds dla zaokrąglonych rogów, zIndex dla kolejności nakładania. Do animacji obrazów z różnym contentScale używaj Modifier.sharedElement z wrapperem SharedContentKey. Dla wsparcia starszych wersji Compose (< 1.7) używaj biblioteki SharedTransitionScope od Google.
Shared Element Transition może powodować problemy z wydajnością, jeśli nie uwzględnia się ograniczeń platformy. W Androidzie głównym problemem jest ChangeImageTransform z dużymi obrazami (powyżej 2048 px). W iOS — snapshot z ciężkimi warstwami (CAGradientLayer, CAShapeLayer z dużą liczbą węzłów). Rozwiązanie: używaj skompresowanych obrazów do shared-animacji i rób snapshot z afterScreenUpdates: false.
| Problem | Przyczyna | Rozwiązanie |
|---|---|---|
| Szarpana animacja | Różny layout przed i po przejściu / ciężki snapshot | Użyj Placeholder: wyświetlaj mały podgląd podczas ładowania |
| Shared element nie animuje się | transitionName nie jest zgodny na obu ekranach | Sprawdź dokładne dopasowanie transitionName w XML/kodzie |
| FPS < 40 na Androidzie | Shared element zawiera złożoną hierarchię View | Rób shared tylko dla ImageView, nie dla całej CardView |
| iOS: artefakty snapshot | renderInContext na warstwie z maskami/animacjami | Użyj drawHierarchy(in:afterScreenUpdates: true) |
| Compose: element nie pojawia się | SharedTransitionLayout nie obejmuje obu ekranów | SharedTransitionLayout musi być rodzicem dla obu |
// Optymalizacja: Placeholder dla obrazu w shared transition
import androidx.core.widget.ImageViewCompat
import com.bumptech.glide.Glide
"> Ładowanie preview przed nawigacją
Glide.with(context)
.load(imageUrl)
.override(300, 300) // Ograniczenie rozmiaru dla shared transition
.placeholder(R.drawable.placeholder)
.into(imageView)
> Opóźnione ładowanie full-resolution po przejściu
val detailImage = findViewById<ImageView>(R.id.detailImage)
detailImage.doOnPreDraw {
Glide.with(context).load(imageUrl).into(detailImage)
}
// iOS: optymalizacja snapshot z drawHierarchy
// snapshotView(afterScreenUpdates: false) — szybciej ignoruje oczekujące aktualizacje
// Dla złożonych View: drawHierarchy(in: view.bounds, afterScreenUpdates: true)
Najlepsze praktyki: nie dodawaj więcej niż 3 shared elementów na jedno przejście — każdy dodatkowy element zwiększa obciążenie renderowania. Dla list (RecyclerView/UICollectionView) rób shared element na kliknięty item, a nie na całą listę. Dla kart z zaokrąglonymi rogami używaj clipToOutline w Androidzie lub cornerRadius w snapshot na iOS. Jeśli shared element na drugim ekranie znajduje się w ScrollView, poczekaj na zakończenie layout (view.doOnPreDraw) przed rozpoczęciem animacji.
Często zadawane pytania
Zaleca się nie więcej niż 3 shared elementy na jedno przejście. Każdy dodatkowy element zwiększa obciążenie renderowania i może spowodować spadek FPS. Optymalnie: 1 obraz + 1 tekst (tytuł). Dla Androida każdy element dodaje się przez addSharedElement(view, transitionName). Dla iOS każdy snapshot dodaje się do containerView osobno.
Upewnij się, że transitionName jest ustawiony dynamicznie w onBindViewHolder: holder.imageView.transitionName = «image_${position}». Dla FragmentTransaction.addSharedElement przekazuj konkretny View z holder. Dla ActivityOptions używaj Pair.create(holder.imageView, holder.imageView.transitionName). Odłóż shared transition do zakończenia layout przez postponeEnterTransition() i startPostponedEnterTransition().
W Androidzie: dodaj ChangeBackground do TransitionSet lub użyj niestandardowego Transition, nadpisującego captureStartValues i captureEndValues. W iOS: animuj backgroundColor snapshot lub UIViewPropertyAnimator ze zmianą koloru. W Jetpack Compose: Modifier.sharedElement automatycznie animuje background przez Color transition. Dla kart animuj również elevation — od 2dp na liście do 8dp na ekranie szczegółów.
Tak, z Navigation Component Shared Element jest obsługiwany przez NavOptions: val navOptions = NavOptions.Builder().setEnterAnim(R.transition.move).applyNavOptions(fragment). Używaj FragmentNavigatorExtras do przekazywania shared elementów: FragmentNavigatorExtras(view to transitionName). Dla Navigation Compose używaj Modifier.sharedElement z SharedTransitionScope (Compose 1.7+).
W Androidzie włącz Transition Debugging: adb shell setprop debug.transition 1 — w logcat pojawią się komunikaty o przechwytywaniu i animacji shared elementów. Włącz «Show layout bounds» w Developer Options, aby sprawdzić granice przed/po. W iOS używaj Debug View Hierarchy do sprawdzenia snapshot w containerView. Do monitorowania FPS używaj Profile GPU Rendering (Android) lub Core Animation instrument (iOS).
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również