Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed - android

I am using ComposeView inside my recyclerview item layout to work with jetpack compose. I am getting weird issue when I open screen
Error
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.
I tried to follow this stack overflow but it didn't work
main_activity.xml
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="parent" />
item_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.compose.ui.platform.ComposeView
android:id="#+id/itemComposable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
viewholder.kt
class OptionsViewHolder(val binding: ItemLayoutBinding) : Recyclerview.ViewHolder(binding.root) {
private val context = binding.root.context
companion object {
fun from(parent: ViewGroup): OptionsViewHolder {
return OptionsViewHolder(
ItemLayoutBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
}
fun bindChoice() {
binding.itemComposable.setContent {
BoxWithConstraints {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(this#BoxWithConstraints.maxHeight)
.verticalScroll(rememberScrollState())
) {
items(getOptions()) { option ->
Text(text = option)
}
}
}
}
}
private fun getOptions() = mutableListOf(
context.getString(R.string.monitor),
context.getString(R.string.pressure),
context.getString(R.string.not_sure)
)
}

You are using a LazyColumn in a RecyclerView which is not allowed. A LazyColumn is the equivalent of a RecyclerView in Compose. So you are nesting RecyclerViews or LazyColumns.

Related

Why the recycler view test is not passed?

I've written the test, testing if the recycler view is displayed (id: comments_view), but it always fails and I've no idea why. When I'm checking for layout (id: cm), the test passes.
I have the following fragment code:
<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">
<data>
<variable
name="post"
type="com.example.kotlinpostapi.apiObjects.Post" />
<variable
name="comments"
type="java.util.List" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.MainActivity"
android:id="#+id/cm">
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/comments_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
The test code (I'm navigating to the fragment from another one):
#RunWith(AndroidJUnit4::class)
class CommentsListTest{
#get: Rule
val activityScenario = ActivityScenarioRule(MainActivity::class.java)
#Test
fun testCommentsAreDisplayed() {
onView(withId(R.id.posts_view)).perform(actionOnItemAtPosition<PostAdapter.PostsViewHolder>(0, MyMatchers.clickChildView(R.id.show_comments_button)))
//it fails
onView(withId(R.id.comments_view)).check(matches(isDisplayed()))
//it passes
onView(withId(R.id.cm)).check(matches(isDisplayed()))
}
}
How is it possible, and how can I test my recycler view?
The height of the RecyclerView is set to wrap_content and if the element is not visible at least 90% the test fails.
What you could do is to check one of the RecyclerView children.
I firstly declare the following method:
fun nthChildOf(parentMatcher: Matcher<View?>, childPosition: Int): Matcher<View?>? {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with $childPosition child view of type parentMatcher")
}
override fun matchesSafely(view: View): Boolean {
if (view.parent !is ViewGroup) {
return parentMatcher.matches(view.parent)
}
val group = view.parent as ViewGroup
return parentMatcher.matches(view.parent) && group.getChildAt(childPosition) == view
}
}
}
with this you can check whether its first child is displayed:
onView(nthChildOf(withId(R.id.comments_view), 0)).check(matches(isDisplayed()))
And to check one element of its children (recyclerview_element_id for example):
onView(allOf(
withId(R.id.recyclerview_element_id),
isDescendantOfA(
nthChildOf(withId(R.id.comments_view), 0))
)).check(matches(isDisplayed()))
Another thing you could try if your RecyclerView expands to the available space of the screen is to change the layout of the RecyclerView to have all the constraints set and with and height to 0dp:
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/comments_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
I have it this way and doing:
onView(withId(R.id.myRecyclerviewId)).check(matches(isDisplayed()))
works for me.

Jumping scrolling when switching fragments

Inside a ScrollView I am dynamically switching between two fragments with different heights.
Unfortunately that leads to jumping. One can see it in the following animation:
I am scrolling down until I reach the button "show yellow".
Pressing "show yellow" replaces a huge blue fragment with a tiny yellow fragment. When this happens, both buttons jump down to the end of the screen.
I want both buttons to stay at the same position when switching to the yellow fragment. How can that be done?
Source code available at https://github.com/wondering639/stack-dynamiccontent respectively https://github.com/wondering639/stack-dynamiccontent.git
Relevant code snippets:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView 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:id="#+id/myScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="#+id/textView"
android:layout_width="0dp"
android:layout_height="800dp"
android:background="#color/colorAccent"
android:text="#string/long_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="#+id/button_fragment1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:text="show blue"
app:layout_constraintEnd_toStartOf="#+id/button_fragment2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<Button
android:id="#+id/button_fragment2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:text="show yellow"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="#+id/button_fragment1"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<FrameLayout
android:id="#+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="#+id/button_fragment2">
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
package com.example.dynamiccontent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// onClick handlers
findViewById<Button>(R.id.button_fragment1).setOnClickListener {
insertBlueFragment()
}
findViewById<Button>(R.id.button_fragment2).setOnClickListener {
insertYellowFragment()
}
// by default show the blue fragment
insertBlueFragment()
}
private fun insertYellowFragment() {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragment_container, YellowFragment())
transaction.commit()
}
private fun insertBlueFragment() {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragment_container, BlueFragment())
transaction.commit()
}
}
fragment_blue.xml:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="#0000ff"
tools:context=".BlueFragment" />
fragment_yellow.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="#ffff00"
tools:context=".YellowFragment" />
HINT
Please note that this is of course a minimum working example to show off my issue. In my real project, I also have views below the #+id/fragment_container. So giving a fixed size to #+id/fragment_container is not an option for me - it would cause a large blank area when switching to the low, yellow fragment.
UPDATE: Overview of proposed solutions
I implemented the proposed solutions for testing purposes and added my personal experiences with them.
answer by Cheticamp, https://stackoverflow.com/a/60323255
-> available in https://github.com/wondering639/stack-dynamiccontent/tree/60323255
-> FrameLayout wraps content, short code
answer by Pavneet_Singh, https://stackoverflow.com/a/60310807
-> available in https://github.com/wondering639/stack-dynamiccontent/tree/60310807
-> FrameLayout gets the size of the blue fragment. So no content wrapping. When switching to the yellow fragment, there's a gap between it and the content following it (if any content follows it). No additional rendering though!
** update ** a second version was provided showing how to do it without gaps. Check the comments to the answer.
answer by Ben P., https://stackoverflow.com/a/60251036
-> available in https://github.com/wondering639/stack-dynamiccontent/tree/60251036
-> FrameLayout wraps content. More code than the solution by Cheticamp. Touching the "show yellow" button twice leads to a "bug" (buttons jump down to the bottom, actually my original issue). One could argue about just disabling the "show yellow" button after switching to it, so I wouldn't consider this a real issue.
Update: To keep the other views right below the framelayout and to handle the scenario automatically, we need to use onMeasure to implement the auto-handling so do the following steps
• Create a custom ConstraintLayout as (or can use MaxHeightFrameConstraintLayout lib):
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import androidx.constraintlayout.widget.ConstraintLayout
import kotlin.math.max
/**
* Created by Pavneet_Singh on 2020-02-23.
*/
class MaxHeightConstraintLayout #kotlin.jvm.JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr){
private var _maxHeight: Int = 0
// required to support the minHeight attribute
private var _minHeight = attrs?.getAttributeValue(
"http://schemas.android.com/apk/res/android",
"minHeight"
)?.substringBefore(".")?.toInt() ?: 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
_minHeight = minHeight
}
var maxValue = max(_maxHeight, max(height, _minHeight))
if (maxValue != 0 && && maxValue > minHeight) {
minHeight = maxValue
}
_maxHeight = maxValue
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
and use it in your layout in place of ConstraintLayout
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView 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:id="#+id/myScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.pavneet_singh.temp.MaxHeightConstraintLayout
android:id="#+id/constraint"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="#+id/textView"
android:layout_width="0dp"
android:layout_height="800dp"
android:background="#color/colorAccent"
android:text="Some long text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="#+id/button_fragment1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:text="show blue"
app:layout_constraintEnd_toStartOf="#+id/button_fragment2"
app:layout_constraintHorizontal_bias="0.3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<Button
android:id="#+id/button_fragment2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:text="show yellow"
app:layout_constraintHorizontal_bias="0.3"
app:layout_constraintStart_toEndOf="#+id/button_fragment1"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<Button
android:id="#+id/button_fragment3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:text="show green"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.3"
app:layout_constraintStart_toEndOf="#+id/button_fragment2"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<FrameLayout
android:id="#+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="#id/button_fragment3" />
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="additional text\nMore data"
android:textSize="24dp"
app:layout_constraintTop_toBottomOf="#+id/fragment_container" />
</com.example.pavneet_singh.temp.MaxHeightConstraintLayout>
</androidx.core.widget.NestedScrollView>
This will keep track of height and apply it during every fragment change.
Output:
Note: As mentioned in comments before, setting minHeight will result in additional rendering pass and it cannot be avoided in the current version of ConstraintLayout.
Old approach with custom FrameLayout
This is an interesting requirement and my approach is to solve it by creating a custom view.
Idea:
My idea for the solution is to adjust the height of the container by keeping the track of the largest child or total height of children in the container.
Attempts:
My first few attempts were based on modifying the existing behaviour of NestedScrollView by extending it but it doesn't provide access to all the necessary data or methods. Customisation resulted in poor support for all scenarios and edge cases.
Later, I achieved the solution by creating a custom Framelayout with different approach.
Solution Implementation
While implementing the custom behaviour of height measurement phases, I dug deeper and manipulated the height with getSuggestedMinimumHeight while tracking the height of children to implement the most optimised solution as it will not cause any additional or explicit rendering because it will manage the height during the internal rendering cycle so create a custom FrameLayout class to implement the solution and override the getSuggestedMinimumHeight as:
class MaxChildHeightFrameLayout #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
// to keep track of max height
private var maxHeight: Int = 0
// required to get support the minHeight attribute
private val minHeight = attrs?.getAttributeValue(
"http://schemas.android.com/apk/res/android",
"minHeight"
)?.substringBefore(".")?.toInt() ?: 0
override fun getSuggestedMinimumHeight(): Int {
var maxChildHeight = 0
for (i in 0 until childCount) {
maxChildHeight = max(maxChildHeight, getChildAt(i).measuredHeight)
}
if (maxHeight != 0 && layoutParams.height < (maxHeight - maxChildHeight) && maxHeight > maxChildHeight) {
return maxHeight
} else if (maxHeight == 0 || maxHeight < maxChildHeight) {
maxHeight = maxChildHeight
}
return if (background == null) minHeight else max(
minHeight,
background.minimumHeight
)
}
}
Now replace the FrameLayout with MaxChildHeightFrameLayout in activity_main.xml as:
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView 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:id="#+id/myScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="#+id/textView"
android:layout_width="0dp"
android:layout_height="800dp"
android:background="#color/colorAccent"
android:text="Some long text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="#+id/button_fragment1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:text="show blue"
app:layout_constraintEnd_toStartOf="#+id/button_fragment2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<Button
android:id="#+id/button_fragment2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:text="show yellow"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="#+id/button_fragment1"
app:layout_constraintTop_toBottomOf="#+id/textView" />
<com.example.pavneet_singh.temp.MaxChildHeightFrameLayout
android:id="#+id/fragment_container"
android:layout_width="match_parent"
android:minHeight="2dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="#+id/button_fragment2"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
getSuggestedMinimumHeight() will be used to calculate the height of the view during the view rendering lifecycle.
Output:
With more views, fragment and different height. (400dp, 20dp, 500dp respectively)
A straightforward solution is to adjust the minimum height of the ConstraintLayout within the NestedScrollView before switching fragments. To prevent jumping, the height of the ConstraintLayout must be greater than or equal to
the amount by which the NestedScrollView has scrolled in the "y" direction
plus
the height of the NestedScrollView.
The following code encapsulates this concept:
private fun adjustMinHeight(nsv: NestedScrollView, layout: ConstraintLayout) {
layout.minHeight = nsv.scrollY + nsv.height
}
Please note that layout.minimumHeight will not work for ConstraintLayout. You must use layout.minHeight.
To invoke this function, do the following:
private fun insertYellowFragment() {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragment_container, YellowFragment())
transaction.commit()
val nsv = findViewById<NestedScrollView>(R.id.myScrollView)
val layout = findViewById<ConstraintLayout>(R.id.constraintLayout)
adjustMinHeight(nsv, layout)
}
It is similar for insertBlueFragment(). You can, of course, simplify this by doing findViewById() once.
Here is a quick video of the results.
In the video, I have added a text view at the bottom to represent additional items that may exist in your layout below the fragment. If you delete that text view, the code will still work, but your will see blank space at the bottom. Here is what that looks like:
And if the views below the fragment don't fill the scroll view, you will see the additional views plus white space at the bottom.
Your FrameLayout inside activity_main.xml has a height attribute of wrap_content.
Your child fragment layouts are determining the height differences you're seeing.
Should post up your xml for the child fragments
Try setting a specific height to the FrameLayout in your activity_main.xml
I solved this by creating a layout listener that keeps track of the "previous" height and adds padding to the ScrollView if the new height is less than before.
HeightLayoutListener.kt
class HeightLayoutListener(
private val activity: MainActivity,
private val root: View,
private val previousHeight: Int,
private val targetScroll: Int
) : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
root.viewTreeObserver.removeOnGlobalLayoutListener(this)
val padding = max((previousHeight - root.height), 0)
activity.setPaddingBottom(padding)
activity.setScrollPosition(targetScroll)
}
companion object {
fun create(fragment: Fragment): HeightLayoutListener {
val activity = fragment.activity as MainActivity
val root = fragment.view!!
val previousHeight = fragment.requireArguments().getInt("height")
val targetScroll = fragment.requireArguments().getInt("scroll")
return HeightLayoutListener(activity, root, previousHeight, targetScroll)
}
}
}
To enable this listener, add this method to both of your fragments:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val listener = HeightLayoutListener.create(this)
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
}
These are the methods that the listener calls in order to actually update the ScrollView. Add them to your activity:
fun setPaddingBottom(padding: Int) {
val wrapper = findViewById<View>(R.id.wrapper) // add this ID to your ConstraintLayout
wrapper.setPadding(0, 0, 0, padding)
val widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(wrapper.width, View.MeasureSpec.EXACTLY)
val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
wrapper.measure(widthMeasureSpec, heightMeasureSpec)
wrapper.layout(0, 0, wrapper.measuredWidth, wrapper.measuredHeight)
}
fun setScrollPosition(scrollY: Int) {
val scroll = findViewById<NestedScrollView>(R.id.myScrollView)
scroll.scrollY = scrollY
}
And you need to set arguments to your fragments in order for the listener to know what the previous height and the previous scroll position were. So make sure to add them to your fragment transactions:
private fun insertYellowFragment() {
val fragment = YellowFragment().apply {
this.arguments = createArgs()
}
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragment_container, fragment)
transaction.commit()
}
private fun insertBlueFragment() {
val fragment = BlueFragment().apply {
this.arguments = createArgs()
}
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragment_container, fragment)
transaction.commit()
}
private fun createArgs(): Bundle {
val scroll = findViewById<NestedScrollView>(R.id.myScrollView)
val container = findViewById<View>(R.id.fragment_container)
return Bundle().apply {
putInt("scroll", scroll.scrollY)
putInt("height", container.height)
}
}
And that should do it!

RecyclerView with horizontal GridLayoutManager adjusts its height to largest row

I want to use RecyclerView with GridLayoutManager to achieve something like that:
Here is GridLayoutManager with horizontal orientation and 2 rows. What's important for me here is that RecyclerView is set as wrap_content.
So I tried to achieve my goal with such code:
MainActivity.kt
class MainActivity : AppCompatActivity() {
private val adapter = Adapter()
private val layoutManager = GridLayoutManager(this, 2, RecyclerView.HORIZONTAL, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView.adapter = adapter
recyclerView.layoutManager = layoutManager
adapter.submitList(listOf(Unit, Unit, Unit, Unit))
}
}
Adapter.kt
class Adapter : ListAdapter<Any, ItemHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return ItemHolder(itemView)
}
override fun onBindViewHolder(holder: ItemHolder, position: Int) {
}
override fun getItemViewType(position: Int): Int {
return when {
position % 2 == 0 -> R.layout.item_small
else -> R.layout.item_normal
}
}
class DiffCallback : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any) = false
override fun areContentsTheSame(oldItem: Any, newItem: Any) = false
}
class ItemHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/recyclerView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
item_normal.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_margin="8dp"
android:background="#color/colorAccent" />
item_small.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="100dp"
android:layout_height="50dp"
android:layout_margin="8dp"
android:background="#color/colorAccent" />
Unfortunately as the result, each row stretches to the largest row in grid:
The question is how can I remove the spacing between first row and second row? Keep in mind that I have to have scrollable horizontal grid that its height depends on its content. And items have to have fixed size.
Assuming you know the ratios between heights of individual rows, you can try to use SpanSizeLookup to adjust height of each row. In your simple case, the smaller item (50dp) will take one row (span size 1) while the larger item (100dp) will take two rows (span size 2) so the whole grid overall will contain 3 rows.
Of course, for a more complex row configuration, the ratios might get a little more complicated: Say I wanted rows of heights 32dp/48dp/64dp, then the height ratios are 32/144, 48/144 and 64/144, which we can simplify to 2/9, 3/9, 4/9, getting 9 rows in total, with span sizes 2, 3, and 4 for individual items. In extreme cases, this can result in large number of rows (when the fractions cannot be simplified), but assuming you are using some type of grid (x8, x10, etc.) and the items are reasonably sized, it should still be manageable.
Anyway, in your case, the code would be this:
val layoutManager = GridLayoutManager(this, 3, RecyclerView.HORIZONTAL, false)
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = when (position % 2) {
0 -> 1 // 50dp item
else -> 2 // 100dp item
}
}
Given more rows, the when statement is going to get more complex, but if you already have the adapter at hand, you can use getItemViewType to differentiate individual rows in the when statement more easily.
If the number of item types is large or changes often (for example different item types on different screens), you can of course also implement the logic above in code, assuming you have access to the heights of the individual item types. Simply sum the heights to obtain the denominator and then find greatest common divisor of all heights and the sum to find the "simplification factor".
I think GridLayoutManager by default constraints the child views to be exactly same sizes.
I adjust the code to use a LinearLayoutManager, and the view for ViewHolder is to wrap two types views into a RelativeLayout.
See some code below:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:id="#+id/top"
android:layout_width="100dp"
android:layout_height="50dp"
android:layout_margin="8dp"
android:layout_alignParentTop="true"
android:background="#color/colorAccent" />
<View
android:id="#+id/bottom"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_margin="8dp"
android:layout_below="#+id/top"
android:background="#color/colorAccent" />
</RelativeLayout>
RecyclerAdapter:
View view = LayoutInflater.from(viewGroup.getContext()).inflate(
R.layout.item_normal,
viewGroup,
false
);
ViewHolder viewHolder = new ViewHolder(view);
return viewHolder;
And set LinearLayoutManager:
LinearLayoutManager layoutManager = new LinearLayoutManager(this, RecyclerView.HORIZONTAL, false);
recyclerView.setLayoutManager(layoutManager);
Try to use a StaggeredGridLayoutManager to have different heights. Replace your GridLayoutManager with the below StaggeredGridLayoutManager
StaggeredGridLayoutManager layoutManager = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL);

RecyclerView in BottomSheet not working as expected

I have a problem with RecyclerView directly inside of layout with bottomsheetbehaviour. The problem is that when bottom sheet is expanded and content is scrolled down, when I go to scroll back up it causes Bottom Sheet to start collapsing, instead of RecyclerView first being scrolled back to top.
Here's a video to demonstrate the problem. As you can see the problem appears when I scroll down on expanded bottom sheet. It immediately start to collapse instead of "waiting" for RecyclerView to scroll to top first.
Here is my layout code
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
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:id="#+id/scheduleRoot"
android:layout_height="match_parent"
tools:context=".schedule.ScheduleFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="#+id/scheduleSheet"
app:behavior_peekHeight="300dp"
android:elevation="16dp"
android:clickable="false"
android:focusable="false"
android:background="#drawable/bg_bottom_sheet"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="#+id/scheduleRecyclerView"
android:clickable="true"
android:focusable="true"
android:layout_marginTop="8dp"/>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Any help is appreciated!
I just encountered same problem, but I fixed it by adding this to onCreate:
androidx.core.view.ViewCompat.setNestedScrollingEnabled(recyclerview, false);
Add
android:nestedScrollingEnabled="true"
in the root layout of BottomSheetDialogFragment.
I had similar issue: Maybe the solution to my problem will give you some ideas. My bottom sheet was expanded to full height with recycler view in it; the bottom sheet was collapsing on user-drag, even though the first item in recycler view wasn't visible yet.
So, what I did:
You can enable/disable bottom sheet dragging by "isDraggable" = true/false
Add OnScrollListener for recycler view.
Override onScrolled and check layoutManager.findFirstVisibleItemPosition() in it
If first item is visible - update bottom sheet behavior.isDraggable = true, i also added small delay before setting behavior.isDraggable = true, because bottom sheet was collapsing too fast, but you might not need it
Maybe it's not optimal but it was fitting my needs and maybe will help you.
Your recyclerview item has overighted the scrolling state, so this error generates. The layout you provided does not have enough data to determine the cause. You change the item is a unique view to check
I played with this for a long time and tried way too many solutions. For me, this worked best:
val layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.setOnScrollChangeListener { _, _, _, _, _ ->
if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
bottomSheetBehavior.isDraggable = layoutManager.findFirstCompletelyVisibleItemPosition() == 0
} else {
bottomSheetBehavior.isDraggable = true
}
}
The key to the solution is is controlling users ability drag the bottom sheet while the recyclerview is partially scrolled. The method only allows scrolling again once the top most cell is fully visible.
Its not ideal as the user may want to grab the very top of the bottom sheet (assuming its not part of the recycler view) and dismiss the bottom sheet regardless of its scroll position. Im just accepting.
Whatever you do, do not try these, as they just disable any recycling functionality and all cells are loaded at instantiation having a really bad impact on performance:
wrap_content
or:
binding.recyclerView.isNestedScrollingEnabled = false
Enable the scroll state of BottomSheet to allow scroll if recyclerview 0th item is visible.
activity_main.xml
<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">
<data />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#A8A7A7"
tools:context=".MainActivity">
<LinearLayout
android:id="#+id/parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#fff"
android:orientation="vertical"
app:behavior_hideable="true"
app:behavior_peekHeight="80dp"
app:layout_behavior="com.asadmukhtar.recyclerviewinsidebottomsheet.LockableBottomSheetBehavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="10dp"
android:gravity="center"
android:text="Drag Me"
android:textColor="#000"
android:textSize="20sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/rv_items"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
LockableBottomSheet file that used for handling allow dragging option or not.
class LockableBottomSheetBehavior<V : View?> : BottomSheetBehavior<V> {
private var mAllowUserDragging = true
constructor()
constructor(context: Context, attrs: AttributeSet?) : super(
context,
attrs
)
fun setAllowUserDragging(allowUserDragging: Boolean) {
mAllowUserDragging = allowUserDragging
}
override fun onInterceptTouchEvent(
parent: CoordinatorLayout,
child: V,
event: MotionEvent
): Boolean {
return if (!mAllowUserDragging) {
false
} else super.onInterceptTouchEvent(parent, child, event)
}
}
MainActivity.java
var bottomSheetBehavior: LockableBottomSheetBehavior<*>? = null
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding =
DataBindingUtil.setContentView(this, R.layout.activity_main)
setUpBottomSheetBehaviour()
binding.rvItems.layoutManager = LinearLayoutManager(this)
binding.rvItems.adapter = RecyclerViewAdapter(this)
binding.rvItems.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
val firstPosition = (binding.rvItems.layoutManager as LinearLayoutManager)
.findFirstVisibleItemPosition()
updateBottomSheetLockState(firstPosition == 0)
}
})
}
fun updateBottomSheetLockState(allow: Boolean) {
bottomSheetBehavior?.setAllowUserDragging(allow)
}
fun updateBottomSheetState(state: Int) {
if (bottomSheetBehavior != null) {
bottomSheetBehavior?.state = state
}
}
private fun setUpBottomSheetBehaviour() {
val bottomSheetBehavior: BottomSheetBehavior<LinearLayout> =
BottomSheetBehavior.from(binding.parent)
this.bottomSheetBehavior = bottomSheetBehavior as LockableBottomSheetBehavior<*>
updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED)
}
Your implementation might need more coding and with the provided code we might not able to give you good feedback.
Try this documentation
https://material.io/develop/android/components/bottom-sheet-behavior/
Plus I found this another implementation.
https://www.youtube.com/watch?v=WeaylHAwIIk

How to show part of next/previous card RecyclerView

What is the best strategy to achieve this feature:
I Have a horizontal RecyclerView with cards.
Each card will fulfil the entire screen, but I want it to show part of the next card and previous one if it has more than one item.
I know I can achieve this by setting my card android:layout_width at the adapter to have a specific DP like 250dp instead of match_parent.
But it doesn't look like a proper solution.
This is my code:
Activity with RecyclerView:
class ListPokemon : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val items = createListPokemons()
recyclerView.adapter = PokemonAdapter(items)
recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
recyclerView.setHasFixedSize(true)
val pagerSnapHelper = PagerSnapHelper()
pagerSnapHelper.attachToRecyclerView(recyclerView)
}
private fun createListPokemons(): List<Pokemon> {
val pokemons = ArrayList<Pokemon>()
pokemons += createPokemon("Pikachu")
pokemons += createPokemon("Bulbasaur")
pokemons += createPokemon("Charmander")
pokemons += createPokemon("Squirtle")
return pokemons
}
private fun createPokemon(name: String) = Pokemon(name = name, height = 1, weight = 69, id = 1)
}
Layout of Activity:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
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=".MainActivity">
<android.support.v7.widget.RecyclerView
android:id="#+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"/>
</android.support.constraint.ConstraintLayout>
Adapter:
class PokemonAdapter(val list: List<Pokemon>) : RecyclerView.Adapter<PokemonAdapter.PokemonVH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PokemonAdapter.PokemonVH {
return PokemonVH(LayoutInflater.from(parent.context)
.inflate(R.layout.pokemon_item, parent, false))
}
override fun onBindViewHolder(holder: PokemonAdapter.PokemonVH, position: Int) {
holder.textViewName.text = list[position].name
}
override fun getItemCount(): Int {
return list.size
}
class PokemonVH(itemView: View) : RecyclerView.ViewHolder(itemView) {
var textViewName: TextView = itemView.findViewById(R.id.textViewName)
}
}
Layout of Adapter:
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
android:layout_gravity="center_horizontal"
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="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:padding="36dp"
android:id="#+id/textViewName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="22sp"
tools:text="Teste String"/>
</LinearLayout>
</android.support.v7.widget.CardView>
This is my result:
I would like to show part of the next card at this situation. How can I do this?
Thanks.
What you need to do is set padding to your RecyclerView, set clipToPadding to false, use a SnapHelper with it, and you need to make sure the margins on your cards are less than or equal to the padding in the RecylerView.
So, let's say you want the distance from the cards to the sides of the screen to be 16dp and you want the distance between the cards to be 8dp. You'll have to set the margins on each card to 4dp, so the total margin is 8dp. And you have to set the padding to 12dp, given there's already a margin of 4dp on each side of the card.
It'll look a bit like this:
Your list:
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp"/>
Your cards:
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
app:cardElevation="2dp"/>
I think the padding solution is not a good for all cases, because forces the last item to have padding to the right.
Personally i use runtime width calculation of each item and i am very satisfied with this. So you can do the following:
onBindViewHolder
if (position == data.size - 1) {
holder.itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} else {
if (width == null) {
holder.itemView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
holder.itemView.viewTreeObserver.removeOnGlobalLayoutListener(this)
width = holder.itemView.width
params.width = width!! - partOfPage
holder.itemView.requestLayout()
}
})
} else {
params.width = width!! - partOfPage
holder.itemView.requestLayout()
}
}
The outcome is that all middle items are rendered showing a part of the next page, but the last one is rendered full width.
Change your CardView width from "match_parent" to "0dp". And add, layout_weight as "80" (or similar). Make your parent view (RecyclerView) layout_weightSum as "100".
android:layout_width="0dp"
android:layout_weight="80"

Categories

Resources