반응형

 

Nike Training Club의 일부를 녹화한 사진입니다. 

* 해당 앱의 기능을 구현하는 것을 목적으로 녹화했을 뿐, 다른 의도는 없습니다. 

 

 

ㅋㅋ 아이폰으로 녹화했지만, 해당 기능이 안드로이드에 사용할 때도 필요할 것 같았다. 물론 GPT의 도움을 좀 받았습니다.

 

굳이 이게 무슨 기능인지 이름을 붙이자면

recyclerview의 expanded item을 자동 스크롤 해서 위에 붙이기?  
이라고 해야할까 ㅋㅋ 



아무튼 원리를 먼저 적자면

1 view 를 Click 한다 ( 예시에서는 imageView)
2 아래로 expanded 된다 ( animation이 적용됐다)
3 expanded가 된 후에 500ms동안 지연되고 펼쳐진 height만큼 View의 Top을 scroll만큼 이동한다

 

아니 나는 계산하기 싫다고요 

너네가 알아서 하라구요 컴퓨터야

라는 마인드로 그냥 해줘 했다..

 

ㅋㅋㅋ

 

아무튼 원리는 알고 있으니 나중에 이걸 심도있게 들어가야할 때 다시 하고, 그냥 UX 개선할 때 유용한 것 같아 공유 한다 


중요한 정보: NestedScrollView 사용, RecyclerView를 눌렀을 때 expanded해서 RecyclerView가 하나 더 나옴.
중요하지 않은 정보: 현재 adapter에 2개의 layout을 연결해놓은 상태 

 

1. 전체 layout

fragment_my.xml

<FrameLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MyFragment">

    <androidx.core.widget.NestedScrollView
        android:id="@+id/nsv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fillViewport="true">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/white">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/rv"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintVertical_bias="0.0" />
                ...
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.core.widget.NestedScrollView>

</FrameLayout>

 

저 rv 안에 rv 를 넣은 상태입니다.  

 

2 코드

1 interface 설정

먼저 Adapter에서 NSV(NestedScrollView)에 접근하기 힘들 것 같아

interfacefragment로 동작을 넘겨줬습니다.

onCategoryScrollListener.kt

interface onCategoryScrollListener {
    fun categoryScroll(view: View)
}

2 adapter에서 interface 넘겨주기 

class CategoryRVAdapter(private val mainCategorys: MutableList<Pair<Int, String>>,
                                private val subCategorys: MutableList<Pair<Int, String>>,
                                ...
                                private val onCategoryScrollListener: onCategoryScrollListener,
                                var xmlname: String
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
...
	inner class mainCategoryViewHolder(view:View): RecyclerView.ViewHolder(view) {
        val tvMCName = view.findViewById<TextView>(R.id.tvMCName)
        val ivMCThumbnail = view.findViewById<ImageView>(R.id.ivMCThumbnail)
        val rvMC = view.findViewById<RecyclerView>(R.id.rvMC)
        val mcvMC = view.findViewById<MaterialCardView>(R.id.mcvMC)
    }
    inner class subCategoryViewHolder(view:View): RecyclerView.ViewHolder(view) {
        val tvSCName = view.findViewById<TextView>(R.id.tvSCName)

    }

override fun getItemViewType(position: Int): Int {
        return when (xmlname) {
            "mainCategory" -> 0
            "subCategory" -> 1
            else -> throw IllegalArgumentException("invalid View Type")
        }

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            0 -> {
                val binding = RvMainCateogoryItemBinding.inflate(inflater, parent, false)
                mainCategoryViewHolder(binding.root)
            }
            1 -> {
                val binding = RvSubCategoryItemBinding.inflate(inflater, parent, false)
                subCategoryViewHolder(binding.root)
            }
            else -> throw IllegalArgumentException("invalid view type binding")
        }
    }

    override fun getItemCount(): Int {
        return when (xmlname) {
            "mainCategory" -> {
                mainCategorys.size
            }
            "subCategory" -> {
                subCategorys.size
            }
            else -> throw IllegalArgumentException("invalid Item Count")
        }
    }

 

이렇게 RecyclerView Adapter의 값 크기랑 전부다 넣었구요. 

아래는 일단 전체 코드인데 

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            // ------! 대분류 item 시작 !------
            is mainCategoryViewHolder -> {
                val currentItemMain = mainCategorys[position]
                holder.mcvMC.visibility = View.GONE
                holder.tvMCName.text = currentItemMain.second
                Glide.with(fragment)
                    .load(fragment.resources.getIdentifier("drawable_main_category_${position + 1}", "drawable", fragment.requireActivity().packageName))
                    .override(1000)
                    .into(holder.ivMCThumbnail)

                // -----! 이미지 클릭 시 서브 카테고리 시작 !------
                val adapter = ExerciseCategoryRVAdapter(mainCategorys, subCategorys, fragment, position ,onCategoryScrollListener,"subCategory" )
                holder.rvMC.adapter = adapter
                val linearLayoutManager = LinearLayoutManager(fragment.requireContext(), LinearLayoutManager.VERTICAL, false)
                holder.rvMC.layoutManager = linearLayoutManager

                holder.ivMCThumbnail.setOnClickListener{
                    if (holder.mcvMC.visibility == View.GONE) {
                        holder.mcvMC.visibility = View.VISIBLE
                        holder.mcvMC.alpha = 0f
                        holder.mcvMC.animate().apply {
                            duration = 100
                            alpha(1f)
                        }
                        Handler(Looper.getMainLooper()).postDelayed({
                            onCategoryScrollListener.categoryScroll(holder.ivMCThumbnail)
                        }, 150)

                    } else {
                        holder.mcvMC.animate().apply {
                            duration = 100
                            alpha(0f)
                            withEndAction {
                                holder.mcvMC.visibility = View.GONE
                            }
                        }
                    }
                }
            }
            is subCategoryViewHolder -> {
                val currentItem = subCategorys[position]
                holder.tvSCName.text = currentItem.second

                holder.tvSCName.setOnClickListener {
                    goExerciseDetail(mainCategorys[mainCategoryIndex], currentItem)
                }
            }
        }
    }
}

여기를 봐보시면 됩니다.

저는 Thumbnail을 누르면 아래로 vertical recyclerView가 펼쳐지게 했고,

해당 visibility를 조건으로 로직을 짰습니다.

 

여기서 

INVISIBLE해버리면 눈에만 안보이고, 해당 View자체는 그 위치에 그대로 있기 때문에 반드시 GONE을 써야합니다.

holder.ivMCThumbnail.setOnClickListener{
                    if (holder.mcvMC.visibility == View.GONE) {
                        holder.mcvMC.visibility = View.VISIBLE
                        holder.mcvMC.alpha = 0f
                        holder.mcvMC.animate().apply {
                            duration = 100
                            alpha(1f)
                        }
                        Handler(Looper.getMainLooper()).postDelayed({
                            onCategoryScrollListener.categoryScroll(holder.ivMCThumbnail)
                        }, 150)

                    } else {
                        holder.mcvMC.animate().apply {
                            duration = 100
                            alpha(0f)
                            withEndAction {
                                holder.mcvMC.visibility = View.GONE
                            }
                        }
                    }
                }

 

코드를 찬찬히 뜯어보면

 

1.
holder.mcvMC.animate().apply {
                            duration = 100
                            alpha(1f)
                        }​

 

펼쳐지기 때문에 1f, 빨리 해야지 사용성이 좋을 듯해서 최대한 짧게 100ms로 구현했습니다.

2.
Handler(Looper.getMainLooper()).postDelayed({
                            onCategoryScrollListener.categoryScroll(holder.ivMCThumbnail)
                        }, 150)​
100ms와 150ms가 동시에 시간이 세지는데
100ms보다 작거나 같게 postDelayed를 준다면 expanded되는 subCategory가 다 펼쳐지기 전에 해당 height를 계산해버려서,
🌟이쁘게🌟안나옵니다

3
else {
                        holder.mcvMC.animate().apply {
                            duration = 100
                            alpha(0f)
                            withEndAction {
                                holder.mcvMC.visibility = View.GONE
                            }
                        }
                    }​

 

닫힐 때는 alpha가 0 이고 
withEndAction이라는 메소드를 통해서 animation이 끝났을 때 visibility를 gone으로 바꾸는 모습입니다

이제 2에서
onCategoryScrollListener.categoryScroll(holder.ivMCThumbnail)
를 fragment에서 다루는 방법입니다.

3 MyFragment.kt

먼저 프래그먼트 클래스에서 아까 정의했던 onCategoryScrollListener를 추가합니다.

 

전체코드

class ExerciseFragment : Fragment(), onCategoryScrollListener {
    lateinit var binding : FragmentExerciseBinding
    ...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentExerciseBinding.inflate(inflater)
        return binding.root
    }
    ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    	binding.nsvEc.isNestedScrollingEnabled = false
        binding.rvEcMainCategory.isNestedScrollingEnabled = false
        binding.rvEcMainCategory.overScrollMode = 0
        
        ...
        
        // 어댑터 추가하는 코드들 
       val adapter = CategoryRVAdapter(categoryArrayList, typeArrayList,this@MyFragment, "mainCategory")
                binding.rvEcMainCategory.adapter = adapter
                val linearLayoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
                binding.rvEcMainCategory.layoutManager = linearLayoutManager
            ...
    } // onViewCreated 닫힘
    
    
	override fun categoryScroll(view: View) {
        scrollToView(view)
    }
    
    private fun scrollToView(view: View) {
        // 1 뷰의 위치를 저장할 배열 생성
        val location = IntArray(2)
        // 2 뷰의 위치를 'window' 기준으로 계산 후 배열 저장
        view.getLocationInWindow(location)
        val viewTop = location[1]
        // 3 스크롤 뷰의 위치를 저장할 배열 생성
        val scrollViewLocation = IntArray(2)

        // 4 스크롤 뷰의 위치를 'window' 기준으로 계산 후 배열 저장
        binding.nsvEc.getLocationInWindow(scrollViewLocation)
        val scrollViewTop = scrollViewLocation[1]
        // 5 현재 스크롤 뷰의 스크롤된 y 위치 가져오기
        val scrollY = binding.nsvEc.scrollY
        // 6 스크롤할 위치 계산
        //    현재 스크롤 위치 + 뷰의 상대 위치 = 스크롤 위치 계산
        val scrollTo = scrollY + viewTop - scrollViewTop
        // 7 스크롤 뷰 해당 위치로 스크롤
        binding.nsvEc.smoothScrollTo(0, scrollTo)
    }

위치를 저장할 배열을 만든 다음
1. 현재 뷰의 위치를 저장, 
2. expanded했을 때 scrollview가 늘어난 height값(y값)

이 둘을 넣고 값을 계산해서 NestedScrollView에 접근하는 방식.

근데 중간에 
이렇게 getLocationOnScreen이나 다른 걸 쓰면
제대로 동작이 안되더라구요. scroll이 움직이긴 하는데 아까 2-2 번처럼 가다가 멈추는 느낌.

말 그대로 getLocationOnScreen은 기기 화면의 위치만 가져오는 거고
getLocationOnWindow는 해당 fragment의 scroll을 전부 포함한 화면(숨겨진것 까지 전부)을 가져오는 것 같더라구요.

 

암튼 scrollToView 한테 고마워졌어요. 앱 퀄리티가 높아졌거든요 ㅎ

해당 기능 구현하다가 시간없어서 그냥 훑고 말았는데 다시 공부할 수 있어서 좋다는 점 

 

반응형