본문 바로가기
Android/커스텀 뷰

하이라이트 튜토리얼 커스텀뷰를 만들어보자

by 안스 인민군 2023. 5. 20.

이번 글은 위와 같은 원하는 뷰를 선택하여 해당 뷰에 하이라이트를 주는 오버레이 튜토리얼 커스텀 뷰를 만들어보겠다.

먼저 해당 커스텀뷰는 하단 회색 버튼을 누를 시 두번째 사진처럼 원하는 뷰를 제외한 나머지를 회색으로 가려준다.

이 후 화면을 터치하면 세번째 사진 처럼 설정해 주었던 다음번의 뷰에 하이라이트를 잡아주며 더이상 설정한 곳이 없다면 하이라이트를 종료하게 된다.


MainActivity.kr

package com.example.test

import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.databinding.DataBindingUtil
import com.example.test.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.showTutorialButton.setOnClickListener {
            showOverlayTutorial()
        }
    }

    private fun showOverlayTutorial() {
        val overlayTutorialView = OverlayTutorialView(this).apply {
            layoutParams = ConstraintLayout.LayoutParams(
                ConstraintLayout.LayoutParams.MATCH_PARENT,
                ConstraintLayout.LayoutParams.MATCH_PARENT
            )
        }
        val rootView = binding.root
        if (rootView is ViewGroup) {
            rootView.addView(overlayTutorialView)
        }

        val tutorialItems = listOf(
            TutorialItem(
                view = binding.view1,
                description = "This is view 1"
            ),
            TutorialItem(
                view = binding.view2,
                description = "This is view 2"
            ),
            TutorialItem(
                view = binding.view3,
                description = "This is view 3"
            )
        )
        overlayTutorialView.setTutorialItems(tutorialItems)
        overlayTutorialView.show()
    }
}

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"
        android:background="@color/white">

        <TextView
            android:id="@+id/view1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="테테테테테"
            android:layout_marginStart="10dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"/>

        <TextView
            android:id="@+id/view2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="테스"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/view3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="테스트"
            android:layout_marginEnd="10dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

        <Button
            android:id="@+id/show_tutorial_button"
            android:layout_width="100dp"
            android:layout_height="50dp"
            android:layout_marginBottom="30dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>


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

먼저 메인 뷰는 간단하게 버튼 하나와 하이라이트를 줄 뷰 Text1 , Text2, Text3 를 배치하였다.


이제 하이라이트를 주는 OverlayTutorialView를 아래와 같다.

OverlayTutorialView.kr

package com.example.test

import android.content.Context
import android.graphics.*
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import com.example.test.databinding.OverlayTutorialViewBinding

class OverlayTutorialView(context: Context) : ConstraintLayout(context) {

    private lateinit var binding: OverlayTutorialViewBinding

    private var currentStep = 0
    private lateinit var tutorialItems: List<TutorialItem>
    private val transparentHoleView = TransparentHoleView(context)

    init {
        addView(transparentHoleView)
//        binding.closeButton.setOnClickListener { (parent as? ViewGroup)?.removeView(this@OverlayTutorialView) }
//        setOnClickListener { showNextStep() }
    }

    fun setTutorialItems(items: List<TutorialItem>) {
        tutorialItems = items
        //showNextStep()
    }

    fun listener() {
        binding.closeButton.setOnClickListener { (parent as? ViewGroup)?.removeView(this@OverlayTutorialView) }
        setOnClickListener { showNextStep() }
    }

    fun show() {
        showNextStep()
        binding = OverlayTutorialViewBinding.inflate(LayoutInflater.from(context), this, true)
        listener()
    }

    private fun showNextStep() {
        if (currentStep < tutorialItems.size) {
            val currentItem = tutorialItems[currentStep]
            highlightView(currentItem.view)
            //binding.descriptionTextView.text = currentItem.description
            currentStep++
        } else {
            (parent as? ViewGroup)?.removeView(this)
        }
    }

    private fun highlightView(view: View) {
        // Get the position of the view to highlight
        val location = IntArray(2)
        view.getLocationOnScreen(location)

        // Get the position of the OverlayTutorialView
        val parentLocation = IntArray(2)
        getLocationOnScreen(parentLocation)

        // Calculate the position of the TransparentHoleView relative to the screen
        val x = location[0].toFloat() - parentLocation[0]
        val y = location[1].toFloat() - parentLocation[1]

        // Convert 4dp margin to pixels
        val margin = 4 * resources.displayMetrics.density

        // Set the size and position of the TransparentHoleView to match the highlighted view
        // with an additional margin of 4dp
        val width = view.width
        val height = view.height
        val holeBounds = RectF(
            x - margin,
            y - margin,
            x + width + margin,
            y + height + margin
        )
        transparentHoleView.setHoleBounds(holeBounds)
    }
}

data class TutorialItem(
    val view: View,
    val description: String
)

overlay_tutorial_view

<?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">

    <data>

    </data>

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

        <TextView
            android:id="@+id/description_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_marginTop="32dp"
            android:padding="16dp"
            android:textColor="@color/black"
            android:textSize="16sp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <Button
            android:id="@+id/close_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:padding="8dp"
            android:background="#FF03DAC5"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

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

간단히 설명하면

  1. OverlayTutorialView TutorialItem 객체의 목록을 유지한다.  TutorialItem 뷰와 설명을 포함한다.
  2. showNextStep() 함수는 다음 단계의 튜토리얼을 표시하는 역할을 한다. 현재 단계에 해당하는 뷰를 강조 표시하기 위해 TransparentHoleView 사용하고 설명 텍스트를 업데이트한다. 이상 단계가 없는 경우, OverlayTutorialView 부모로부터 제거된다.
  3. highlightView() 함수는 강조할 뷰의 위치와 크기를 계산하고 TransparentHoleView 경계를 설정한다. TransparentHoleView 배경을 투명하게 만들고 강조된 뷰를 보여줄 있도록 하는 커스텀 뷰이다.
  4. setTutorialItems() 함수는 튜토리얼 아이템 목록을 설정하는 사용된다.
  5. listener() 함수는 닫기 버튼과 OverlayTutorialView 자체에 대한 클릭 리스너를 설정하여 사용자 상호작용에 대응한다.
  6. OverlayTutorialView 제공된 튜토리얼 아이템과 함께 튜토리얼 오버레이를 표시하기 위해 show() 함수를 호출하여 사용된다.

이제 이 뷰에 가장 중요한 TranspaentHoleView를 살펴보자.

package com.example.test

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View

class TransparentHoleView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
    }

    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLACK
        alpha = 128
    }

    private var holeBounds: RectF? = null

    fun setHoleBounds(bounds: RectF) {
        holeBounds = bounds
        invalidate()
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // Draw the semi-transparent black background
        canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint)

        // Draw the completely transparent hole
        holeBounds?.let {
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)

            // Convert 16dp to pixels
            val cornerRadius = 16 * resources.displayMetrics.density
            canvas?.drawRoundRect(it, cornerRadius, cornerRadius, paint)
            paint.xfermode = null
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        setLayerType(View.LAYER_TYPE_SOFTWARE, null)
    }
}

이 뷰는 배경을 투명하게 만들고 특정 영역을 뚫는 역할을 한다.

  1. TransparentHoleView는 setHoleBounds 함수를 사용하여 투명한 영역의 경계를 설정한다. 이 함수는 RectF 타입의 경계를 받아 설정하고 화면을 다시 그리도록 invalidate() 함수를 호출한다.
  2. onAttachedToWindow 함수는 뷰가 윈도우에 첨부될 때 호출된다. 이 함수에서는 소프트웨어 가속을 비활성화하기 위해 setLayerType 함수를 사용한다.

이제 메인 액티비티에서 원하는 뷰와 Text를 설정하면 완성이다.

    private fun showOverlayTutorial() {
        val overlayTutorialView = OverlayTutorialView(this).apply {
            layoutParams = ConstraintLayout.LayoutParams(
                ConstraintLayout.LayoutParams.MATCH_PARENT,
                ConstraintLayout.LayoutParams.MATCH_PARENT
            )
        }
        val rootView = binding.root
        if (rootView is ViewGroup) {
            rootView.addView(overlayTutorialView)
        }

        val tutorialItems = listOf(
            TutorialItem(
                view = binding.view1,
                description = "This is view 1"
            ),
            TutorialItem(
                view = binding.view2,
                description = "This is view 2"
            ),
            TutorialItem(
                view = binding.view3,
                description = "This is view 3"
            )
        )
        overlayTutorialView.setTutorialItems(tutorialItems)
        overlayTutorialView.show()
    }