I am working on a custom expandable view in android.
The goal is that I can add child elements in the xml files and they will be expanded and collapsed when the user clicks the expand/collapse button as on the picture below.
The expananding/collapsing works fine, but I cannot find out how to handle the child views.
In the constructor of my custom view, I inflate an xml layout, and I have a linear layout inside, in which i would like to put the child elements.
I tried using the solution suggested in the answer to the question here.
But I get StackOverflowError, and about a hundres of these:
"at android.view.ViewGroup.resetResolvedLayoutDirection(ViewGroup.java:7207)", even if I try to use the solution in the second aswer, using a while loop instead of the for.
Here is the kotlin class of my view:
class CollapsableCategoryView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
/** Declare some variables */
private var titleString : String = ""
private var subtitleString : String = ""
private var isExpaneded : Boolean = false
/** The required views */
private lateinit var ivIcon : ImageView
private lateinit var llExpandableContent : LinearLayout
init {
/** Receive the attributes */
context.theme.obtainStyledAttributes(
attrs,
R.styleable.CollapsableCategoryView,
0, 0
).apply {
try {
titleString = getString(R.styleable.CollapsableCategoryView_categoryTitle) ?: ""
subtitleString = getString(R.styleable.CollapsableCategoryView_categorySubtitle) ?: ""
} finally {
recycle()
}
}
/** Inflate the layout */
val root : View = View.inflate(context, R.layout.collapsable_task_category, this)
/** Find the views we need*/
ivIcon = root.findViewById(R.id.ivCollapsableCategoryIcon) as ImageView
llExpandableContent = root.findViewById(R.id.llExpandableContent) as LinearLayout
/** onClickListener for the icon */
ivIcon.setOnClickListener {
toggleExpanded()
}
}
override fun onFinishInflate() {
for(i in 0..childCount){
var view : View = getChildAt(i)
removeViewAt(i)
llExpandableContent.addView(view)
}
super.onFinishInflate()
}
/** This method is called when user clicks the expand/collapse button */
fun toggleExpanded(){
isExpaneded = !isExpaneded
if(isExpaneded)
{
ivIcon.setImageResource(R.drawable.ic_collapse)
llExpandableContent.visibility = VISIBLE
}else{
ivIcon.setImageResource(R.drawable.ic_expand)
llExpandableContent.visibility = GONE
}
}
}
I read somewhere else about a different solution, which also doesn't work. That solution suggests to ovverride the addView() method something like this:
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
llExpandableContent.addView(child, params)
}
But if I do so, I get an exception that the lateinint var llExpandableContent is never initialized.
I have also seen solutions that override onMeasure() method but that doesn't seem to be the right approach for me to this problem, since I don't wan't to lay my views out in a special way, just want to add them in a linear layout.
Here is the xml resource file for the layout of the custom view:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="#dimen/collapsable_category_corner_radius"
android:background="#drawable/bg_collapsable_category_top"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/clCollapsableCategoryMain"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#drawable/bg_collapsable_category_middle">
<ImageView
android:id="#+id/ivCollapsableCategoryIcon"
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_marginStart="8dp"
android:src="#drawable/ic_expand"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="#+id/clCollapsableCategoryTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Title"
app:layout_constraintStart_toEndOf="#+id/ivCollapsableCategoryIcon"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="#+id/clCollapsableCategorySubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Subtitle"
app:layout_constraintStart_toEndOf="#+id/ivCollapsableCategoryIcon"
app:layout_constraintTop_toBottomOf="#+id/clCollapsableCategoryTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="#+id/llExpandableContent"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#drawable/bg_collapsable_category_middle"
android:visibility="gone">
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="#dimen/collapsable_category_corner_radius"
android:background="#drawable/bg_collapsable_category_bottom"/>
</LinearLayout>
And here is how I am trying to use my custom view in a layout xml file:
<com.test.test.util.CollapsableCategoryView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Child view 1"/>
</com.test.test.util.CollapsableCategoryView>
Does anyone know how to solve this problem?
Thank you very much in advance for any help. Best regards,
Agoston
So I found the solution at another question, which I cannot find again...
But this solution works like a charm :)
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if(llExpandableContent == null){
super.addView(child, index, params)
}else{
llExpandableContent?.addView(child, index, params)
}
}
Hope it will help someone else at some point :)
Related
This is what I created
And what I want is like this below
Actually in here, I want my cards should look like cards in a wallet (as shown in the 2nd image above) which I can scroll like the native stack card view as shown in first image, problem I am facing is I am not able to align cards vertically straight, cards get cut if I try to do so.
I want to achieve this behaviour using Android Native stackview widget only.
My code is as below:
ActivityMain.kt
class MainActivity : AppCompatActivity() {
lateinit var cardAdapter: CardAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val stackView = findViewById<StackView>(R.id.stack_view)
cardAdapter= CardAdapter(numberWord(),numberImage(),R.layout.item_card,this)
stackView.adapter=cardAdapter
}
// the method numberWord() is used to
// add the text below corresponding images.
private fun numberWord(): List<String> {
val word: MutableList<String> = ArrayList()
word.add("One")
word.add("Two")
word.add("Three")
word.add("Four")
word.add("Five")
return word
}
// the method numberWord() is used to call
// the images that are added to the stack.
private fun numberImage(): List<Int>{
val image: MutableList<Int> = ArrayList()
image.add(R.drawable.ic_filter)
image.add(R.drawable.ic_filter)
image.add(R.drawable.ic_filter)
image.add(R.drawable.ic_filter)
image.add(R.drawable.ic_filter)
return image
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<!--We have used RelativeLayout for layout-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".MainActivity">
<!--Add StackView-->
<StackView
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:id="#+id/stack_view"
android:layoutDirection="ltr"
android:layout_width="400dp"
android:layout_height="300dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
CardAdapter.kt
class CardAdapter
(
var numberWord: List<String>,
var numberImage: List<Int>,
var itemLayout: Int,
var c: Context
) : ArrayAdapter<Any?>(
c, itemLayout, numberWord
) {
// getCount() is called to return
// the total number of words to be used
override fun getCount(): Int {
return numberWord.size
}
// getView() is called to get position,
// parent and view of the images.
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var convertView = convertView
if (convertView == null) {
convertView = LayoutInflater.from(parent.context).inflate(itemLayout, parent, false)
}
val word = numberWord[position]
val image = numberImage[position]
val textView = convertView!!.findViewById<TextView>(R.id.text_view)
val imageView = convertView.findViewById<ImageView>(R.id.image_view)
textView.text = word
imageView.setImageResource(image)
return convertView
}
}
item_card.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="300dp"
android:layout_height="180dp"
android:background="#drawable/border_bg"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="#+id/image_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="#drawable/ic_filter" />
<TextView
android:id="#+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="40sp"
android:textStyle="bold" />
</LinearLayout>
you guys can refer android native stackview: https://developer.android.com/reference/android/widget/StackView
I tried many different solution, I guess we can achieve that with third party library but that's not what I want. I want to create solution with using native android stackview.
Any help will be great.
I'm working on a dynamicViews (not sure if that's the right word for creating a view from a json file).
I'm getting the schema from a JSON file, I've stepped up the recycleView and its adapter, so far so good, each Recycleview item (must or not) contain a number of EditText whose number is unknown in advance, so based on the Json file, I have to inflate inside.
I searched a lot but the similar solution I found for Heterogene Recycleview: the idea was to use separate layout and inflate each of them according to your needs inside onCreateViewHolder but the developer who published the solution knew in advance what is the combination of all possible views and he just switch.
class Adapter_base_Display(private val listener: Display_Fragment,
activity: FragmentActivity ,
liste_display : ArrayList<DisplaySections>)
: RecyclerView.Adapter<Base_DisplayViewHolder>() {
private val activityIns = activity
private val liste_display_Recycle_adapter = liste_display
interface Base_DisplayListener {
fun onClickeddisplay(position: Int)
}
private val items = ArrayList<DisplaySections>()
fun setItems(items: ArrayList<DisplaySections>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
fun clear() {
val size: Int = items.size
items.clear()
notifyItemRangeRemoved(0, size)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Base_DisplayViewHolder {
val binding: ItemDisplayBinding =
ItemDisplayBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return Base_DisplayViewHolder(
binding,
listener as Base_DisplayListener,
activityIns,
parent,
liste_display_Recycle_adapter)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: Base_DisplayViewHolder, position: Int) =
holder.bind(items[position])
}
class Base_DisplayViewHolder(
private val itemBinding: ItemDisplayBinding,
private val listener: Adapter_base_Display.Base_DisplayListener,
private val activityIns: FragmentActivity,
private var parent: ViewGroup,
private val items: ArrayList<DisplaySections>,
) : RecyclerView.ViewHolder(itemBinding.root),
View.OnClickListener {
init {
itemBinding.root.setOnClickListener(this)
}
fun bind(item: DisplaySections) {
itemBinding.textView2.text = item.name
}
override fun onClick(v: View?) {
listener.onClickeddisplay(adapterPosition)
}
}
The EditText I want to inflate multiple time
<EditText
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="#+id/edittext_isplay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="15dp"
android:text="TextView"
android:maxLines="3"
android:textColor="#color/black"
android:textSize="18sp" />
data class DisplaySections(
val id : Int,
val name : String,
val createdAt : String,
val updatedAt : String,
val displayTypeId : Int,
val displayCustomFields : List<DisplayCustomFields> // Contains the elements that will be displayed as EditText
The Base layout-Recycleview Item which is common for all scenarios
<LinearLayout
android:id="#+id/parent_edittext" // ALl EditText container
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="#+id/textView2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TextView" />
</LinearLayout>
<LinearLayout
android:id="#+id/camera_linear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:visibility="visible">
<ImageView
android:id="#+id/addphoto"
android:layout_width="316dp"
android:layout_height="250dp"
android:layout_gravity="center"
android:layout_marginTop="#dimen/_20sdp"
android:src="#drawable/ajouter_photo"
app:tint="#color/clear_grey" />
<TextView
android:id="#+id/camera_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Ajouter des photos"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="#+id/plus_image"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="#dimen/_20sdp"
android:gravity="center"
android:orientation="vertical"
android:visibility="visible">
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/my_photo_recycle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
I going to post the solution I found.
To solve this problem, we absolutely must use a nested RecyclerView;
As I said above, I want to display a list of items with the same common layout (Imageview + Textview), but the tricky part here is the dynamic part of each item.
For recap:
Each element (may or may not) contain N-1 (EditText), it depends on what it gets from a json file.
if you want to solve this problem by creating multiple Viewholder and switch depending on which "ViewHolderType" you are wrong !, you will just create an infinite layout files, it doesn't make sense.
if you create more than one (EditText) and only change the visibility it may work, but if you get for example 100 EditText from the Json file you are not going to manually create 100 Edittext.
if you want to programmatically generate an EditText, you will affect every item in your view Recycle since you cannot create view inside OnbindViewHolder function.
the only way I found to solve this problem is to create a parent-child RecycleView whenever there is an (EditText) you send it to the child adapter and you keep your parent element safe in the parent adapter.
You can also put a condition (NULL tester) inside the Parent-OnbindViewholder whenever there is no data, you just don't call Child-adapter.
I hope this solution will be useful to anyone who has had this problem, and if you have another solution I will be very happy to test them.
I'm a Kotlin newbie learning how to create simple recyclerview apps. My code is supposed to list the integers 1..10 in vertically stacked cells. However, it only lists the first item. I've consulted several tutorials and reviewed my code several times(after long breaks), but I can't see anything wrong in my code.
I got the bright idea early today to print Log statements. Examining them, I note that my onBindViewHolder function is only called once. What blunder am I making?
Here is my log output:
D/QuoteAdapter: value is: 1
D/QuoteAdapter: index is: 0
D/QuoteAdapter: Size is: 10
my activity:
class MainActivity : AppCompatActivity() {
lateinit var mRecyclerView: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
mRecyclerView = findViewById(R.id.recyclerView)
mRecyclerView.layoutManager = LinearLayoutManager(this)
mRecyclerView.adapter = QuoteAdapter()
//mRecyclerView.setHasFixedSize(true)
}
}
my adapter:
class QuoteAdapter : RecyclerView.Adapter<QuoteViewHolder>() {
private val listOfInts = intArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
private val TAG = "QuoteAdapter"
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuoteViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.recyclerview_item_row, parent, false)
return QuoteViewHolder(view)
}
override fun getItemCount(): Int {
Log.d(TAG, "Size is: ${listOfInts.size.toString()}")
return listOfInts.size
}
override fun onBindViewHolder(holder: QuoteViewHolder, position: Int) {
val item = listOfInts[position]
Log.d(TAG, "value is: ${item.toString()}")
Log.d(TAG, "index is: ${position.toString()}")
holder.listTitle.text = item.toString()
}
}
my viewholder:
class QuoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val listTitle = itemView.findViewById(R.id.itemString) as TextView
}
my layout:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="#+id/itemString"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
my main layout is shown below:
<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="0dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
In your "my layout" try this:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="#+id/itemString"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
Notice layout_height of LinearLayout has changed to wrap_content
Also doubt you need the android:orientation="vertical" on your ViewHolders item xml unless you will add more than just 1 TextView in the future.
Like Zain says, you can just use a TextView on its own in a layout file, which will also fix the problem (so long as its height is wrap_content!)
There are actually a few included with Android - type android.R.layout. and you'll see a few things, like simple_list_item_1 which is just a styled TextView (you can ctrl+click the reference or whatever to look at the file). Can be nice if you just want to make a quick thing!
The ID of the TextView in android.R.layout.simple_list_item_1 is #android:id/text1 - note the android prefix, because its part of the android resources, not your app's. Which means you have to reference the ID in the same way as the layout, with android at the front: android.R.id.text1
You can get rid of the LinearLayout in the list item layout, and only keep the TextView.
So, replace your list item layout with:
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="#+id/itemString"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
I have a ViewPager2 which I'm using with a RecyclerView Adapter. I'm binding the children of each viewpager item via the ViewHolder and everything is working okay.
I have a bunch of elements in this ViewPager item XML, some RadioButton components and a Button. I want this button to move it to the next item.
I know how to do that externally, assigning a sibling to the ViewPager2 and then setting a click-listener in the activity and then comparing currentItem of the ViewPager with the adapter's total item count.
I want the "Move to next" button inside the ViewPager as I want to change it based on the inputs supplied to the RadioButton components.
I'm currently stuck at failing getting the ViewPager item's button in the activity to set the click-listener. Is there any workaround to get the child element via the adapter itself?
Here's the activity_quiz.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=".ui.quiz.view.QuizActivity">
<androidx.viewpager2.widget.ViewPager2
android:id="#+id/quiz_list"
android:layout_width="match_parent"
android:layout_height="600dp"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="16dp"
tools:listitem="#layout/quiz_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
The quiz_item.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:padding="#dimen/bigSpacing"
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="#+id/question"/>
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="#+id/choices">
<RadioButton
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="#+id/choice_1"/>
<RadioButton
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="#+id/choice_2"/>
<RadioButton
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="#+id/choice_3"/>
<RadioButton
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="#+id/choice_4"/>
</RadioGroup>
<Button
android:id="#+id/result_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Next question" />
</LinearLayout>
The MyAdapter class (kotlin)
class MyAdapter: RecyclerView.Adapter<MyAdapter.MyViewHolder> {
override fun onCreateViewHolder(...) {
...
}
override fun getItemCount(): Int {
...
}
override fun onBindViewHolder(...) {
...
}
class MyViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
fun bind(someData: SomeData) {
itemView.question.text = somedata.question
itemView.choice_1.text = somedata.choice_1
itemView.choice_2.text = somedata.choice_2
itemView.choice_3.text = somedata.choice_3
itemView.choice_4.text = somedata.choice_4
val answerKey = someData.answerKey
var rightOrWrong = ""
itemView.choices.setOnCheckedChangeListener {_, checkedID ->
val checkedIndex: Int = itemView.choices.indexOfChild(itemView.choices.findViewById(checkedID))
if(checkedIndex + 1 == answerKey.toInt()) {
rightOrWrong = "Correct! Next Question."
} else {
rightOrWrong = "Incorrect :/ Next Question."
}
itemView.result_button.text = rightOrWrong
}
}
}
And the QuizActivity.kt file
class QuizActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_quiz)
val myAdapter = MyAdapter()
quiz_list.adapter = myAdapter
// quiz_list.result_button.setOnClickListener {
// No idea how to get the children correctly, as this one throws errors
// }
}
}
First, why do you want to use RecyclerView.Adapter<MyAdapter.MyViewHolder> as an adapter for viewpager2? There is FragmentStateAdapter a built-in class implementation of PagerAdapter that uses a Fragment to manage each page, which is recommended for viewpager2.
Second, you are not inflating the views in MyViewHolder, I don't know if you left them intentionally for this post.
You cant access child views like this quiz_list.result_button.setOnClickListener {} while using something like RecyclerView, Viewpagger. You can access them in the ViewHolder after you inflate them, you can set any listener there aswell
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!