본문 바로가기

ANDROID/UI - UX

[안드로이드] Elevation 효과가 적용된 CardView ViewPager 구현하기

 

이번에 매터리얼 카드뷰 형태의 ViewPager를 구현해야 하는 요구사항이 생기면서,

 

일전에 한번 스쳐본 적이 있던 레퍼지토리에 다시 방문했다.

 

 

rubensousa/ViewPagerCards

ViewPager cards inspired by Duolingo. Contribute to rubensousa/ViewPagerCards development by creating an account on GitHub.

github.com

 

해당 레퍼지토리에서는 일반적인 CardView 형태의 ViewPager 구현은 물론 Elevation 효과까지 적용 가능한 코드들을 제공하고 있다.

 

JAVA 기반 소스이기에 이를 프로젝트에 적용하면서 코틀린 및 binding된 요소들과 섞는 작업을 했다.

 

작업을 진행하면서 필요한 요소들과 과정들을 간단히 정리하고자 한다.


< 구현 환경 >

- androidX 환경

- viewBinding 및 dataBinding 적용

- Kotlin

 

< 필요 구성요소 >

 

1. interface CardAdapter 

2. class CardItem

3. class CardPagerAdapter

4. layout card_adapter

5. ViewPager를 보여줄 레이아웃 코드
6. ViewPager를 설정할 코드

 

< 과정 >

 

1. interface CardAdpater 생성

 

interface CardAdapter {

    fun getBaseElevation(): Float
    fun getCardViewAt(position: Int): CardView
    fun getCount(): Int

    companion object {
        const val MAX_ELEVATION_FACTOR = 8
    }
}

 

 

getBaseElevation은 기본으로 선택된 카드를 띄우는 Elevation 값을 가져오는 getter, getCardViewAt은 해당 포지션의 CardView를 가져오는 getter, getCount는 일반적으로 ViewPager에서 사용하던 getCount의 역할로 사용한다.

 

MAX_ELEVATION_FACTOR는 기본값을 8로 지정하고 있는데, 값이 높아질 수록 좌,우측의 카드가 보이지 않게 되는 점을 참조하면 되겠다.

 

2. CardItem class 생성

 

class CardItem {

    private var mTextResource: String
    private var mTitleResource : String

    constructor(title: String, text: String) {
        mTitleResource = title
        mTextResource = text
    }

    fun getText(): String {
        return mTextResource
    }

    fun getTitle(): String {
        return mTitleResource
    }
}

 

 

1개의 카드 뷰에 보여주고자 하는 요소들을 해당 클래스에 정의한다.

 

3. card_adapter layout xml 생성

 

<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/cardView"
    app:cardCornerRadius="15dp"
    app:cardUseCompatPadding="true"
    android:layout_width="300dp"
    android:layout_height="wrap_content"
    android:layout_gravity="center">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:background="#61FFFFFF"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/middle_section_constraint"
                android:layout_width="0dp"
                android:layout_height="350dp"
                android:background="@drawable/ic_launcher_foreground"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toTopOf="@id/bottom_section_constraint"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent">

            </androidx.constraintlayout.widget.ConstraintLayout>

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/bottom_section_constraint"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:padding="50dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/middle_section_constraint">

                <TextView
                    android:id="@+id/content_text"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:text="Card always wins."
                    android:textSize="20sp"
                    android:gravity="center"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintLeft_toLeftOf="parent"
                    app:layout_constraintRight_toRightOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

            </androidx.constraintlayout.widget.ConstraintLayout>

        </androidx.constraintlayout.widget.ConstraintLayout>
    </FrameLayout>

</androidx.cardview.widget.CardView>

 

CardView를 최상위 레이아웃으로 가지는 card_view 레이아웃을 구성한다. CardView 아래로는 FrameLayout을 적용했고, FrameLayout 아래는 ConstraintLayout을 사용해 본인이 원하던 구성을 적용했다.

 

여기서 CardView의 cardCornerRadius를 사용해 카드 모서리의 둥근 정도를 조정할 수 있다.

 

 

상단 코드로는 이러한 프리뷰가 나온다.

 

4. CardPagerAdapter class 생성

 

class CardPagerAdapter(val context: Context): CardAdapter, PagerAdapter(){
    private var mViews: MutableList<CardView> = mutableListOf()
    private var mData: MutableList<CardItem> = mutableListOf()
    private lateinit var binding : MainCardAdapterBinding
    private var mBaseElevation = 0f

    override fun getBaseElevation(): Float {
        return mBaseElevation
    }

    override fun getCardViewAt(position: Int): CardView {
        return mViews[position]
    }

    fun addCardItem(item: CardItem) {
        mData.add(item)
    }

    override fun instantiateItem(container: ViewGroup, position: Int): Any {
        val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)
                as LayoutInflater

        binding = MainCardAdapterBinding.inflate(inflater)
        binding.contentText.text = mData[position].getText()

        binding.cardView.maxCardElevation = mBaseElevation * MAX_ELEVATION_FACTOR

        if (mBaseElevation == 0f) {
            mBaseElevation = binding.cardView.cardElevation
        }

        binding.cardView.maxCardElevation = mBaseElevation * MAX_ELEVATION_FACTOR

        mViews.add(binding.cardView)
        container.addView(binding.root)

        return binding.root
    }

    override fun isViewFromObject(view: View, `object`: Any): Boolean {
        return view == `object`
    }

    override fun getCount(): Int {
        return mData.size
    }

    fun getRegisteredView(position: Int): CardView? {
        return mViews[position]
    }
}

 

 

CardAdapter와 PagerAdapter를 상속받는 CardPagerAdapter를 만든다.

 

기타 코드들은 일반 ViewPager를 구성할 때의 모습과 비슷한데, instantiateItem 내의 코드들을 신경써서 적용해주어야 한다.

 

이곳에서 CardAdapter interface에서 지정한 MAX_ELEVATION_FACTOR를 기반으로 카드들의 Elevation 효과의 정도를 적용하게 된다.

 

5. ViewPager를 보여줄 레이아웃 코드

 

...

<androidx.viewpager.widget.ViewPager
        android:id="@+id/card_view_pager"
        android:layout_width="match_parent"
        android:layout_height="500dp"
        android:clipToPadding="false"
        android:overScrollMode="never"
        android:padding="30dp"
        android:layout_marginTop="50dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/mainDots"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"/>
        
        
        ...

 

최종적으로 카드들을 보여줄 ViewPager의 레이아웃 코드를 보여주고자 하는 View의 레이아웃 코드에 적용한다.

 

여기서 clipToPadding 옵션을 필히 false로 넣어주어야 이전 카드들과 다음 카드들이 보이는 효과를 얻을 수 있을 것이다.

 

6. ViewPager를 설정할 클래스 코드 

 

override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        binding = CardViewFragmentBinding.inflate(inflater, container, false)

        val testCardAdapter =
            CardPagerAdapter(activity!!.applicationContext)
        testCardAdapter.addCardItem(
            CardItem(
                "First Card",
                "First Card"
            )
        )
        testCardAdapter.addCardItem(
            CardItem(
                "Second Card",
                "Second Card"
            )
        )
        testCardAdapter.addCardItem(
            CardItem(
                "Third Card",
                "Third Card"
            )
        )

        var mLastOffset = 0f

        binding.cardViewPager.adapter = testCardAdapter
        binding.cardViewPager.offscreenPageLimit = 3
        binding.cardViewPager.currentItem = 0
        
        // 별도의 인덱스를 보여주는 도트 TabView 링크. ViewPager만 구성시에 필요하지 않음.
        binding.mainDots.setupWithViewPager(binding.cardViewPager)
        
        binding.cardViewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
            override fun onPageScrollStateChanged(state: Int) {

            }

            override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
                val realCurrentPosition: Int
                val nextPosition: Int
                val baseElevation: Float = (binding.cardViewPager.adapter as CardPagerAdapter).getBaseElevation()
                val realOffset: Float
                val goingLeft: Boolean = mLastOffset > positionOffset

                if (goingLeft) {
                    realCurrentPosition = position + 1
                    nextPosition = position
                    realOffset = 1 - positionOffset
                } else {
                    nextPosition = position + 1
                    realCurrentPosition = position
                    realOffset = positionOffset
                }

                if (nextPosition > (binding.cardViewPager.adapter as CardPagerAdapter).count - 1
                    || realCurrentPosition > (binding.cardViewPager.adapter as CardPagerAdapter).count - 1) {
                    return
                }

                val currentCard: CardView = (binding.cardViewPager.adapter as CardPagerAdapter).getCardViewAt(realCurrentPosition)

                currentCard.scaleX = (1 + 0.1 * (1 - realOffset)).toFloat()
                currentCard.scaleY = (1 + 0.1 * (1 - realOffset)).toFloat()

                currentCard.cardElevation = baseElevation + (baseElevation
                        * (CardAdapter.MAX_ELEVATION_FACTOR - 1) * (1 - realOffset))

                val nextCard: CardView = (binding.cardViewPager.adapter as CardPagerAdapter).getCardViewAt(nextPosition)
                
                nextCard.scaleX = (1 + 0.1 * realOffset).toFloat()
                nextCard.scaleY = (1 + 0.1 * realOffset).toFloat()

                nextCard.cardElevation = baseElevation + (baseElevation
                        * (CardAdapter.MAX_ELEVATION_FACTOR - 1) * realOffset)

                mLastOffset = positionOffset
            }

            override fun onPageSelected(position: Int) {

            }
        })

        return binding.root
    }

 

 

마지막으로 ViewPager에 넣을 카드들과 Interaction을 적용하기 위한 코드를 적용한다. 본인의 경우 Fragment에 CardView를 보여주기 위해 onCreateView에서 설정을 진행했다.

 

다소 복잡한 감이 있지만 골자를 말하면 최근 offset(ViewPager의 이동값)과 다음 offset의 비교를 통해 카드를 어느방향으로 움직이는지 확인하고, 이에 맞는 scale 값을 CardView에 적용해 끌려오는, 혹은 선택한 카드를 위로 나오게끔 보이도록 하는 것이다.

 

 

완성된 모습

 

이 과정까지 마치게 되면, 입체감과 그림자 효과까지 더해진 예쁜 CardView ViewPager를 감상할 수 있다. :)

 

< 적용 소스코드 >

 

victory316/MySampleKotlin

Android Application with Kotlin, actively using Jetpack and architecture patterns - victory316/MySampleKotlin

github.com