확대/축소 애니메이션으로 뷰 확대

이 과정에서는 '터치로 확대/축소' 애니메이션을 실행하는 방법을 설명합니다. 이러한 애니메이션은 뷰를 미리보기 이미지에서 화면을 가득 채우는 전체 크기 이미지로 애니메이션하는 사진 갤러리 같은 앱에 유용합니다.

다음은 화면을 가득 채우도록 미리보기 이미지를 확대하는 '터치로 확대/축소' 애니메이션입니다.

확대/축소 애니메이션
 

이를 건너뛰고 완벽히 작동하는 예를 보려면 GitHub에서 Wear 스피커 샘플 프로젝트의 UIAnimation 클래스를 확인하세요.

뷰 만들기

확대/축소하려는 콘텐츠의 큰 버전과 작은 버전이 포함된 레이아웃 파일을 만듭니다. 다음 예에서는 클릭 가능한 미리보기 이미지를 위한 ImageButton을 만들고 이미지의 확대된 뷰를 표시하는 ImageView를 만듭니다.

    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="16dp">

            <ImageButton
                android:id="@+id/thumb_button_1"
                android:layout_width="100dp"
                android:layout_height="75dp"
                android:layout_marginRight="1dp"
                android:src="@drawable/thumb1"
                android:scaleType="centerCrop"
                android:contentDescription="@string/description_image_1" />

        </LinearLayout>

        <!-- This initially-hidden ImageView will hold the expanded/zoomed version of
             the images above. Without transformations applied, it takes up the entire
             screen. To achieve the "zoom" animation, this view's bounds are animated
             from the bounds of the thumbnail button above, to its final laid-out
             bounds.
             -->

        <ImageView
            android:id="@+id/expanded_image"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:visibility="invisible"
            android:contentDescription="@string/description_zoom_touch_close" />

    </FrameLayout>
    

확대/축소 애니메이션 설정

레이아웃을 적용했으면 확대/축소 애니메이션을 트리거하는 이벤트 핸들러를 설정합니다. 다음 예에서는 View.OnClickListenerImageButton에 추가하여 사용자가 이미지 버튼을 클릭할 때 확대/축소 애니메이션을 실행합니다.

Kotlin

    class ZoomActivity : FragmentActivity() {

        // Hold a reference to the current animator,
        // so that it can be canceled mid-way.
        private var currentAnimator: Animator? = null

        // The system "short" animation time duration, in milliseconds. This
        // duration is ideal for subtle animations or animations that occur
        // very frequently.
        private var shortAnimationDuration: Int = 0

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_zoom)

            // Hook up clicks on the thumbnail views.

            val thumb1View: View = findViewById(R.id.thumb_button_1)
            thumb1View.setOnClickListener({
                zoomImageFromThumb(thumb1View, R.drawable.image1)
            })

            // Retrieve and cache the system's default "short" animation time.
            shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
        }
        ...
    }
    

자바

    public class ZoomActivity extends FragmentActivity {
        // Hold a reference to the current animator,
        // so that it can be canceled mid-way.
        private Animator currentAnimator;

        // The system "short" animation time duration, in milliseconds. This
        // duration is ideal for subtle animations or animations that occur
        // very frequently.
        private int shortAnimationDuration;

        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_zoom);

            // Hook up clicks on the thumbnail views.

            final View thumb1View = findViewById(R.id.thumb_button_1);
            thumb1View.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    zoomImageFromThumb(thumb1View, R.drawable.image1);
                }
            });

            // Retrieve and cache the system's default "short" animation time.
            shortAnimationDuration = getResources().getInteger(
                    android.R.integer.config_shortAnimTime);
        }
        ...
    }
    

뷰 확대/축소

이제 필요에 따라 일반 크기의 뷰에서 확대/축소된 뷰로 애니메이션해야 합니다. 대개 일반 크기의 뷰 경계에서 확대된 크기의 뷰 경계로 애니메이션해야 합니다. 다음 메서드는 아래 작업을 실행하여 미리보기 이미지에서 더 큰 뷰로 확대하는 확대/축소 애니메이션을 구현하는 방법을 보여줍니다.

  1. 고해상도 이미지를 숨겨진 '확대'된 ImageView에 할당합니다. 다음 예에서는 편의를 위해 UI 스레드에 큰 이미지 리소스를 로드합니다. UI 스레드에서의 차단을 방지하기 위해 이 로드를 별도의 스레드에서 수행한 다음, UI 스레드에 비트맵을 설정할 수 있습니다. 비트맵이 화면 크기보다 크지 않은 것이 이상적입니다.
  2. ImageView의 시작 경계와 끝 경계를 계산합니다.
  3. 시작 경계에서 끝 경계까지 4개의 각 위치 지정 및 크기 조정 속성 X, Y, SCALE_X, SCALE_Y를 동시에 애니메이션합니다. 이러한 4개의 애니메이션은 동시에 시작될 수 있도록 AnimatorSet에 추가됩니다.
  4. 이미지가 확대된 상태에서 사용자가 화면을 터치하는 경우 유사한 애니메이션을 반대로 실행하여 다시 축소합니다. ImageViewView.OnClickListener를 추가하여 이 작업을 실행할 수 있습니다. ImageView는 클릭 시 미리보기 이미지 크기로 다시 축소되고 공개 상태를 GONE으로 설정하여 숨깁니다.

Kotlin

    private fun zoomImageFromThumb(thumbView: View, imageResId: Int) {
        // If there's an animation in progress, cancel it
        // immediately and proceed with this one.
        currentAnimator?.cancel()

        // Load the high-resolution "zoomed-in" image.
        val expandedImageView: ImageView = findViewById(R.id.expanded_image)
        expandedImageView.setImageResource(imageResId)

        // Calculate the starting and ending bounds for the zoomed-in image.
        // This step involves lots of math. Yay, math.
        val startBoundsInt = Rect()
        val finalBoundsInt = Rect()
        val globalOffset = Point()

        // The start bounds are the global visible rectangle of the thumbnail,
        // and the final bounds are the global visible rectangle of the container
        // view. Also set the container view's offset as the origin for the
        // bounds, since that's the origin for the positioning animation
        // properties (X, Y).
        thumbView.getGlobalVisibleRect(startBoundsInt)
        findViewById<View>(R.id.container)
                .getGlobalVisibleRect(finalBoundsInt, globalOffset)
        startBoundsInt.offset(-globalOffset.x, -globalOffset.y)
        finalBoundsInt.offset(-globalOffset.x, -globalOffset.y)

        val startBounds = RectF(startBoundsInt)
        val finalBounds = RectF(finalBoundsInt)

        // Adjust the start bounds to be the same aspect ratio as the final
        // bounds using the "center crop" technique. This prevents undesirable
        // stretching during the animation. Also calculate the start scaling
        // factor (the end scaling factor is always 1.0).
        val startScale: Float
        if ((finalBounds.width() / finalBounds.height() > startBounds.width() / startBounds.height())) {
            // Extend start bounds horizontally
            startScale = startBounds.height() / finalBounds.height()
            val startWidth: Float = startScale * finalBounds.width()
            val deltaWidth: Float = (startWidth - startBounds.width()) / 2
            startBounds.left -= deltaWidth.toInt()
            startBounds.right += deltaWidth.toInt()
        } else {
            // Extend start bounds vertically
            startScale = startBounds.width() / finalBounds.width()
            val startHeight: Float = startScale * finalBounds.height()
            val deltaHeight: Float = (startHeight - startBounds.height()) / 2f
            startBounds.top -= deltaHeight.toInt()
            startBounds.bottom += deltaHeight.toInt()
        }

        // Hide the thumbnail and show the zoomed-in view. When the animation
        // begins, it will position the zoomed-in view in the place of the
        // thumbnail.
        thumbView.alpha = 0f
        expandedImageView.visibility = View.VISIBLE

        // Set the pivot point for SCALE_X and SCALE_Y transformations
        // to the top-left corner of the zoomed-in view (the default
        // is the center of the view).
        expandedImageView.pivotX = 0f
        expandedImageView.pivotY = 0f

        // Construct and run the parallel animation of the four translation and
        // scale properties (X, Y, SCALE_X, and SCALE_Y).
        currentAnimator = AnimatorSet().apply {
            play(ObjectAnimator.ofFloat(
                    expandedImageView,
                    View.X,
                    startBounds.left,
                    finalBounds.left)
            ).apply {
                with(ObjectAnimator.ofFloat(expandedImageView, View.Y, startBounds.top, finalBounds.top))
                with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X, startScale, 1f))
                with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_Y, startScale, 1f))
            }
            duration = shortAnimationDuration.toLong()
            interpolator = DecelerateInterpolator()
            addListener(object : AnimatorListenerAdapter() {

                override fun onAnimationEnd(animation: Animator) {
                    currentAnimator = null
                }

                override fun onAnimationCancel(animation: Animator) {
                    currentAnimator = null
                }
            })
            start()
        }

        // Upon clicking the zoomed-in image, it should zoom back down
        // to the original bounds and show the thumbnail instead of
        // the expanded image.
        expandedImageView.setOnClickListener {
            currentAnimator?.cancel()

            // Animate the four positioning/sizing properties in parallel,
            // back to their original values.
            currentAnimator = AnimatorSet().apply {
                play(ObjectAnimator.ofFloat(expandedImageView, View.X, startBounds.left)).apply {
                    with(ObjectAnimator.ofFloat(expandedImageView, View.Y, startBounds.top))
                    with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X, startScale))
                    with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_Y, startScale))
                }
                duration = shortAnimationDuration.toLong()
                interpolator = DecelerateInterpolator()
                addListener(object : AnimatorListenerAdapter() {

                    override fun onAnimationEnd(animation: Animator) {
                        thumbView.alpha = 1f
                        expandedImageView.visibility = View.GONE
                        currentAnimator = null
                    }

                    override fun onAnimationCancel(animation: Animator) {
                        thumbView.alpha = 1f
                        expandedImageView.visibility = View.GONE
                        currentAnimator = null
                    }
                })
                start()
            }
        }
    }
    

자바

    private void zoomImageFromThumb(final View thumbView, int imageResId) {
        // If there's an animation in progress, cancel it
        // immediately and proceed with this one.
        if (currentAnimator != null) {
            currentAnimator.cancel();
        }

        // Load the high-resolution "zoomed-in" image.
        final ImageView expandedImageView = (ImageView) findViewById(
                R.id.expanded_image);
        expandedImageView.setImageResource(imageResId);

        // Calculate the starting and ending bounds for the zoomed-in image.
        // This step involves lots of math. Yay, math.
        final Rect startBounds = new Rect();
        final Rect finalBounds = new Rect();
        final Point globalOffset = new Point();

        // The start bounds are the global visible rectangle of the thumbnail,
        // and the final bounds are the global visible rectangle of the container
        // view. Also set the container view's offset as the origin for the
        // bounds, since that's the origin for the positioning animation
        // properties (X, Y).
        thumbView.getGlobalVisibleRect(startBounds);
        findViewById(R.id.container)
                .getGlobalVisibleRect(finalBounds, globalOffset);
        startBounds.offset(-globalOffset.x, -globalOffset.y);
        finalBounds.offset(-globalOffset.x, -globalOffset.y);

        // Adjust the start bounds to be the same aspect ratio as the final
        // bounds using the "center crop" technique. This prevents undesirable
        // stretching during the animation. Also calculate the start scaling
        // factor (the end scaling factor is always 1.0).
        float startScale;
        if ((float) finalBounds.width() / finalBounds.height()
                > (float) startBounds.width() / startBounds.height()) {
            // Extend start bounds horizontally
            startScale = (float) startBounds.height() / finalBounds.height();
            float startWidth = startScale * finalBounds.width();
            float deltaWidth = (startWidth - startBounds.width()) / 2;
            startBounds.left -= deltaWidth;
            startBounds.right += deltaWidth;
        } else {
            // Extend start bounds vertically
            startScale = (float) startBounds.width() / finalBounds.width();
            float startHeight = startScale * finalBounds.height();
            float deltaHeight = (startHeight - startBounds.height()) / 2;
            startBounds.top -= deltaHeight;
            startBounds.bottom += deltaHeight;
        }

        // Hide the thumbnail and show the zoomed-in view. When the animation
        // begins, it will position the zoomed-in view in the place of the
        // thumbnail.
        thumbView.setAlpha(0f);
        expandedImageView.setVisibility(View.VISIBLE);

        // Set the pivot point for SCALE_X and SCALE_Y transformations
        // to the top-left corner of the zoomed-in view (the default
        // is the center of the view).
        expandedImageView.setPivotX(0f);
        expandedImageView.setPivotY(0f);

        // Construct and run the parallel animation of the four translation and
        // scale properties (X, Y, SCALE_X, and SCALE_Y).
        AnimatorSet set = new AnimatorSet();
        set
                .play(ObjectAnimator.ofFloat(expandedImageView, View.X,
                        startBounds.left, finalBounds.left))
                .with(ObjectAnimator.ofFloat(expandedImageView, View.Y,
                        startBounds.top, finalBounds.top))
                .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X,
                        startScale, 1f))
                .with(ObjectAnimator.ofFloat(expandedImageView,
                        View.SCALE_Y, startScale, 1f));
        set.setDuration(shortAnimationDuration);
        set.setInterpolator(new DecelerateInterpolator());
        set.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                currentAnimator = null;
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                currentAnimator = null;
            }
        });
        set.start();
        currentAnimator = set;

        // Upon clicking the zoomed-in image, it should zoom back down
        // to the original bounds and show the thumbnail instead of
        // the expanded image.
        final float startScaleFinal = startScale;
        expandedImageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (currentAnimator != null) {
                    currentAnimator.cancel();
                }

                // Animate the four positioning/sizing properties in parallel,
                // back to their original values.
                AnimatorSet set = new AnimatorSet();
                set.play(ObjectAnimator
                            .ofFloat(expandedImageView, View.X, startBounds.left))
                            .with(ObjectAnimator
                                    .ofFloat(expandedImageView,
                                            View.Y,startBounds.top))
                            .with(ObjectAnimator
                                    .ofFloat(expandedImageView,
                                            View.SCALE_X, startScaleFinal))
                            .with(ObjectAnimator
                                    .ofFloat(expandedImageView,
                                            View.SCALE_Y, startScaleFinal));
                set.setDuration(shortAnimationDuration);
                set.setInterpolator(new DecelerateInterpolator());
                set.addListener(new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        thumbView.setAlpha(1f);
                        expandedImageView.setVisibility(View.GONE);
                        currentAnimator = null;
                    }

                    @Override
                    public void onAnimationCancel(Animator animation) {
                        thumbView.setAlpha(1f);
                        expandedImageView.setVisibility(View.GONE);
                        currentAnimator = null;
                    }
                });
                set.start();
                currentAnimator = set;
            }
        });
    }