MotionLayout 및 ViewPager2를 사용하여 애니메이션 목록 작성 방법

안녕하세요!이것은 내가 이 플랫폼에 올린 첫 번째 댓글이니 네가 좋아하길 바란다.댓글을 남기거나 댓글에 답장을 해서 지지를 표시해 주세요.그리고 더 많은 댓글이 곧 올라옵니다!
우리는 매일 각종 사이트와 블로그에서 창의적인 디자이너가 제시한 아름다운 디자인 개념을 볼 수 있지만, 대다수 사람들은 그것을 구축하려고 시도한 적이 없다.당신은 실천 속에서 이러한 포석을 세우는 것이 어떤 것인지 생각해 본 적이 있습니까?현대적인 틀을 사용하는 것은 매우 간단합니까 아니면 너무 번거롭습니까?이 블로그의 목적은 바로 이 수수께끼를 풀고 복잡한 설계를 만드는 첫 번째 자료를 찾아내는 것이다.

구현


MotionLayout(ConstraintLayout 2.0이 발표됨에 따라 MotionLayout이 안정됨) ImageView, AppBars, DrawerLayouts 등 작은 위젯의 애니메이션 제작은 쉬워졌지만 목록 항목은?작은 위젯 애니메이션에 관한 블로그가 많지만, 애니메이션 목록 항목에 관한 블로그는 드물다.예시 항목에 대해 Filmdom 프로그램을 선택하고 로그인 화면을 시도합니다.다음은 어떻게 진행되었는지, 그리고 이런 구조를 구축하는 이해득실을 알아보겠습니다.
최종 버전의 레이아웃은 아래 영상과 같다.

참고: MotionLayout 및 ViewPager2의 기본 지식은 이러한 구성 요소의 작동 방식에 대해 자세히 설명하지 않으므로 이해한다고 생각합니다.
이 블로그의 목적은 애니메이션을 열거하는 것이기 때문에 간결하게 보기 위해서 우리는 모든 다른 내용을 배제할 것이다.

너의 최선의 선택


추천하는 영화를 보여주기 위해 ViewPager2를 사용했습니다. 이것은 RecyclerView를 확장했습니다.XML 레이아웃에서 유일하게 언급할 수 있는 속성은 clipToPadding과 clipChildren입니다. 이 두 속성을 모두false로 설정해야 합니다.속성 clipChildren은 각 하위 뷰를 부모 뷰의 경계 외부에서 그릴 수 있는지, clipToPadding 속성은 부모 뷰 자체에서 하위 뷰를 그릴 수 있는지 여부를 결정합니다.이러한 속성을false로 설정하지 않으면 ViewPager는 두 개의 다가오는 페이지를 편집합니다. 현재 선택한 페이지만 볼 수 있습니다. 이것은 우리가 원하는 것이 아닙니다.이 예에서 속성paddingand는 선택한 페이지의 범위를 좁혔기 때문에 화면에 두 페이지를 더 표시할 수 있는 공간이 있습니다.
<androidx.viewpager2.widget.ViewPager2
    android:layout_width="match_parent"
    android:layout_height=”wrap_content"
    android:clipChildren="false"
    android:clipToPadding="false"
    android:paddingEnd="120dp"  />
ViewPager는 여백을 지원하지 않습니다.LayoutParams. 그래서 나는 경계를 적용하기 위해 세션의 레이아웃을 추가 프레임 레이아웃에 포장해야 한다.배경이 있기 때문에 배치에 채우기를 사용할 수 없습니다. ViewPager 위젯에 경계를 직접 적용하면 선택한 페이지가 화면에서 나가는 것을 볼 수 없습니다.ViewPager 위젯에 채우기를 적용하는 것도 옵션이 아닙니다. CliptPadding과 clipChildren 속성을 설정했기 때문에 이전 페이지에서 볼 수 있습니다. 이것은 우리가 원하는 것이 아닙니다.
디자인에서처럼 선택하지 않은 카드에 퇴색 효과를 내기 위해서는 내부 프레임 레이아웃의 배경을 검은색으로 설정해야 합니다.중요한 것은 배경이 이미지를 불러오는 모양을 따라 검은색 영역을 많이 얻지 못하게 하는 것이다.내가 이 점을 실현하기 위해 다른 방법을 사용하지 않은 것은, 내가 페이드/페이드 효과를 만들어야 하기 때문이다. 유일한 방법은 보기의 불투명도를 바꾸는 것이다.우리는 다음 절에서 이것에 대해 더욱 상세하게 소개할 것이다.세 페이지를 동시에 화면에 표시하려면 set OffscreenPageLimit 방법을 사용하고 ViewPager에서 제한 값을 "3"으로 설정해야 합니다.
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="24dp"
        android:background="@drawable/bg_rounded_circle_drawable">

        <ImageView
               
         />

    </FrameLayout>
</FrameLayout>
애니메이션의 확대/축소 및 페이드 아웃을 위해 ViewPage에서 PageTransformer를 구현했습니다.transformPage를 다시 쓰는 방법에서, 우리는 우선 모든 카드의 위치에 따라 고도를 설정해야 한다.선 뷰가 호환됩니다.set Elevation(page,-abs(position))은 세 장의 카드 창고에 첫 번째 카드의 최고 높이를 설정하고 연속 카드마다 비례에 따라 작은 높이를 설정합니다.이것은 우리에게 필요한 시각적 효과를 주는 카드가 우리에게 다가왔다.다음에 우리가 해야 할 일은 비례인자를 계산하는 것이다. 이렇게 하면 우리는 영화 카드에 정확한 값을 적용할 수 있다.
만약 카드가 화면에 보인다면, 우리의 when 함수는 카드를 축소할 것입니다. 그렇지 않으면 기본값을 가지게 될 것입니다.X축의 변환은 배율 계수에 비례하므로 부드러운 애니메이션을 만들 수 있습니다.이후의 조건은 우리 카드의 영화 포스터의 가시성을 결정했다.중간 위치에 있는 선택된 카드의 가시성은 100%이며 ViewPager의 중심을 벗어나면 가시성이 떨어집니다.우리는 set 알파 방법을 통해 카드의 영화 포스터의 가시성을 변경할 수 있다.이러한 PageTransformer 설정은 예상한 결과를 달성하기 위해 몇 가지 시도와 오류가 필요합니다.
class SliderTransformer(private val offscreenPageLimit: Int) : ViewPager2.PageTransformer {

    companion object {
        private const val DEFAULT_TRANSLATION_X = .0f
        private const val DEFAULT_TRANSLATION_FACTOR = 1.46f
        private const val SCALE_FACTOR = .14f
        private const val DEFAULT_SCALE = 1f
    }

    override fun transformPage(page: View, position: Float) {

        page.apply {
            ViewCompat.setElevation(page, -abs(position))
            val scaleFactor = -SCALE_FACTOR * position + DEFAULT_SCALE
            when (position)  {
                    in 0f..offscreenPageLimit - 1f -> {
                         scaleX = scaleFactor
                         scaleY = scaleFactor
                         translationX = -(width / DEFAULT_TRANSLATION_FACTOR) * position
                    }
                    else -> {
                         translationX = DEFAULT_TRANSLATION_X
                         scaleX = DEFAULT_SCALE
                         scaleY = DEFAULT_SCALE
                   }
            }

            val recommendedMovieIV: ImageView = findViewById(R.id.recommendedMovieIV)
            if (position <= -1.0f || position >= 1.0f) {
                recommendedMovieIV.alpha = 0.5f
            } else if (position == 0.5f) {
                recommendedMovieIV.alpha = 1.0f
            } else if (position < 0.5f) {
                recommendedMovieIV.alpha = 1.0f - abs(position)
            }
        }
    }
}
MotionLayout의 디자인은 하나의 ViewPage2 페이지에 적용되지 않는다는 결론을 얻을 수 있다.페이지 애니메이션에 대해 PageTransformer를 사용해야 합니다. 만약 우리가 정말로 한 페이지에서 MotionLayout을 사용하고 싶다면, 우리는 이를 논리적으로transformPage 방법에 놓을 수 있습니다.이런 방법의 문제는 Position 매개 변수의 값으로MotionLayout의 진도를 추적하는 방법을 찾아야 한다는 것이다. 이것은 쉬운 일이 아니다.ViewPager2/RecyclerView의 모든 항목에 애니메이션을 쉽게 설정할 수 있지만, 이것은 우리가 찾으려는 것이 아닙니다.

금방.


곧 발표될 영화 목록에 대해 ViewPager2도 사용했습니다.Google의 페이지 레이아웃은 MotionLayout에 포장되어 있습니다. 왜냐하면 이것은 슬라이딩 제스처의 경사 애니메이션을 실현하기 위해 사용되기 때문입니다.슬라이딩할 때마다 전체 ViewPager와 모든 페이지에 대한 애니메이션이 설정됩니다.
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:minHeight="240dp"
    app:layoutDescription="@xml/tilt_scene">

    <LinearLayout
            
     />

</androidx.constraintlayout.motion.widget.MotionLayout>
아래의 MotionScene은 말하지 않아도 알 수 있기 때문에 나는 그것을 상세하게 소개하지 않을 것이다.페이지가 오른쪽으로 기울거나 왼쪽으로 기울어지는 것은 제스처 방향에 달려 있다.
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Transition
        android:id="@+id/rightToLeft"
        app:constraintSetEnd="@id/end"
        app:constraintSetStart="@id/start"
        app:duration="1000">

        <OnSwipe
            app:dragDirection="dragLeft"
            app:touchAnchorId="@id/motionContainer" />

        <KeyFrameSet>
            <KeyAttribute
                android:rotationY="0"
                app:framePosition="0"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="-15"
                app:framePosition="25"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="-30"
                app:framePosition="50"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="-15"
                app:framePosition="75"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="0"
                app:framePosition="100"
                app:motionTarget="@id/motionContainer" />
        </KeyFrameSet>
    </Transition>

    <Transition
        android:id="@+id/leftToRight"
        app:constraintSetEnd="@id/end"
        app:constraintSetStart="@id/start"
        app:duration="1000">

        <OnSwipe
            app:dragDirection="dragRight"
            app:touchAnchorId="@+id/motionContainer" />

        <KeyFrameSet>
            <KeyAttribute
                android:rotationY="0"
                app:framePosition="0"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="15"
                app:framePosition="25"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="30"
                app:framePosition="50"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="15"
                app:framePosition="75"
                app:motionTarget="@id/motionContainer" />
            <KeyAttribute
                android:rotationY="0"
                app:framePosition="100"
                app:motionTarget="@id/motionContainer" />
        </KeyFrameSet>
    </Transition>

    <ConstraintSet android:id="@+id/start" />

    <ConstraintSet android:id="@+id/end" />

</MotionScene>
화면에 5페이지를 동시에 표시하기 위해서, 나는 다시 한 번 우리의 ViewPager에서 set OffscreenPageLimit 방법을 사용하여 제한을 '3' 으로 설정해야 한다.저희 ViewPager에 사용자 정의 OnPageChangeCallback을 적용했습니다.이 리셋은 현재 오프셋과 이전 오프셋을 비교하여 제스처의 방향을 정합니다.이 정보를 이용하면realCurrentPosition,nextPosition,realOffset을 계산할 수 있습니다.
realCurrentPosition과 nextPosition 속성을 사용하면 ViewPager에서 두 개의 보이는 페이지를 가져옵니다.왼쪽으로 이동할 때 현재 선택한 페이지를 축소하고 다음 페이지를 확대합니다.오른쪽으로 미끄러지고, 반대로도 마찬가지다.이 리셋은 우리가 페이지를 기울일 수 있도록 제스처의 강도를 확정하는 것도 책임진다.
class UpcomingMovieChangedCallback(private val binding: ActivityMainBinding, private val upcomingMoviesAdapter: GenericMoviesAdapter) : ViewPager2.OnPageChangeCallback() {

    var goingLeft: Boolean by Delegates.notNull()
    private var lastOffset = 0f
    var progress: Float by Delegates.notNull()

    override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
        val realCurrentPosition: Int
        val nextPosition: Int
        val realOffset: Float
        goingLeft = lastOffset > positionOffset
        if (goingLeft) {
            realCurrentPosition = position + 1
            nextPosition = position
            realOffset = 1 - positionOffset
        } else {
            nextPosition = position + 1
            realCurrentPosition = position
            realOffset = positionOffset
        }

        val currentCard = (binding.upcomingMoviesVP[0] as RecyclerView).layoutManager?.findViewByPosition(realCurrentPosition)
        currentCard?.scaleX = (1 + 0.4 * (1 - realOffset)).toFloat()
        currentCard?.scaleY = (1 + 0.4 * (1 - realOffset)).toFloat()
        currentCard?.pivotY = 0f

        val nextCard = (binding.upcomingMoviesVP[0] as RecyclerView).layoutManager?.findViewByPosition(nextPosition)
        nextCard?.scaleX = (1 + 0.4 * realOffset).toFloat()
        nextCard?.scaleY = (1 + 0.4 * realOffset).toFloat()
        nextCard?.pivotY = 0f

        lastOffset = positionOffset
        progress = when (position) {
            position -> positionOffset
            position + 1 -> 1 - positionOffset
            position - 1 -> 1 - positionOffset
            else -> 0f
        }
    }
}
PageTransformer for Uncoming movies는 목록의 페이지 위치와 전체 여백의 음수와 같은 X축에 변환을 적용합니다.사용자 정의 OnPageChangeCallback에서 계산한 값을 사용하여 ViewPager에서 올바른 변환 애니메이션과 변환 진행률을 결정합니다.페이지 장식은 아직 언급되지 않았지만, 밑에 연결된 저장소에서 볼 수 있습니다.
val nextItemVisiblePx = resources.getDimension(R.dimen.viewpager_next_item_visible)  //50dp
val currentItemHorizontalMarginPx = resources.getDimension(R.dimen.viewpager_current_item_horizontal_margin_right)  //230dp
val pageTranslationX = nextItemVisiblePx + currentItemHorizontalMarginPx
val pageTransformer = PageTransformer { page: View, position: Float ->
    page.translationX = -pageTranslationX * position

    if (upcomingMovieChangedCallback.goingLeft) {
        ((page as ViewGroup).getChildAt(0) as MotionLayout).setTransition(R.id.leftToRight)
    } else {
        ((page as ViewGroup).getChildAt(0) as MotionLayout).setTransition(R.id.rightToLeft)
    }
    (page.getChildAt(0) as MotionLayout).progress = upcomingMovieChangedCallback.progress
}
binding.upcomingMoviesVP.setPageTransformer(pageTransformer)

결론


애니메이션을 포함하는 복잡한 레이아웃을 구축하는 것은 MotionLayout과PageTransformer 클래스를 사용하더라도 쉽지 않다.비록 여러 해 전에 유행했던 응용 프로그램이 화면에 여러 페이지를 디자인하고 한 페이지만 돋보이게 했지만, 여전히 이 문제를 처리할 표준화된 방법은 없다.만약 우리가 스크린에 세 개의 페이지를 가지고 있다면, 이것은 매우 쉬운 일이지만, 만약 우리가 스크린에 다섯 개 이상의 페이지를 가지고 있다면, 일을 네가 원하는 대로 실행하기 전에, 너는 매우 골치 아플 것이다. (네가 어떤 작은 위젯을 사용해서 프로젝트 목록을 표시하든지.)
MotionLayout과 ViewPager2는 모두 좋은 도구이지만 상술한 문제를 해결할 수 없다.나는 우리가 흔히 볼 수 있는 행동을 얻기 위해 일련의 복잡한 계산을 작성하는 것은 무의미하다고 생각한다.

더 알고 싶으세요?


너는 GitLab에서 전체 항목을 찾을 수도 있고, 우리의 프로젝트 페이지로 가서 전체 항목을 볼 수도 있다 Filmdom case study.
더 간단한 방법으로 이 문제를 해결하거나 다른 복잡한 구조를 알고 있다면, 그 문제를 보고 싶으면 언제든지 평론을 남겨라.

좋은 웹페이지 즐겨찾기