Shared Element Transition을 사용해야 할까요?
앱 사용중에 목록에서 이미지를 탭했을 때 상세 화면으로 넘어갈 때 이미지가 부드럽게 커지면서 나타나거나, 목록 아이템의 텍스트가 상세 화면의 제목으로 자연스럽게 이어지는 것을 본 적이 있으신가요? 바로 그것이 Shared Element Transition입니다.
Shared Element Transition, 어디에 사용하면 좋을까요?
이미지 목록 & 상세 보기:
가장 대표적이고 흔하게 사용되는 예시입니다. 이미지 목록에서 특정 이미지를 탭하면, 해당 이미지가 부드럽게 커지면서 상세 화면의 이미지로 전환됩니다. 텍스트나 설명도 함께 전환될 수 있습니다.
예시: 소셜 미디어 앱의 피드, 사진 갤러리 앱
뉴스 기사 목록 & 상세 기사:
뉴스 기사 목록에서 헤드라인 텍스트나 썸네일 이미지를 탭했을 때, 해당 요소가 상세 기사 화면의 제목이나 주요 이미지로 자연스럽게 이어지도록 합니다.
예시: 뉴스 앱, 블로그 앱
상품 목록 & 상품 상세 페이지:
쇼핑 앱에서 상품 목록에 있는 상품 이미지를 탭하면, 해당 이미지가 상품 상세 페이지의 메인 이미지로 확대되어 나타납니다. 상품명이나 가격 등 텍스트 정보도 함께 전환될 수 있습니다.
예시: 이커머스 앱, 쇼핑몰 앱
프로필 목록 & 프로필 상세:
사용자 목록에서 특정 사용자를 탭했을 때, 프로필 이미지나 이름이 상세 프로필 화면의 해당 요소로 부드럽게 전환됩니다.
예시: 연락처 앱, 소셜 네트워킹 앱
동영상 썸네일 목록 & 동영상 플레이어:
동영상 목록에서 썸네일을 탭하면, 썸네일이 동영상 플레이어 화면의 비디오 창으로 전환되면서 재생이 시작됩니다.
알림 목록 & 상세 알림:
푸시 알림 목록에서 특정 알림을 탭했을 때, 알림의 아이콘이나 텍스트가 해당 콘텐츠를 보여주는 화면으로 부드럽게 전환될 수 있습니다.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SET_Test"
tools:targetApi="31">
<activity
android:name=".ImageListActivity"
android:exported="true"> <!-- ImageListActivity가 메인으로 실행된다면 exported="true" -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ImageDetailActivity"
android:exported="false"
android:transitionName="shared_image"> <!-- transitionName은 Activity 태그가 아니라 View 태그에 적용되는 속성입니다. -->
<!-- Shared Element Transition을 사용하므로, Activity 태그에 transitionName은 불필요합니다. -->
<!-- Activity 자체에 transitionName을 설정하는 것은 일반적이지 않으며, View에 적용해야 합니다. -->
</activity>
</application>
</manifest>
ImageDetailActivity
import androidx.annotation.NonNull; // @NonNull 추가
import androidx.annotation.Nullable; // @Nullable 추가
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.ViewCompat;
import androidx.core.app.SharedElementCallback;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.widget.ImageView;
import android.widget.TextView;
// import android.transition.ChangeBounds; // Glide의 Transition을 사용할 것이므로, Android system의 Transition은 불필요할 수 있습니다.
// import android.transition.Transition; // 이것도 Glide의 Transition과 겹치므로 주의
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition; // <-- Glide의 Transition 임포트
public class ImageDetailActivity extends AppCompatActivity {
private ImageView imageViewDetail;
private TextView textViewDetail;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_image_detail);
imageViewDetail = findViewById(R.id.imageViewDetail);
textViewDetail = findViewById(R.id.textViewDetail);
int imageUrl = getIntent().getIntExtra("image_url", -1);
String title = getIntent().getStringExtra("image_title");
// String description = getIntent().getStringExtra("image_description"); // 현재 코드에서 description은 사용되지 않음
if (imageUrl != -1) {
Glide.with(this)
.load(imageUrl)
.apply(RequestOptions.centerCropTransform())
.into(new CustomTarget<Drawable>() { // Drawable을 타겟으로 합니다.
@Override
// --- 여기가 중요 ---
// 시그니처를 정확히 맞춰야 합니다.
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
imageViewDetail.setImageDrawable(resource);
// Glide의 transition 객체를 사용할 경우, 여기에 코드를 추가할 수 있습니다.
// 하지만 Shared Element Transition은 보통 시스템이 처리하므로,
// Glide 자체의 transition은 필요 없을 수 있습니다.
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
imageViewDetail.setImageDrawable(placeholder);
}
});
}
textViewDetail.setText(title);
}
}
ImageListActivity
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityOptionsCompat; // ActivityOptionsCompat 사용
import androidx.core.view.ViewCompat; // ViewCompat 사용
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.Glide; // 이미지 로딩 라이브러리 (Glide 예시)
import com.bumptech.glide.request.RequestOptions;
import java.util.ArrayList;
import java.util.List;
public class ImageListActivity extends AppCompatActivity {
private RecyclerView recyclerView;
private ImageAdapter adapter;
private List<ImageData> imageList; // 이미지 데이터 클래스 (아래에 정의)
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Activity 전환 애니메이션 활성화
// getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS); // API Level 21+
// ActivityOptionsCompat 사용 시 별도 호출 불필요 (하지만 명시적으로 해주는 것도 좋음)
setContentView(R.layout.activity_image_list);
recyclerView = findViewById(R.id.recyclerView);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
imageList = new ArrayList<>();
// 샘플 데이터 추가 (실제로는 API 호출 등으로 가져옴)
imageList.add(new ImageData(R.drawable.sample_image1, "Sample Image 1", "Description for Sample 1"));
imageList.add(new ImageData(R.drawable.sample_image2, "Sample Image 2", "Description for Sample 2"));
imageList.add(new ImageData(R.drawable.sample_image3, "Sample Image 3", "Description for Sample 3"));
// (res/drawable 폴더에 sample_image1.jpg, sample_image2.jpg, sample_image3.jpg 파일이 있어야 함)
adapter = new ImageAdapter(imageList, this);
recyclerView.setAdapter(adapter);
}
// ViewHolder에서 호출될 클릭 리스너
public void onItemClick(ImageData imageData, ImageView sharedImageView, TextView sharedTextView) {
Intent intent = new Intent(this, ImageDetailActivity.class);
intent.putExtra("image_url", imageData.getImageUrl()); // 이미지 리소스 ID 전달
intent.putExtra("image_title", imageData.getTitle());
intent.putExtra("image_description", imageData.getDescription());
// Shared Element Transition 설정
ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(
this,
// 공유할 뷰와 해당 뷰의 transitionName 쌍으로 전달
// ViewCompat.getTransitionName()을 사용하여 뷰의 transitionName을 가져옴
androidx.core.util.Pair.create(sharedImageView, ViewCompat.getTransitionName(sharedImageView)),
androidx.core.util.Pair.create(sharedTextView, ViewCompat.getTransitionName(sharedTextView))
);
startActivity(intent, options.toBundle());
}
// 이미지 데이터 클래스 (별도 파일로 만들거나 inner class로 정의)
public static class ImageData {
private int imageUrl; // drawable 리소스 ID
private String title;
private String description;
public ImageData(int imageUrl, String title, String description) {
this.imageUrl = imageUrl;
this.title = title;
this.description = description;
}
public int getImageUrl() { return imageUrl; }
public String getTitle() { return title; }
public String getDescription() { return description; }
}
// Adapter 클래스 (간략화)
private class ImageAdapter extends RecyclerView.Adapter<ImageAdapter.ImageViewHolder> {
private List<ImageData> dataList;
private ImageListActivity activity;
public ImageAdapter(List<ImageData> dataList, ImageListActivity activity) {
this.dataList = dataList;
this.activity = activity;
}
@Override
public ImageViewHolder onCreateViewHolder(android.view.ViewGroup parent, int viewType) {
View view = getLayoutInflater().inflate(R.layout.item_image, parent, false);
return new ImageViewHolder(view);
}
@Override
public void onBindViewHolder(ImageViewHolder holder, int position) {
ImageData data = dataList.get(position);
// Glide 라이브러리로 이미지 로딩 (res ID 사용)
Glide.with(activity)
.load(data.getImageUrl())
.apply(RequestOptions.centerCropTransform()) // 중앙 자르고 채우기
.into(holder.imageViewItem);
holder.textViewItem.setText(data.getTitle());
// ViewHolder의 클릭 리스너 설정
holder.itemView.setOnClickListener(v -> {
activity.onItemClick(data, holder.imageViewItem, holder.textViewItem);
});
}
@Override
public int getItemCount() {
return dataList.size();
}
public class ImageViewHolder extends RecyclerView.ViewHolder {
ImageView imageViewItem;
TextView textViewItem;
public ImageViewHolder(View itemView) {
super(itemView);
imageViewItem = itemView.findViewById(R.id.imageViewItem);
textViewItem = itemView.findViewById(R.id.textViewItem);
// itemView.findViewById(R.id.imageViewItem)의 transitionName은 XML에서 설정됨
// ViewCompat.setTransitionName(imageViewItem, "shared_image");
// ViewCompat.setTransitionName(textViewItem, "shared_text");
}
}
}
}
activity_image_detail
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ImageDetailActivity">
<ImageView
android:id="@+id/imageViewDetail"
android:layout_width="0dp"
android:layout_height="300dp"
android:scaleType="centerCrop"
android:transitionName="shared_image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/textViewDetail"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textViewDetail"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textSize="20sp"
android:textStyle="bold"
android:gravity="center_horizontal"
android:transitionName="shared_text"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageViewDetail"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="Sample Image Detail Title" />
</androidx.constraintlayout.widget.ConstraintLayout>
activity_image_list
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ImageListActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/item_image"/> <!-- 이미지 아이템 레이아웃 -->
</androidx.constraintlayout.widget.ConstraintLayout>
item_image
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<ImageView
android:id="@+id/imageViewItem"
android:layout_width="100dp"
android:layout_height="100dp"
android:scaleType="centerCrop"
android:transitionName="shared_image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textViewItem"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textSize="16sp"
android:transitionName="shared_text"
app:layout_constraintStart_toEndOf="@+id/imageViewItem"
app:layout_constraintTop_toTopOf="@+id/imageViewItem"
app:layout_constraintEnd_toEndOf="parent"
tools:text="Sample Image Title" />
</androidx.constraintlayout.widget.ConstraintLayout>
dependencies {
implementation 'com.github.bumptech.glide:glide:4.16.0' // 최신 버전 확인
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' // 최신 버전 확인
}
시연영상
'Android' 카테고리의 다른 글
[Java][Android] BuildConfig을 사용하여 안드로이드 앱에서 특정 값을 전역으로 사용하기 / 모든 클래스 사용 / (1) | 2025.08.02 |
---|---|
[Java][Android] RXJava 사용하기 / AsyncTask의 대체 (1) | 2025.07.13 |
[Java][Android] 안드로이드 자바 버튼 연속, 연타 방지 방법 (5) | 2025.06.02 |
[Java][Android] SharedPreferences 데이터 암호화 하기! (1) | 2025.05.26 |
[Java][Android] 화면 회전, 메모리 부족 onSaveInstanceState()로 데이터 지키기 (2) | 2025.05.19 |