In my current project I have a RecyclerView with a rounded rectangle as its border. I set it using the background view tag in my layout xml and it produces the correct effect:
<androidx.recyclerview.widget.RecyclerView
...
android:background="#drawable/layout_sample_view_background"
....
/>
The problem arises when I longclick() one of the view items and open a floating action mode menu using ActionMode.Callback2. This selection overlaps the borders of my RecyclerView:
Btw. Not sure this is important, but the viewitems inside my RecyclerView/ListAdapter are using a custom view:
/**
* Custom view for each [RecyclerView] list item in the list inside a [SampleView]
*/
class SampleListItem(context: Context, attrs: AttributeSet, #AttrRes defStyleAttr: Int) : ConstraintLayout(context, attrs, defStyleAttr), View.OnLongClickListener {
constructor(context: Context, attrs: AttributeSet) : this(context, attrs, 0)
init {
setOnLongClickListener(this)
clipToOutline = false
}
private val binding: LayoutSampleListItemBinding = LayoutSampleListItemBinding.inflate(LayoutInflater.from(context), this)
var sample: Sample? = null
set(value) {
field = value
fun Long.toTimeString(): String {
val date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault())
val formatter = DateTimeFormatter.ofPattern("HH:mm")
val time = date.format(formatter)
Timber.d("With $this time was: $time")
return time
}
checkNotNull(value)
binding.id.text = value.sampleNumber.toString()
binding.timestamp.text = value.sampleTime.toTimeString()
binding.comment.text = SpannableStringBuilder(value.comment)
}
private var sampleItemClickListener = object : SampleItemClickListener {
override fun onSampleEditClick(sample: Sample) {
Timber.d("ActionMode edit icon clicked. Please edit $sample")
isSelected = !isSelected
}
override fun onSampleDeleteClick(sample: Sample) {
Timber.d("ActionMode delete icon clicked. Please delete $sample")
isSelected = !isSelected
}
}
interface SampleItemClickListener {
fun onSampleEditClick(sample: Sample)
fun onSampleDeleteClick(sample: Sample)
}
override fun onLongClick(v: View?): Boolean {
Toast.makeText(context,"longClick $sample", Toast.LENGTH_LONG).show()
// Start floating ActionMode
isSelected = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val floatingActionModeCallback = SampleViewItemActionModeCallback(this, sampleItemClickListener, R.menu.sampleviewitem_menu_actions)
}
return true
}
}
I set the ripple effect of this custom view using the following drawable:
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#color/colorPrimaryLight">
<item
android:id="#android:id/mask"
android:drawable="#color/icon_inactive_light_background" />
<item>
<selector>
<item android:state_selected="true">
<color android:color="#color/colorPrimaryLight"/>
</item>
<item android:state_activated="true">
<color android:color="#color/colorPrimaryLight"/>
</item>
<item>
<color android:color="#android:color/transparent"/>
</item>
</selector>
</item>
</ripple>
The layout of the custom view:
<?xml version="1.0" encoding="utf-8"?>
<merge
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"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<com.google.android.material.textview.MaterialTextView
android:id="#+id/id"
style="#style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
app:layout_constraintEnd_toStartOf="#+id/timestamp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="#+id/comment"
app:layout_constraintWidth_percent="0.10"
tools:text="#sample/samples.json/data/sampleId" />
<com.google.android.material.textview.MaterialTextView
android:id="#+id/timestamp"
style="#style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
app:layout_constraintEnd_toStartOf="#+id/comment"
app:layout_constraintStart_toEndOf="#+id/id"
app:layout_constraintTop_toTopOf="#+id/comment"
app:layout_constraintWidth_percent="0.20"
tools:text="#sample/samples.json/data/timestamp" />
<com.google.android.material.textview.MaterialTextView
android:id="#+id/comment"
style="#style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="#id/sampleviewitem_optional_icon"
app:layout_constraintStart_toEndOf="#id/timestamp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.60"
tools:hint="Kommentar"
tools:text="#sample/samples.json/data/comment" />
<com.google.android.material.button.MaterialButton
android:id="#+id/sampleviewitem_optional_icon"
style="#style/IconOnlyButton"
android:layout_width="0dp"
android:layout_height="0dp"
app:icon="#drawable/ic_edit_white_24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="#+id/layout_sample_item_sample_comment"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintWidth_percent="0.10" />
</merge>
Does anyone know what causes this effect and how to avoid this behavior? I know of ViewOutlineProvider, but I'm just not familiar with it. Could it maybe solve this issue?
For others that might come across this problem. The solution to this was to wrap the RecyclerView in a MaterialCardView (a regular CardView could also work).
Like this:
<com.google.android.material.card.MaterialCardView
android:id="#+id/sample_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:cardElevation="0dp"
app:cardCornerRadius="4dp"
app:strokeColor="#color/icon_active_unfocused_light_background"
app:strokeWidth="1dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="#id/layout_sample_view_icon"
app:layout_constraintTop_toTopOf="#id/layout_sample_view_icon"
>
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/layout_sample_view_recyclerview"
android:layout_height="match_parent"
android:layout_width="match_parent"
tools:itemCount="4"
tools:listitem="#layout/layout_patientsample_listitem"
/>
</com.google.android.material.card.MaterialCardView>
Here's a screenshot of the outcome:
Use padding in your RecyclerView
<androidx.recyclerview.widget.RecyclerView
...
android:background="#drawable/layout_sample_view_background"
android:padding="5dp"
....
/>
Related
I want to animate TabLayout dot indicator to stretch it on selected state. I used default animation to do that but its bugged and it cant take default shape by animating width only. After animation its stretched to rectangle and I don't understand why.
Code:
val vg = tabLayout.getChildAt(0) as ViewGroup
tabLayout.addOnTabSelectedListener(object: TabLayout.ViewPagerOnTabSelectedListener(viewPager){
override fun onTabReselected(tab: TabLayout.Tab) {}
override fun onTabUnselected(tab: TabLayout.Tab) {
val tabDot = vg.getChildAt(tab.position)
tabDot?.let { v->
animateDotWidthDefault(v)
animateDotColor(v, R.color.selected_blue, R.color.default_grey)
}
}
override fun onTabSelected(tab: TabLayout.Tab) {
val tabDot = vg.getChildAt(tab.position)
tabDot?.let { v->
animateDotWidthStretch(v)
animateDotColor(v, R.color.default_grey, R.color.selected_blue)
}
}
})
private fun animateDotWidthDefault(tabDot: View){
val widthAnimator = ValueAnimator.ofInt(stretchedWidth, defaultWidth)
widthAnimator.duration = 500
widthAnimator.interpolator = DecelerateInterpolator()
widthAnimator.addUpdateListener { animation ->
tabDot.layoutParams.width = animation.animatedValue as Int
tabDot.requestLayout()
}
widthAnimator.start()
}
private fun animateDotWidthStretch(tabDot: View){
val widthAnimator = ValueAnimator.ofInt(defaultWidth, stretchedWidth)
widthAnimator.duration = 500
widthAnimator.interpolator = AccelerateInterpolator()
widthAnimator.apply {
addUpdateListener { animation ->
tabDot.layoutParams.width = animation.animatedValue as Int
tabDot.requestLayout()
}
}
widthAnimator.start()
}
TabIndicator:
<?xml version="1.0" encoding="utf-8"?>
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<item android:gravity="center">
<shape
android:shape="oval"
android:useLevel="false">
<solid android:color="#color/selected_blue" />
<size
android:width="8dp"
android:height="8dp"/>
</shape>
</item>
</layer-list>
Goal:
Reality:
The bug could definitely be related to the
android:fillAfter="true"
android:fillEnabled="true"
configurations on the XML. Could you paste the original XML and the container so we can edit our answer and give a more accurate solution to your issue.
Great component by the way, I tested it on my end and it works perfect with this XML config:
<!-- For demonstration / testing purposes -->
<com.google.android.material.tabs.TabLayout
android:id="#+id/indicator3"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_alignParentBottom="true"
android:layout_marginStart="14dp"
android:layout_marginBottom="64dp"
android:clipChildren="false"
android:clipToPadding="false"
app:tabBackground="#drawable/tablayout_unselected"
app:tabGravity="center"
app:tabIndicator="#drawable/tablayout_selected2"
app:tabIndicatorAnimationDuration="250"
app:tabIndicatorAnimationMode="elastic"
app:tabIndicatorColor="#color/xxxRed"
app:tabIndicatorFullWidth="false"
app:tabIndicatorGravity="bottom"
app:tabIndicatorHeight="6dp"
app:tabMode="scrollable"
android:fillAfter="true"
android:fillEnabled="true"
app:tabRippleColor="#color/carbon_red_a700"
app:tabSelectedTextColor="#color/black"
app:tabTextColor="#android:color/black"
app:tabUnboundedRipple="true">
<com.google.android.material.tabs.TabItem
android:id="#+id/firstTab"
android:layout_width="12dp"
android:layout_height="wrap_content" />
<com.google.android.material.tabs.TabItem
android:id="#+id/secondTab"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.google.android.material.tabs.TabItem
android:id="#+id/thirdTab"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.google.android.material.tabs.TabItem
android:id="#+id/fourthTab"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.google.android.material.tabs.TabItem
android:id="#+id/fifthTab"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</com.google.android.material.tabs.TabLayout>
edit1:
I just figured out that even though you didn't provide the animateDotColor() function, ChatGPT took a guess and provided this missing function.
It turns out it is exactly this function that's causing the trouble for you, removing the rounded corners.
private fun animateDotColor(tabDot: View, fromColor: Int, toColor: Int) {
val colorAnimator = ValueAnimator.ofObject(
ArgbEvaluator(),
ContextCompat.getColor(tabDot.context, fromColor),
ContextCompat.getColor(tabDot.context, toColor)
)
colorAnimator.duration = 500
colorAnimator.interpolator = DecelerateInterpolator()
colorAnimator.addUpdateListener { animation ->
tabDot.setBackgroundColor(animation.animatedValue as Int)
}
colorAnimator.start()
}
Because it's replacing the background of the TabItem().
Solution is instead of setting the color, setting the same background with a different color, so you can preserve the edges and corners. I am going through the same work right now and I will edit a second time and provide you this code as well.
edit2:
Here's the working animateDotColor function for you, this one doesn't remove the corners. Enjoy it buddy.
private fun animateDotColor2(tab: TabView, color: Int) {
tab.background = AppCompatResources.getDrawable(tab.context, R.drawable.tablayout_selected_final)
}
I've created my custom View class:
class CustomCheckBox constructor(
context: Context,
attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs), Checkable {
private var checked: Boolean = false
init {
LayoutInflater.from(context).inflate(R.layout.custom_view, this)
}
fun setCheckedAnim(check: Boolean) {
isChecked = check
animation.playAnimation()
}
fun toggleAnim(check: Boolean) {
toggle(check)
animation.playAnimation()
}
override fun setChecked(value: Boolean) {
checked = value
checkbox.isChecked = value
}
override fun isChecked(): Boolean = checked
override fun toggle() {
checked = !checked
checkbox.isChecked = checked
}
}
Layout is pretty simple:
<?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_height="wrap_content"
android:layout_width="wrap_content"
android:background="#android:color/transparent">
<androidx.appcompat.widget.AppCompatCheckBox
android:id="#+id/checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#android:color/transparent"
android:button="#drawable/toggle_drawable"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.airbnb.lottie.LottieAnimationView
android:id="#+id/animation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:lottie_rawRes="#raw/animation_json"
app:lottie_speed="2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:lottie_autoPlay="false"
app:lottie_loop="false"/>
</androidx.constraintlayout.widget.ConstraintLayout>
The problem I am facing is that when I set ClickListener for this View in code, the Listener doesn't work. It happens because CheckBox captures every click. If I create custom ClickListener and set is as listener for CheckBox then it works fine but the true problem is that I need this listener to be set for my main View using setOnClickListener() so the View has it, not it's component. It's because later in code I am checking if (listener != null) that's why it has to have it's own listener which will work.
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!
I have a BottomSheet which houses a product detail card. The problem is, when I click on the + or - button on the product detail while the bottom sheet is in it's Expanded state, it jumps down.
When it is down and I click on the buttons it doesn't jump, it only happens when it is in it's Expanded (completely up) state
I have attached a GIF to show what is exactly happening
Here is the code
scan_sheet.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:id="#+id/bottom_sheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:animateLayoutChanges="false"
android:background="#drawable/bottom_sheet_dialog_fragment"
android:orientation="vertical"
app:behavior_hideable="true"
app:behavior_peekHeight="100dp"
app:layout_behavior="studio.monotype.storedemo.BottomSheetBehavior">
<include
layout="#layout/hero_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="#+id/divider_view"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginStart="24dp"
android:layout_marginTop="44dp"
android:layout_marginEnd="24dp"
android:background="#color/colorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/hero_item" />
<include
layout="#layout/related_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="#+id/divider_view"
tools:layout_editor_absoluteX="0dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
ScanActivity.kt (simplified to show only what is essential)
class ScanActivity : AppCompatActivity() {
private lateinit var bottomSheet: BottomSheetBehavior<*>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scan)
setupBottomSheet()
showSheet()
}
private fun setupBottomSheet() {
bottomSheet = BottomSheetBehavior.from(bottom_sheet)
bottomSheet.isHideable = true
bottomSheet.skipCollapsed= true
bottomSheet.isDraggable = true
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
bottomSheet.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
#SuppressLint("SwitchIntDef")
override fun onStateChanged(sheet: View, newState: Int) {
when (newState) {
BottomSheetBehavior.STATE_HIDDEN -> {
codeScanner.startPreview()
}
}
}
})
plus_btn.setOnClickListener {
var qty= qty_tv.text.toString().toInt()
qty++
qty_tv.text =qty.toString()
}
minus_btn.setOnClickListener {
var qty= qty_tv.text.toString().toInt()
if(qty!=0)
{
qty--
}
qty_tv.text =qty.toString()
}
}
private fun showSheet() {
bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
}
}
it seems that google engineer gave correct answer
Seems like something is going on because you are setting
android:layout_gravity="bottom" on the view with the
BottomSheetBehavior. You should remove that line.
It helped on my case
Looks to me like that could be a bug in the BottomSheetBehavior? Seems like the height of the sheet isn't getting saved or restored correctly. After the button is pressed, a layout happens again which changes the height. Could you file a bug at https://issuetracker.google.com/issues/new?component=439535
I am trying to add a badge to the BottomNavigationView Item without using any library, however somehow the BottomNavigationView is not showing the badge (custom_view)
main_view.xml:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="#+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="#dimen/activity_vertical_margin"
android:paddingLeft="#dimen/activity_horizontal_margin"
android:paddingRight="#dimen/activity_horizontal_margin"
android:paddingTop="#dimen/activity_vertical_margin"
tools:context="com.hrskrs.test.MainActivity">
<FrameLayout
android:id="#+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<android.support.design.widget.BottomNavigationView
android:id="#+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:itemBackground="#color/colorPrimary"
app:itemIconTint="#color/colorAccent"
app:itemTextColor="#color/colorPrimaryDark"
app:menu="#menu/bottom_navigation_main" />
</RelativeLayout>
bottom_navigation_menu.xml:
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="#+id/item_test"
android:icon="#mipmap/ic_launcher"
android:title="action1"
app:showAsAction="always" />
<item
android:enabled="true"
android:icon="#mipmap/ic_launcher"
android:title="action2"
app:showAsAction="ifRoom" />
<item
android:enabled="true"
android:icon="#mipmap/ic_launcher"
android:title="action3"
app:showAsAction="ifRoom" />
</menu>
Activity extended from AppCompatActivity:
#Override
public boolean onCreateOptionsMenu(Menu menu) {
menu = bottomNavigationView.getMenu();
menu.clear();
getMenuInflater().inflate(R.menu.bottom_navigation_main, menu);
MenuItem item = menu.findItem(R.id.item_test);
item = MenuItemCompat.setActionView(item, R.layout.custom_view);
RelativeLayout badgeWrapper = (RelativeLayout) MenuItemCompat.getActionView(item);
TextView textView = (TextView) badgeWrapper.findViewById(R.id.txtCount);
textView.setText("99+");
return super.onCreateOptionsMenu(menu);
}
custom_view.xml:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
style="#android:style/Widget.ActionButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#android:color/transparent"
android:clickable="true"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Notification Icon"
android:gravity="center"
android:src="#mipmap/ic_launcher" />
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="#+id/txtCount"
android:gravity="right"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#drawable/ic_badge"
android:text="55"
android:textColor="#ffffffff"
android:textSize="12sp" />
</RelativeLayout>
Istead of showing (badge) custom_view it shows the item itself only:
Below you can see from the debug mode that the view accessed is the right one and it is being set correctly. However somehow the BottomNavigationViewis not being invalidated:
You can use the BottomNavigationView provided by the Material Components Library.
Just add the BottomNavigationView to your layout:
<com.google.android.material.bottomnavigation.BottomNavigationView
android:layout_gravity="bottom"
app:menu="#menu/navigation_main"
../>
Then use in your code:
int menuItemId = bottomNavigationView.getMenu().getItem(0).getItemId();
BadgeDrawable badge = bottomNavigationView.getOrCreateBadge(menuItemId);
badge.setNumber(2);
To change the badge gravity use the setBadgeGravity method.
badge.setBadgeGravity(BadgeDrawable.BOTTOM_END);
I managed to make BottomNavigationView with the badge. Here is my code (Kotlin).
This is the panel (inherited from BottomNavigationView)
/** Bottom menu with badge */
class AdvancedBottomNavigationView(context: Context, attrs: AttributeSet) : BottomNavigationView(context, attrs) {
private companion object {
const val BADGE_MIN_WIDTH = 16 // [dp]
const val BADGE_MARGIN_TOP = 5 // [dp]
const val BADGE_MARGIN_LEFT = 15 // [dp]
}
#Inject internal lateinit var uiCalculator: UICalculatorInterface
private val bottomMenuView: BottomNavigationMenuView
init {
// Get access to internal menu
val field = BottomNavigationView::class.java.getDeclaredField("mMenuView")
field.isAccessible = true
bottomMenuView = field.get(this) as BottomNavigationMenuView
App.injections.presentationLayerComponent!!.inject(this)
#SuppressLint("CustomViewStyleable")
val a = context.obtainStyledAttributes(attrs, R.styleable.advanced_bottom_navigation_bar)
val badgeLayoutId = a.getResourceId(R.styleable.advanced_bottom_navigation_bar_badge_layout, -1)
a.recycle()
initBadges(badgeLayoutId)
}
/**
* [position] index of menu item */
fun setBadgeValue(position: Int, count: Int) {
val menuView = bottomMenuView
val menuItem = menuView.getChildAt(position) as BottomNavigationItemView
val badge = menuItem.findViewById(R.id.bottom_bar_badge)
val badgeText = menuItem.findViewById(R.id.bottom_bar_badge_text) as TextView
if (count > 0) {
badgeText.text = count.toString()
badge.visibility = View.VISIBLE
} else {
badge.visibility = View.GONE
}
}
/**
* Select menu item
* [position] index of menu item to select
*/
fun setSelected(position: Int) = bottomMenuView.getChildAt(position).performClick()
private fun initBadges(badgeLayoutId: Int) {
// Adding badges to each Item
val menuView = bottomMenuView
val totalItems = menuView.childCount
val oneItemAreaWidth = uiCalculator.getScreenSize(context).first / totalItems
val marginTop = uiCalculator.dpToPixels(context, BADGE_MARGIN_TOP)
val marginLeft = uiCalculator.dpToPixels(context, BADGE_MARGIN_LEFT)
for (i in 0 until totalItems) {
val menuItem = menuView.getChildAt(i) as BottomNavigationItemView
// Add badge to every item
val badge = View.inflate(context, badgeLayoutId, null) as FrameLayout
badge.visibility = View.GONE
badge.minimumWidth = uiCalculator.dpToPixels(context, BADGE_MIN_WIDTH)
val layoutParam = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT)
layoutParam.gravity = Gravity.START
layoutParam.setMargins(oneItemAreaWidth / 2 + marginLeft, marginTop, 0, 0)
menuItem.addView(badge, layoutParam)
}
}
}
It's attr.xml file with options for this component:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="advanced_bottom_navigation_bar">
<attr name="badge_layout" format="reference|integer" />
</declare-styleable>
</resources>
Background for badge from drawable folder:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<solid android:color="#ff0000" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
Badge itself:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:id="#+id/bottom_bar_badge"
android:layout_height="20dp"
android:layout_width="20dp"
xmlns:android="http://schemas.android.com/apk/res/android"
android:background="#drawable/bcg_badge"
>
<TextView
android:id="#+id/bottom_bar_badge_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textSize="10sp"
android:textColor="#android:color/white"
xmlns:android="http://schemas.android.com/apk/res/android"
android:textAlignment="center"
android:layout_gravity="center"/>
</FrameLayout>
And this is an example how to use it in your code:
<?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="su.ivcs.ucim.presentationLayer.userStories.mainScreen.view.MainActivity">
<su.ivcs.ucim.presentationLayer.common.advancedBottomNavigationView.AdvancedBottomNavigationView
android:id="#+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:itemBackground="#android:color/white"
app:itemIconTint="#color/main_screen_tabs_menu_items"
app:itemTextColor="#color/main_screen_tabs_menu_items"
app:menu="#menu/main_screen_tabs_menu"
app:badge_layout = "#layout/common_badge"
app:layout_constraintTop_toBottomOf="#+id/fragmentsContainer"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
</android.support.constraint.ConstraintLayout>
I hope this helps you.
#hrskrs Try adding a higher elevation on your txtCount or badgeWrapper itself.
BottomNavigationView seems to have higher elevation than the views on the screen.
I struggled with showing badges on BottomNavigationView items. My badge (without any text value) being part of the drawable itself turned grey when user clicked other item or became the same color defined in the tint (if not defined is colorPrimary).
I think you will run into the same problem I faced with colouring of the badge/counter on top of menu item of BottomNavigationViewas tint color will be applied to the item itself and your badgeWrapper being part of MenuItem will take the tint (turns grey when you tap any other item which you will not want I guess).
Check out my answer here: Is there a way to display notification badge on Google's official BottomNavigationView menu items introduced in API 25?
I used an ImageView for a badge but you can have your badgeWrapper RelativeView instead of the ImageView.
use the BadgeDrawable like this:
Integer amount = tabBadgeCountMap.get(tab);
BadgeDrawable badgeDrawable = bottomNavigationView.getOrCreateBadge(tabMenuResId);
badgeDrawable.setNumber(amount != null ? amount : 0);