Hanbit the Developer

[Kotlin] ViewPager2에 custom indicator 적용하기(without TabLayout) 본문

Mobile/Android

[Kotlin] ViewPager2에 custom indicator 적용하기(without TabLayout)

hanbikan 2022. 8. 6. 23:56

서론

결과는 위와 같습니다.

 

구현

먼저 구현하려는 커스텀 뷰는 다음과 같은 특징이 있습니다.

1. 선택된 아이템의 indicator의 width와 color가 다르다.

2. 위 특성의 변화가 연속적으로 이루어진다.

 

layout_custom_indicator.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/layout"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:gravity="center_horizontal"/>

    </FrameLayout>
</layout>

뷰가 그려질 레이아웃입니다.

 

layout_indicator.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_vertical"
    android:orientation="horizontal"
    android:clipToPadding="false">

    <ImageView
        android:id="@+id/image_dot"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:background="@drawable/indicator_background" />
</LinearLayout>

각 indicator의 레이아웃입니다.

 

indicator_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@android:color/white" />
    <corners android:radius="0dp" />
</shape>

위 layout_indicator에서 참조하는 리소스 파일입니다. 여기서는 shape가 rectangle이지만, 이후에 radius를 줌으로써 둥그런 indicator를 그리게 됩니다.

 

attrs.xml

/res/values/attrs.xml 내부에 다음과 같은 코드를 작성하여 커스텀 뷰에 특성을 부여합니다.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomIndicator">
        <attr name="customIndicatorSize" format="dimension"/>
        <attr name="selectedIndicatorWidthScale" format="integer"/>
        <attr name="indicatorMargin" format="dimension"/>
        <attr name="indicatorColor" format="color"/>
        <attr name="selectedIndicatorColor" format="color"/>
    </declare-styleable>
</resources>

위 코드를 작성함으로써 커스텀 뷰에 아래와 같은 코드가 작성이 가능해집니다.

app:selectedIndicatorColor="@color/light_blue"

 

[⭐️] CustomIndicator.kt(중요!)

/**
 * A customized indicator view for [ViewPager2]
 */
class CustomIndicator @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
): FrameLayout(context, attrs, defStyle) {

    private lateinit var binding: LayoutCustomIndicatorBinding

    private var indicatorSize = 20
    private var indicatorMargin = 15
    private var indicatorRadius = indicatorSize*0.5f
    // Determines how large the selected indicator will be compared to the others.
    private var selectedIndicatorWidthScale = 2

    private var indicatorColor = ContextCompat.getColor(context, R.color.light_gray)
    private var selectedIndicatorColor = ContextCompat.getColor(context, R.color.light_blue)

    private var indicators = mutableListOf<ImageView>()

    init {
        initializeView(context)
        getAttrs(attrs)
    }

    private fun initializeView(context: Context) {
        binding = LayoutCustomIndicatorBinding.inflate(LayoutInflater.from(context), this, true)
    }

    private fun getAttrs(attrs: AttributeSet?) {
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomIndicator)
        setTypedArray(typedArray)
    }

    private fun setTypedArray(typedArray: TypedArray) {
        indicatorSize = typedArray.getDimensionPixelSize(R.styleable.CustomIndicator_customIndicatorSize, indicatorSize)
        indicatorRadius = indicatorSize * 0.5f
        selectedIndicatorWidthScale = typedArray.getDimensionPixelSize(R.styleable.CustomIndicator_selectedIndicatorWidthScale, selectedIndicatorWidthScale)
        indicatorMargin = typedArray.getDimensionPixelSize(R.styleable.CustomIndicator_indicatorMargin, indicatorMargin)

        indicatorColor = typedArray.getColor(R.styleable.CustomIndicator_indicatorColor, indicatorColor)
        selectedIndicatorColor = typedArray.getColor(R.styleable.CustomIndicator_selectedIndicatorColor, selectedIndicatorColor)

        typedArray.recycle()
    }


    /**
     * @param viewPager2 A [ViewPager2] which [CustomIndicator] refers to.
     * @param startPosition A start position of [ViewPager2].
     */
    fun setupViewPager2(viewPager2: ViewPager2, startPosition: Int) {
        val itemCount = viewPager2.adapter?.itemCount?: 0
        for(i in 0 until itemCount) addIndicator(i, startPosition)

        viewPager2.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() {
            override fun onPageScrolled(
                position: Int,
                positionOffset: Float,
                positionOffsetPixels: Int
            ) {
                super.onPageScrolled(position, positionOffset, positionOffsetPixels)

                val absoluteOffset = position + positionOffset
                val firstIndex = kotlin.math.floor(absoluteOffset).toInt()
                val secondIndex = kotlin.math.ceil(absoluteOffset).toInt()

                // To fix an ui problem which occurs when firstIndex is same as secondIndex
                if (firstIndex != secondIndex) {
                    refreshTwoIndicatorColor(firstIndex, secondIndex, positionOffset)
                    refreshTwoIndicatorWidth(firstIndex, secondIndex, positionOffset)
                }
            }
        })
    }

    private fun addIndicator(index: Int, selectedPosition: Int) {
        val indicator = LayoutInflater.from(context).inflate(R.layout.layout_indicator, this, false)

        // Set color
        val imageView = indicator.findViewById<ImageView>(R.id.image_indicator)
        imageView.background = (imageView.background as GradientDrawable).apply {
            setColor(if (index == selectedPosition) selectedIndicatorColor else indicatorColor)
        }

        // Set size, margin
        val params = imageView.layoutParams as LinearLayout.LayoutParams
        params.height = indicatorSize
        params.width = if (index == selectedPosition) selectedIndicatorWidthScale * indicatorSize else indicatorSize
        params.setMargins(indicatorMargin, 0, 0, 0)
        params.setMargins(0, 0, indicatorMargin, 0)

        // Set cornerRadius to the rectangle background then it will be shown as a circle
        (imageView.background as GradientDrawable).cornerRadius = indicatorRadius

        indicators.add(imageView)
        binding.layout.addView(indicator)
    }

    private fun refreshTwoIndicatorColor(
        firstIndex: Int,
        secondIndex: Int,
        positionOffset: Float
    ) {
        // Blend selectedIndicatorColor and indicatorColor by positionOffset
        val firstColor = ColorUtils.blendARGB(selectedIndicatorColor, indicatorColor, positionOffset)
        val secondColor = ColorUtils.blendARGB(indicatorColor, selectedIndicatorColor, positionOffset)

        indicators[firstIndex].background = (indicators[firstIndex].background as GradientDrawable).apply { setColor(firstColor) }
        indicators[secondIndex].background = (indicators[secondIndex].background as GradientDrawable).apply { setColor(secondColor) }
    }

    private fun refreshTwoIndicatorWidth(
        firstIndex: Int,
        secondIndex: Int,
        positionOffset: Float
    ) {
        val firstWidth = calculateWidthByOffset(positionOffset)
        val secondWidth = calculateWidthByOffset(1 - positionOffset)

        val firstParams = indicators[firstIndex].layoutParams
        val secondParams = indicators[secondIndex].layoutParams
        firstParams.width = firstWidth.toInt()
        secondParams.width = secondWidth.toInt()

        indicators[firstIndex].layoutParams = firstParams
        indicators[secondIndex].layoutParams = secondParams
    }

    private fun calculateWidthByOffset(offset: Float) = indicatorSize * offset + indicatorSize * selectedIndicatorWidthScale * (1-offset)
}

코드가 매우 길지만, 위 코드 중간즈음 나오는 'setupViewPager2()' 함수가 핵심입니다. 해당 함수의 내용을 요약하자면 다음과 같습니다.

 

1. 최초에 addIndicator() 함수를 통해 indicator를 초기화시킵니다.

각 indicator의 뷰를 그린 뒤, 색상과 사이즈, 마진, cornerRadius를 초기화 시키며 커스텀 뷰 내의 LinearLayout에 추가시킵니다.

추가로 indicators라는 멤버 변수에 ImageView를 추가하여 hold하는 것은, ViewPager2가 스크롤될 때 참조해서 색상 및 width를 변경하기 위함입니다.

 

2. ViewPager2에 OnPageChangeCallback을 등록합니다.

페이지가 스크롤되었을 때 스크롤 포지션, 오프셋에 따라 refreshTwoIndicatorColor(), refreshTwoIndicatorWidth()를 적절히 호출하여 인디케이터에 변화를 주게 됩니다.

 

여기서 서서히 변하는 색상 및 width를 구현하기 위해서 position과 positionOffset에 대한 이해가 필요합니다. 먼저 position + positionOffset을 구하면 아래와 같이 absoluteOffset이 나오게 됩니다.

선택된 인디케이터 색상이 검정색, 그렇지 않은 일반 인디케이터 색상이 흰색일 경우를 생각해보겠습니다.(여기서 width는 고려하지 않겠습니다.) absoluteOffset이 위와 같이 1.2 정도라면, 1번째 인디케이터의 색상은 검정색*0.8 + 흰색*0.2, 2번째 인디케이터의 색상은 검정색*0.2 + 흰색*0.8이 될 것입니다.

이러한 내용을 바탕으로, 색상을 블랜드하여 적용하고 적절한 width를 구해서 적용하는 코드를 작성을 하였습니다.

 

적용

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/view_pager2"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:orientation="horizontal"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@id/custom_indicator"
            />

        <com.hanbitkang.custom_indicator_with_viewpager2.CustomIndicator
            android:id="@+id/custom_indicator"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="30dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

MainActivity.kt - onStart()

override fun onStart() {
    super.onStart()
    binding.viewPager2.let {
        it.adapter = PageViewPagerAdapter(this)
        binding.customIndicator.setupViewPager2(it, it.currentItem)
    }
}

 

 

(2022.10.4 추가)

프로젝트를 개발하면서 ViewPager2뿐만 아니라 Recycler View(using PagerSnapHelper)에도 해당 UI가 필요한 경우가 생겨, CustomIndicator 클래스에 아래 setupRecyclerView() 함수를 추가했습니다.

/**
 * @param recyclerView A [RecyclerView] which [CustomIndicator] refers to.
 * @param pagerSnapHelper A [PagerSnapHelper] attached to [recyclerView].
 * @param itemCount Item count of [recyclerView].
 * @param startPosition A start position of [RecyclerView].
 */
fun setupRecyclerView(
    recyclerView: RecyclerView,
    pagerSnapHelper: PagerSnapHelper,
    itemCount: Int,
    startPosition: Int
) {
    for (i in 0 until itemCount) addIndicator(i, startPosition)

    recyclerView.addOnScrollListener(object: RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)

            pagerSnapHelper.findSnapView(recyclerView.layoutManager)?.let { itemView ->
                val position = recyclerView.layoutManager?.getPosition(itemView)?: 0
                val offset = itemView.x / itemView.width
                val firstIndex = if (offset < 0) position else position - 1
                val secondIndex = if (offset < 0) position + 1 else position
                val positionOffset = if (offset < 0) -offset else 1 - offset
                refreshTwoIndicatorColor(firstIndex, secondIndex, positionOffset)
                refreshTwoIndicatorWidth(firstIndex, secondIndex, positionOffset)
            }
        }
    })
}

 

그 결과 아래처럼 잘 작동하는 것을 확인할 수 있었습니다 :)

 

Github Repository

https://github.com/hanbikan/custom-indicator-with-viewpager2

 

GitHub - hanbikan/custom-indicator-with-viewpager2: A customized indicator view for ViewPager2

A customized indicator view for ViewPager2. Contribute to hanbikan/custom-indicator-with-viewpager2 development by creating an account on GitHub.

github.com