Android

[Java][Android] Shared Element Transition 사용하여 화면이 커지면서 전환 / 부드럽게

어렵지만 2025. 7. 21. 19:06

 

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' // 최신 버전 확인

}

 

시연영상