Search code examples
androidkotlinandroid-recyclerviewandroid-viewpager2

Setting the height of the ViewPager2 based on its content is not working properly


I have a ViewPager2 with TabLayout. I'm trying to set the height of the ViewPager2 dynamically based on its content. I have checked some related questions like this and this. Actually, the latter is helped me but it is not working properly. When I navigate into DetailsFragment I'm getting data from the network as follow:

DetailFragmentViewModel

   private val _advert = MutableLiveData<Resource<DetailAdvert>>()
    val advert: LiveData<Resource<DetailAdvert>> get() = _advert
    init {
        savedStateHandle.get<Int>("id")?.let {
            getAdvert(it)      
        }
}

 private fun getAdvert(id: Int) {
        _advert.value = Resource.Loading()
        viewModelScope.launch(Dispatchers.Main) {
            _advert.value = repo.advert(id)
        }
    }

I'm observing advert LiveData in my DetailsFragment :

DetailsFragment

  mViewModel.advert.observe(viewLifecycleOwner) {
            when (it) {
                is Resource.Loading -> {
                    showProgress(true)
                }

                is Resource.Error -> {
                    showSnack(it.message!!)
                    showProgress(false)
                }
                is Resource.Success -> {
                    it.data?.let { advert ->
                        this.advert = advert
                        bindAdvert(advert)         
                    }
                    showProgress(false)
                }
            }
        }

    private fun bindAdvert(item: DetailAdvert) {
        initTabLayout(fragments, item)
}

When I receive the data successfully I'm initializing my TabLayout and ViewPager2 then passing a List to the InfoFragment which is the first tab and description text to the second tab which is DescriptionFragment

    private fun initTabLayout(fragments: List<Fragment>, advert: DetailAdvert) {
        viewpagerAdapter =
            DetailsFragmentPagerAdapter(childFragmentManager, lifecycle, fragments, advert, this)
        binding.viewpager.adapter = viewpagerAdapter
        binding.viewpager.isUserInputEnabled = false
        //initializing utility class
        val heightAnimator = ViewPager2ViewHeightAnimator()
        heightAnimator.viewPager2 = binding.viewpager
        tabLayoutMediator = TabLayoutMediator(binding.tabs, binding.viewpager) { tab, position ->
            tab.text = titles[position]
        }
        tabLayoutMediator.attach()
    }

DetailsFragmentPagerAdapter

class DetailsFragmentPagerAdapter(
    fragmentManager: FragmentManager,
    lifecycle: Lifecycle,
    private val fragments: List<Fragment>,
    private val advert: DetailAdvert,
    private val parentFragment: Fragment
) : FragmentStateAdapter(fragmentManager, lifecycle) {

    init {
        registerFragmentTransactionCallback(object :
            FragmentStateAdapter.FragmentTransactionCallback() {
            override fun onFragmentMaxLifecyclePreUpdated(
                fragment: Fragment,
                maxLifecycleState: Lifecycle.State,
            ) = if (maxLifecycleState == Lifecycle.State.RESUMED) {
                OnPostEventListener {
                    fragment.parentFragmentManager.commitNow {
                        setPrimaryNavigationFragment(fragment)
                    }
                }
            } else {
                super.onFragmentMaxLifecyclePreUpdated(fragment, maxLifecycleState)
            }
        })
    }

    override fun getItemCount(): Int = fragments.size

    override fun createFragment(position: Int): Fragment {
        val fragment = fragments[position]
        when (fragment) {
            is DescriptionFragment -> {
                fragment.arguments = Bundle().apply {
                    putString(DESCRIPTION_KEY, advert.text)
                }
            }
            is InfoFragment -> {
                fragment.arguments = Bundle().apply {
                    val infoList = getInfoList(advert,parentFragment.requireContext())
                    putParcelableArray(INFO_KEY, infoList.toTypedArray())
                }
            }
        }
        return fragment
    }
}

In the first tab I only have RecyclerView that shows list items:

fragment_info.xml

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/info_rv"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:adapter="@{adapter}"
         
            android:orientation="vertical"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

InfoFragment

@AndroidEntryPoint
class InfoFragment : Fragment(R.layout.fragment_info_layout) {

    private lateinit var binding: FragmentInfoLayoutBinding

    @Inject
    lateinit var mAdapter: InfoFragmentAdapter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding = FragmentInfoLayoutBinding.bind(view)
        val infoList = arguments?.getParcelableArray(Constants.INFO_KEY)!!.toList() as List<Info>
        binding.adapter = mAdapter
        val dividerItemDecoration = DividerItemDecoration(binding.infoRv.context,
            LinearLayoutManager(requireContext()).orientation)
        binding.infoRv.addItemDecoration(dividerItemDecoration)
        mAdapter.submitList(infoList)
    }

    override fun onDestroyView() {
        super.onDestroyView()
        binding.unbind()
    }
}

In the second tab which is DescriptionFragment I have a NestedScrollView with a TextView : fragment_description.xml

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

        <androidx.core.widget.NestedScrollView
            android:id="@+id/nsv_detail"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="16dp"
            android:paddingStart="24dp"
            android:paddingEnd="24dp"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <TextView
                android:id="@+id/description_tv"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@{Jsoup.parse(description).text()}"
                android:lineSpacingExtra="8dp"
                tools:text="@string/lorem" />
        </androidx.core.widget.NestedScrollView>

    </androidx.constraintlayout.widget.ConstraintLayout>

DescriptionFragment

class DescriptionFragment : Fragment(R.layout.fragment_description_layout) {

    private lateinit var binding: FragmentDescriptionLayoutBinding


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding = FragmentDescriptionLayoutBinding.bind(view)
        val description = arguments?.getString(Constants.DESCRIPTION_KEY)!!
        binding.description = description
    }

    override fun onDestroyView() {
        super.onDestroyView()
        binding.unbind()
    }
}

and lastly layout of my DetailsFragment :

   <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:id="@+id/main_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:backgroundTint="@android:color/transparent">

            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:id="@+id/collapsing_toolbar"
                android:layout_width="match_parent"
                android:layout_height="@dimen/view_pager_height"
                app:layout_scrollFlags="scroll|exitUntilCollapsed">

                <androidx.viewpager2.widget.ViewPager2
                    android:id="@+id/advert_images_vp"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:adapter="@{adapter}"
                    android:transitionGroup="true"
                    android:orientation="horizontal"
                    />

                <TextView
                    android:id="@+id/no_image_tv"
                    style="@style/TextAppearance.AppCompat.Medium"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="@string/no_image"
                    android:textColor="@color/gray"
                    android:visibility="invisible" />

            </com.google.android.material.appbar.CollapsingToolbarLayout>


            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tabs"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:layout_gravity="bottom"
                android:background="@color/primaryColor"
                app:tabContentStart="72dp"
                app:tabGravity="fill"
                app:tabIndicatorColor="@color/white"
                app:tabMode="fixed"
                app:tabTextAppearance="@android:style/TextAppearance.Widget.TabWidget"
                app:tabTextColor="@color/white" />

        </com.google.android.material.appbar.AppBarLayout>

        <androidx.core.widget.NestedScrollView
            android:id="@+id/nested_scroll"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical">

                <androidx.viewpager2.widget.ViewPager2
                    android:id="@+id/viewpager"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content" />
                      
                   //other stuff

        </androidx.core.widget.NestedScrollView>

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

As I mentioned previously I'm using the following class to change the height of ViewPager2 dynamically:

ViewPager2ViewHeightAnimator

class ViewPager2ViewHeightAnimator {

    var viewPager2: ViewPager2? = null; set(value) {
        if (field != value) {
            field?.unregisterOnPageChangeCallback(onPageChangeCallback)
            field = value
            value?.registerOnPageChangeCallback(onPageChangeCallback)
        }
    }

    private val layoutManager: LinearLayoutManager? get() = (viewPager2?.getChildAt(0) as? RecyclerView)?.layoutManager as? LinearLayoutManager

    private val onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
        override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
            super.onPageScrolled(position, positionOffset, positionOffsetPixels)
            recalculate(position, positionOffset)
        }
    }

    fun recalculate(position: Int, positionOffset: Float = 0f) = layoutManager?.apply {
        val leftView = findViewByPosition(position) ?: return@apply
        val rightView = findViewByPosition(position + 1)
        val setMeasure = {
            viewPager2?.apply {
                val leftHeight = getMeasuredViewHeightFor(leftView)
                layoutParams = layoutParams.apply {
                    height = if (rightView != null) {
                        val rightHeight = getMeasuredViewHeightFor(rightView)
                        leftHeight + ((rightHeight - leftHeight) * positionOffset).toInt()
                    } else {
                        leftHeight
                    }
                }
                invalidate()
            }
        }
        val onLayoutChanged =
            ViewTreeObserver.OnGlobalLayoutListener {
                setMeasure.invoke()
            }
        leftView.viewTreeObserver.addOnGlobalLayoutListener(onLayoutChanged)
        rightView?.viewTreeObserver?.addOnGlobalLayoutListener(onLayoutChanged)
        setMeasure.invoke()
    }

    private fun getMeasuredViewHeightFor(view: View): Int {
        val wMeasureSpec = View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.EXACTLY)
        val hMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
        view.measure(wMeasureSpec, hMeasureSpec)
        return view.measuredHeight
    }
}

But the result is:

I have also tried to modify the code as stated in here but the result is :

I think this is happening because of loading data asynchronously. How do I solve this?

Thanks.


Solution

  • Okay, I don't have any idea of what is going on but I have solved my problem. Here is the code:

    ViewPager2HeightAnimator

    class ViewPager2HeightAnimator {
    
        var viewPager2: ViewPager2? = null; set(value) {
                if (field != value) {
                    field?.unregisterOnPageChangeCallback(onPageChangeCallback)
                    field = value
                    value?.registerOnPageChangeCallback(onPageChangeCallback)
                }
            }
    
        private val layoutManager: LinearLayoutManager? get() = (viewPager2?.getChildAt(0) as? RecyclerView)?.layoutManager as? LinearLayoutManager
    
        private val onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
            override fun onPageScrolled(
                position: Int,
                positionOffset: Float,
                positionOffsetPixels: Int,
            ) {
                super.onPageScrolled(position, positionOffset, positionOffsetPixels)
                recalculate(position, positionOffset)
            }
        }
    
        fun recalculate(position: Int, positionOffset: Float = 0f) = layoutManager?.apply {
            val leftView = findViewByPosition(position) ?: return@apply
            val rightView = findViewByPosition(position + 1)
            val setMeasure = {
                viewPager2?.apply {
                    val leftHeight = getMeasuredViewHeightFor(leftView)
                    layoutParams = layoutParams.apply {
                        height = if (rightView != null) {
                            val rightHeight = getMeasuredViewHeightFor(rightView)
                            leftHeight + ((rightHeight - leftHeight) * positionOffset).toInt()
                        } else {
                            leftHeight
                        }
                    }
                    invalidate()
                }
            }
            val onLayoutChanged =
                ViewTreeObserver.OnGlobalLayoutListener {
                    setMeasure.invoke()
                }
            leftView.viewTreeObserver.addOnGlobalLayoutListener(onLayoutChanged)
            rightView?.viewTreeObserver?.addOnGlobalLayoutListener(onLayoutChanged)
        }
    
        private fun getMeasuredViewHeightFor(view: View): Int {
            val wMeasureSpec = View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.EXACTLY)
            val hMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
            view.measure(wMeasureSpec, hMeasureSpec)
            return view.measuredHeight
        }
    }
    

    And I added binding.infoRv.isNestedScrollingEnabled = false to the OnViewCreated method of my first tab. Although it has some tiny performance issues, it is working as expected.