Jumping scrolling when switching fragments
Asked Answered
B

4

8

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:

  1. I am scrolling down until I reach the button "show yellow".
  2. 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?

roll

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.

Bonkers answered 13/2, 2020 at 22:53 Comment(3)
When I try to reproduce this, the "jumping" only happens if the view is scrolled up enough that the space below the buttons is larger than the total size of the yellow fragment. There's nothing below that, so there's no way to keep the buttons in position. Are you sure that this is actually a bug?Leto
After thinking more about your comment, I think you are right that the current behavior is technically correct. Scrollview tries to fill the complete screen and after switching to the yellow fragment there is not enough content below it, so it scrolls to get some more content from above. So I can imagine two solutions: 1) tell scrollview to not force to fill the screen 2) when switching to the yellow fragment, add the height difference between blue and yellow fragment as bottom padding to the outer constraintlayout or to the scrollview, if it directly supports it.Bonkers
@BenP. do you have any idea how to best do this? e.g. for 1) if it possible at all and for 2) how to do it in a way that avoids unnecessary rendering as much as possible.Bonkers
G
1

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)

Giantess answered 19/2, 2020 at 23:27 Comment(8)
first of all, thank you very much! Due to complexity of this topic I will check your answer and the other ones on saturday. Do you have a link or can you add more information about the rendering lifecycle? I am not yet so much into all the methods like onMeasure etc and how getSuggestedMinimumHeight() is related to them or in which order they are all executed.Bonkers
@stefan.at.wpf onMeasure is first step of measuring of rendering process and getSuggestedMinimumHeight(and just a getter so won't cause additional rendering) is being used inside onMeasure to define the height. setMinHeight will call requestLayout() while will result in in measuring, laying out, and drawing the layout view treeGiantess
@stefan.at.wpf added more info and use case which is tedious or cannot be handled manually, specially when the number of fragments, views are unknown or growing dynamically. this solution is a dynamic solution for all scenarios and requires no manual handling.Giantess
just checked and tested your answer. Actually I want the FrameLayout to wrap content. Otherwise there's a gap when content follows after the FrameLayout. Would you add a second variant for completeness, where the FrameLayout wraps content? Like the attempt anyways, because I learnt about the rendering cycle :-) Check the update to my original post, I implemented your version including content following the FrameLayout. Maybe could be used as basis for a wrapping FrameLayout :-)Bonkers
@stefan.at.wpf yeah, then I can create a custom constraint layout(or any container layout) to avoid manual settings with minimum height or other approach is adding additional view(as you said, Maybe could be used as basis for a wrapping FrameLayout) so here's the demo with output though can try adding 3rd biggest fragment, that will work with this approach but not with manual height setup.Giantess
Thank you, I accepted this as answer. There is one thing I don't like about the new solution: one is forced to nest its views inside the MaxHeightFrameLayout. Guess one could do the same modification to the ConstraintLayout root view, right? But for obvious reasons the implementation on the FrameLayout is more performant, as it has less children.Bonkers
@stefan.at.wpf yes, the new solution is bit clumsy and requires more view but it's a tradeoff to handle dynamic scenario though I can do the custom modification with Constraint layout though I tried it before but with different approach, I will update the answer and I am glad that I could help, Happy coding!!Giantess
@stefan.at.wpf please feel free to look at the updates, Happy coding!Giantess
J
1

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

  1. the amount by which the NestedScrollView has scrolled in the "y" direction

plus

  1. 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.

enter image description here

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:

enter image description here

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.

enter image description here

Josphinejoss answered 20/2, 2020 at 15:23 Comment(18)
well, this straightforward solution is the manual implementation(tedious) of my solution to adjust the minHeight, applied on different container :). anyway, I also thought of this approach but it won't work in all scenarios, like what if the first fragment is smaller than the second fragment then this logic will fail as you won't have the desired height. other example, what if there are more than two fragments (quite common) and third one is bigger than the previous ones.Giantess
@Giantess I haven't examined your code, so I can't comment on similarities. Although, from your comment, I surmise that the two are not the same. I do believe that what I presented is a general one. The buttons jump because the child of the NestedScrollView (the ConstraintLayout) doesn't have enough height to cover the part of the scroll view that is scrolled off-screen plus what is on-screen. As long as that height requirement is met (through the min height calculation), it doesn't really matter what else happens below the buttons. You may be looking at my first post before edits.Josphinejoss
It's not the code, just the core idea for the solution though no problem whatsoever. I understand the concept pretty well and as I said before and now, your height requirements won't match if the first fragment is small so won't work. Plus, setting and maintenaning height explicitly is tedious and will cause additional rendering as well.Giantess
@Giantess I'm wondering now if you are commenting on the answer you think you are. I don't set any height explicitly and the small first fragment is not a problem AFAIK.Josphinejoss
Cheticamp, just wanted to say thanks a lot and let you know I will have a closer look on this and the other answers on saturday. Now I am not so much into the rendering lifecycle, but as the FragmentManager commit is called before adjustMinHeight, I assume there's an additional rendering cycle involved?Bonkers
@stefan.at.wpf The commit is scheduled and does not happen immediately. If you want, you can adjust the min height before the commit since the min height is not dependent upon the fragment, although I don't think it matters.Josphinejoss
@Josphinejoss here height is being set explicitly layout.minHeight = nsv.scrollY + nsv.height (additional rendering)and now about small first fragment, Actually first won't be the problem but will be the culprit of problem as it will decide the minHeight value which can caused issue if there are multiple fragments(more than 2, having height 200, 400, 100, 400).Giantess
@Giantess So, you are commenting on the answer you think you are. You are right, min height is being set. We will just have to disagree on the question of multiple fragments being a problem. :) At any rate, this answer addresses the question as posed by the OP and just involves (basically) a single line of code.Josphinejoss
@Josphinejoss I implemented the solution with minHeight because I knew that it will work so I know will work this way as well :) and for multiple fragment, I believe min height needs to adjusted when you find another smaller/smallest fragment though OP requested to avoid unnecessary rending so it's upto OP now :PGiantess
@Giantess Not a contest. I peeked at your solution and have an idea of what you are doing. It's a trade-off to set the min height blindly and let the system work it out and going through the effort to decide when min height will have an effect and only setting it then. Something to consider: The OP states that there may be views below the fragment, so is the fragment the right view to set the min height?Josphinejoss
@Giantess Took another look. It's a nice solution. I like the use of getSuggestedMinimumHeight().Josphinejoss
@Josphinejoss I was mentioning the fact about OP has the right. I believe it's a trade off to avoid extra rending and making the right decision at the right time. my logic is applied on framelayout so won't have any issue with additional view below it. I am not saying it's not a nice solution, it will work only for this particular scenario though the optimal in terms of rendering, real world complexity and manual handling.Giantess
@stefan.at.wpf Since, in all likelihood, you are changing the size of the FrameLayout_ that holds the fragment and, therefore, the size of the ConstraintLayout, you will go through a layout pass anyway. Setting the min height on the ConstraintLayout before the layout pass won't cause any additional passes.Josphinejoss
as mentioned, it will trigger the rendering explicitly no matter it's called before or after whereas I am handling this during fragment creation, with logic so no explicit rendering occur.Giantess
doh, I still have to learn more about the rendering lifecycle and how to check using debug tools if there is a new rendering pass. So I won't judge on this. Would be cool if anyone of you could "prove" if there is an additional rendering cycle or not by hooking up some tools and explaining how to use them. But that might be a little bit too much asked, but just a though ;-) I like Cheticamp's answer anyway for it's short code and at least there is no noticable flickering or so. Like the other answers also, I am positively surprised by all the detailed answers here :-)Bonkers
@stefan.at.wpf When the fragment is changed, there is a layout pass. There is a 2nd pass when min height is set and scroll has changed on the NestedScrollView. (Set a breakpoint on onLayout() in ConstraintLayout to see how many times called.) Only you can tell if this is a problem for your app. ConstraintLayout is highly optimized, so the pass may not be as costly as you may think. ConstraintLayout doesn't need to do a layout, but it does. If I inhibit the call to requestLayout() in setMinHeight() there is no 2nd pass and all is well, but that is ugly.Josphinejoss
@Josphinejoss I ended up selecting the answer by Pavneet_Singh, but I still love the short code of your reply. Unfortunately stackoverflow does not allow splitting the bounty, otherwise I would have done that. Thank you very much for your answer and comments!Bonkers
@stefan.at.wpf No problem. Good luck with your project.Josphinejoss
B
0

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

Boyt answered 13/2, 2020 at 22:58 Comment(1)
Thanks for your reply, your explanation makes sense. I updated my post and added the XML for both fragments. In this example they both have fixed (but of course differents) heights. In my "real" app their height is of course wrap_content. I can't set the height of the FrameLayout to a fixed height, as in my real app I have some content after the FrameLayout. I didn't include it in the example, sorry \: Any idea how I could handle my case?Bonkers
L
0

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!

Leto answered 16/2, 2020 at 17:13 Comment(7)
thank you for that extensive reply! give me some time to experiment it and wait for maybe other answers without flickering. Otherwise I will of course accept it as a good starting basis and reward the bounty :-)Bonkers
@stefan.at.wpf I've updated my answer with a workaround for the flickeringLeto
once again, thank you very much! I will have a closer look on saturday :-)Bonkers
I checked your updated version and it works fine. When one touches yellow again, then it jumps like in the description of my original issue, but I wouldn't consider this a practical issue though. Does your version cause an additional rendering cycle? I have a hard time to select ONE answer from all the great answers here \:Bonkers
Ah, yes, the padding only updates to be at least as large as the previous thing, so if the "previous" fragment is yellow and you add yellow again, the padding will become 0. You could solve that by keeping a single "maximum" value instead of just the previous one. And hey, upvote whatever you find helpful and award whoever you want!Leto
As for "an additional rendering cycle", I think mine causes an eager layout pass, but given my understanding of how the android system works, this just means that the normal layout pass you'd usually get will be skipped because the view knows it's already been laid out.Leto
thank you for your efforts here. I ended up selecting one of the other answers, but I highly appreciate your efforts. Unfortunately it's not possible to split bounties, otherwise I would have also given some to you for all the work here. Thanks!Bonkers

© 2022 - 2024 — McMap. All rights reserved.