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)에 접근하기 힘들 것 같아
interface로 fragment로 동작을 넘겨줬습니다.
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.100ms와 150ms가 동시에 시간이 세지는데Handler(Looper.getMainLooper()).postDelayed({ onCategoryScrollListener.categoryScroll(holder.ivMCThumbnail) }, 150)
100ms보다 작거나 같게 postDelayed를 준다면 expanded되는 subCategory가 다 펼쳐지기 전에 해당 height를 계산해버려서,
🌟이쁘게🌟안나옵니다
3else { 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 한테 고마워졌어요. 앱 퀄리티가 높아졌거든요 ㅎ
해당 기능 구현하다가 시간없어서 그냥 훑고 말았는데 다시 공부할 수 있어서 좋다는 점
끗
'개발 > android_kotlin' 카테고리의 다른 글
[ Kotlin ] 안드로이드 바차트 MPAndroidchart BarChart Round Shape (0) | 2024.10.12 |
---|---|
[ Kotlin ] elevation이 -1인 토글그룹버튼 만들기 ( UI / UX 개선 ) (0) | 2024.09.15 |
[ Kotlin ] Adapter 뿌셔보기 1 ( 내 머리가 부셔짐 ) (0) | 2024.08.05 |
[ Kotlin ] 타이머 Runnable (0) | 2024.05.25 |
[ Kotlin ] 헬스 커넥트 총 걸음수, 칼로리 가져오기 (0) | 2024.05.19 |