I want to display a grid layout in a fragment with thousands (>1.000) grid cells. In order to save code lines, I want to add the grid cells programmatically when the fragment is created. The grid cells do not have to do more than just each of them individually displaying a certain colour.
The problem is, whenever the fragment is created, the UI is blocked for several seconds because the grid layout has to be setup first.
I tried making use of AsyncLayoutInflater but that doesn't really solve my problem since the xml layout itself is very small and is inflated without blocking the UI. Creating and adding thousands of views to the grid layout after the xml layout was inflated is what blocks the UI.
So my question is, how can I add all these grid cells to my grid layout in the background without blocking the UI?
My code:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.grid_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupGrid()
}
private fun setupGrid() {
// Row and column count is currently set to 60
for (yPos in 0 until gridLayout.rowCount) {
for (xPos in 0 until gridLayout.columnCount) {
val gridCell = ImageView(activity)
val params = GridLayout.LayoutParams(GridLayout.spec(yPos, 1f), GridLayout.spec(xPos, 1f))
gridCell.layoutParams = params
gridLayout.addView(gridCell)
}
}
}
My xml file:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android">
<GridLayout
android:id="#+id/gridLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="240dp"
android:layout_marginBottom="240dp"
android:columnCount="60"
android:rowCount="60"
android:orientation="horizontal" />
</RelativeLayout>
Screenshot how it should look like:
Thanks a lot!
Okay, so as the Android docs say, one should never call any methods or constructors on any view outside the UI thread since it is not thread safe. It may compile and actually run but it is unsafe to use.
I came up with a solution/workaround which actually only delays the UI block with a higher chance of the user not taking notice of it. This may only work in my specific scenario since I populate my grid with thousands of views only after a network connection was established. Populating the view still blocks the UI but the user may not notice it. A little tweak how to reduce the blocking UI time is described below.
The key to that is making use of an OnLayoutChangedListener. Once I have added all my views to my gridLayout, I call gridLayout.addOnChangeListener and implemented the listener to take care of the grid cells' layout params.
Here's the code:
fun configureGridLayout(gridHeight: Int, gridWidth: Int) {
println("Setting grid dimensions to: ${gridHeight}x${gridWidth}")
runOnUiThread {
gridLayout.rowCount = gridHeight
gridLayout.columnCount = gridWidth
for (g in 0 until gridHeight * gridWidth) {
val gridCell = View(context!!)
gridLayout.addView(gridCell)
}
gridLayout.addOnLayoutChangeListener(LayoutChangeListener(gridLayout, this))
}
}
// This callback is fired when fragment was completely rendered in order to reduce UI blocking time
class LayoutChangeListener(private val gridLayout: GridLayout, private val gridLayoutConfiguredListener: GridLayoutConfiguredListener) : View.OnLayoutChangeListener {
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
v?.removeOnLayoutChangeListener(this)
val gridRowCount = gridLayout.rowCount
val gridColumnCount = gridLayout.columnCount
val gridHeight = gridLayout.height
val gridWidth = gridLayout.width
val margin = 1
val h = gridHeight / gridRowCount
val w = gridWidth / gridColumnCount
for (yPos in 0 until gridRowCount) {
for (xPos in 0 until gridColumnCount) {
val params = GridLayout.LayoutParams()
params.height = h - 2 * margin
params.width = w - 2 * margin
params.setMargins(margin, margin, margin, margin)
// Use post to get rid of "requestView() was called twice" errors
gridLayout.getChildAt(yPos * gridColumnCount + xPos).post {
gridLayout.getChildAt(yPos * gridColumnCount + xPos).layoutParams = params
}
}
}
gridLayoutConfiguredListener.onGridLayoutConfigured()
}
}
Related
I have a ViewPager2 inside a BottomSheetDialog in which I load a Fragment that contains a ComposeView. Inside this view I populate a LazyList with items as soon as they're loaded.
Now this works all fine, except that the ViewPager2 makes no height adaptions when it's inner contents change, so naturally I adapted the peekHeight at first and then added a GlobalLayoutListener to give the pager the height of the inner, currently displayed fragment view, like so:
val myPager = ...
myPager.registerOnPageChangeCallback(AdaptChildHeightOnPageChange(myPager))
...
internal class AdaptChildHeightOnPageChange(private val viewPager: ViewPager2) : ViewPager2.OnPageChangeCallback() {
private val otherViews = mutableSetOf<View>()
private fun getViewAtPosition(position: Int): View =
(viewPager.getChildAt(0) as RecyclerView).layoutManager?.findViewByPosition(position)
?: error("No layout manager set or no view found at position $position")
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val itemView = getViewAtPosition(position)
val layoutListener = ViewTreeObserver.OnGlobalLayoutListener {
itemView.updatePagerHeightForChild()
}
// remove the global layout listener from other views
otherViews.forEach { it.viewTreeObserver.removeOnGlobalLayoutListener(it.tag as ViewTreeObserver.OnGlobalLayoutListener) }
itemView.viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
itemView.tag = layoutListener
otherViews.add(itemView)
}
private fun View.updatePagerHeightForChild() {
post {
val wMeasureSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY)
val hMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
measure(wMeasureSpec, hMeasureSpec)
if (viewPager.layoutParams.height != measuredHeight) {
viewPager.layoutParams = (viewPager.layoutParams as ViewGroup.LayoutParams)
.also { lp -> lp.height = measuredHeight }
}
}
}
}
(taken and adapted from https://stackoverflow.com/a/58632613/305532)
Now while this works fantastically with regular compose content, as soon as I switch my compose view to the LazyList implementation (or anything that uses Modifier.verticalScroll(...)), I receive the following exception:
Nesting scrollable in the same direction layouts like LazyColumn and \
Column(Modifier.verticalScroll()) is not allowed (Scroll.kt:370)
But I don't get this really, because I haven't nested any vertical-scolling compose elements that could trigger this exception. My only guess is that because of the height constraint I give to the ViewPager2 this internally triggers the enablement of vertical scrolling, making the inner LazyList unable to take over.
How can I solve this issue?
Ok, the crash seem to have stem from an issue with the GlobalLayoutListener. This constantly fired updates and kicked of relayouts, even though I tried to remove the listener explicitely before setting a new height to the surrounding pager.
it's hard to me to explain this problem, but you can see the below layout code,
First i have the layout look like this:
yeah, this is the call screen using webrtc, when i have the video, put it into main_render, the change the size when i have delegate for video size:
main_render.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
main_render.init(rootEglBase.eglBaseContext, object : RendererCommon.RendererEvents{
override fun onFirstFrameRendered() {
Log.e(TAG, "onFirstFrameRendered")
}
override fun onFrameResolutionChanged(i: Int, i1: Int, i2: Int) {
Log.e(TAG, "onFrameResolutionChanged: $i - $i1")
runOnUiThread {
val newParams = main_render.layoutParams as FrameLayout.LayoutParams
newParams.width = dm.widthPixels
newParams.height = i1 * dm.widthPixels / i
main_render.layoutParams = newParams
main_render.requestLayout()
main_layout.updateViewLayout(main_render, newParams)
main_layout.requestLayout()
}
}
})
But the problem is the size does not changed, i have to press to hide sheet, press again to show sheet then now the size is change ( i have onclick to hide and show collapse sheet)
Can someone help me know this problem, when remove sheet and using main_layout it's work normally, but when using sheet the size can not changed immediately
Try to set state of BottomSheet after update viewlayout if it helps:
val behavior = bottomSheetDialog.behavior
behavior.state = BottomSheetBehavior.STATE_EXPANDED
I have a MotionLayout inside the NestedScrollView:
<androidx.core.widget.NestedScrollView
android:id="#+id/scroll_content"
android:layout_width="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.motion.widget.MotionLayout
android:id="#+id/content_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="10dp"
app:layoutDescription="#xml/main_scene">
<View 1>
<View 2>
<View 3>
</androidx.constraintlayout.motion.widget.MotionLayout>
My state 1 shows View 1 only.
My state 2 shows View 2 only.
My state 3 shows View 1 + View 2(below View 1) + View 3(below View 2)
Since state 3 appends multiple views vertically, it is the longest vertically.
However, I can only scroll down to the amount set for state 1 & state 2. It does not reset the height inside the scrollView.
Am I doing something wrong?
I tried following at onTransitionCompleted():
scroll_content.getChildAt(0).invalidate()
scroll_content.getChildAt(0).requestLayout()
scroll_content.invalidate()
scroll_content.requestLayout()
They did not solve my issue.
Adding motion:layoutDuringTransition="honorRequest" inside the <Transition> in your layoutDescription XML file fixes the issue.
This was added to ConstraintLayout in version 2.0.0-beta4
Unfortunately, I also encountered such a problem, but found a workaround
<ScrollView>
<LinearLayout>
<MotionLayout>
and after the animation is completed
override fun onTransitionCompleted(contentContainer: MotionLayout?, p1: Int) {
val field = contentContainer::class.java.getDeclaredField("mEndWrapHeight")
field.isAccessible = true
val newHeight = field.getInt(contentContainer)
contentContainer.requestNewSize(contentContainer.width, newHeight)
}
requestViewSize this is an extension function
internal fun View.requestNewSize(width: Int, height: Int) {
layoutParams.width = width
layoutParams.height = height
layoutParams = layoutParams
}
if you twitch when changing height, just add animateLayoutChanges into your main container and your MotionLayout.
Add if necessary in code
yourView.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
--------UPDATE--------
I think I found a more correct option for animating the change in height.
Just the first line in the method onTransitionEnd, insert scroll.fullScroll (ScrollView.FOCUS_UP). I added so that the code for changing the height is executed in 500 milliseconds
override fun onTransitionCompleted(contentContainer: MotionLayout?, currentState: Int) {
if (currentState == R.id.second_state) {
scroll.fullScroll(ScrollView.FOCUS_UP)
GlobalScope.doAfterDelay(500) {
if (currentState == R.id.second_state) {
val field = contentContainer::class.java.getDeclaredField("mEndWrapHeight")
field.isAccessible = true
val newHeight = field.getInt(contentContainer)
contentContainer.requestNewSize(contentContainer.width, newHeight)
}
}
}
}
doAfterDelay is a function extension for coroutine
fun GlobalScope.doAfterDelay(time: Long, code: () -> Unit) {
launch {
delay(time)
launch(Dispatchers.Main) { code() }
}
}
But you can use alitenative
I have a ConstraintLayout. For the purposes of this example, we can have three views inside.
<android.support.constraint.ConstraintLayout ...>
<TextView
android:id="#+id/text"
.../>
<TextView
android:id="#+id/value"
.../>
<View
android:id="#+id/bar"
android:layout_width="0dp"
android:layout_height="8dp"
android:background="#color/bar_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="#+id/text"
app:layout_constraintEnd_toStartOf="#id/value"
/>
</android.support.constraint.ConstraintLayout>
At runtime, I want to set the width of bar to some value between the distance between text and value.
I already have a value between 0 and 100 which is a percentage of the distance between the two text views.
I've tried two methods so far but have gotten stuck.
Attempt 1
In order to do this, I feel I need the actual distance between text and value and a method to set the bar's width correctly.
I'm able to set the width of bar by doing the following:
val params: ConstraintLayout.LayoutParams = percentageBar.layoutParams as LayoutParams
params.matchConstraintMaxWidth = 300 // should be actual value rather than 300
percentageBar.layoutParams = params
This sets the width of the bar fine. But I need some way of figuring out what the actual number should be rather than 300. Something like (percent value * text-value-distance / 100).
Attempt 2
From looking at other answers on SO, I found out about the percent constraint.
...
<View
android:id="#+id/bar"
android:layout_width="0dp"
android:layout_height="8dp"
android:background="#color/bar_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="#id/value"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="#id/text"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="1.0"/>
...
Then, in the code, I can just do params.matchConstraintPercentWidth = item.percentageOfMaxValue.toFloat()
The problem with this is that when the percent value is set high, it's taking the percentage of the parent and goes past the start of value.
Am I heading in the right direction?
I hope I've described the problem clearly enough.
Thanks in advance.
// Update
I've pushed a simplified version with what resembles the problem I'm having.
BarProblem on GitHub
You can actually see the problem from the design tab of the layout editor.
// Update 2
Problem solved. GitHub repo now contains the code for the solution.
I good solution for your case is guidelines. Those helpers are anchors that won’t be displayed in your app, they are like one line of a grid above your layout and can be used to attach or constraint your widgets to it.
The key was that we need to wait until the ConstraintLayout is laid out. We can do this by using a ViewTreeObserver.OnGlobalLayoutListener.
Example solution:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
text.text = "Text"
value.text = "Value"
val globalLayoutListener = MainActivityGlobalListener(item_view, text, value, percentage_bar)
item_view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
}
}
class MainActivityGlobalListener(private val itemView: View,
private val text: View,
private val value: View,
private val bar: View) : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
Log.i("yoyo", "in onGlobalLayout")
val width = value.x - (text.x)
Log.i("yoyo", "Width: $width")
val params: ConstraintLayout.LayoutParams = bar.layoutParams as ConstraintLayout.LayoutParams
params.matchConstraintMaxWidth = (1.0 * width).toInt() // using 0.1 here - this value is dynamic in real life scenario
bar.layoutParams = params
// required to prevent infinite loop
itemView.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
}
Note the need to remove the listener on the last line to prevent an infinite loop from occurring (listen for layout -> set bar width -> triggers re-layout etc)
If using Android KTX, we can simplify to the following:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
text.text = "Text"
value.text = "Value"
item_view.doOnLayout {
Log.i("yoyo", "in onGlobalLayout")
val width = value.x - (text.x)
Log.i("yoyo", "Width: $width")
val params: ConstraintLayout.LayoutParams = percentage_bar.layoutParams as ConstraintLayout.LayoutParams
params.matchConstraintMaxWidth = (1.0 * width).toInt() // using 0.1 here - this value is dynamic in real life scenario
percentage_bar.layoutParams = params
}
}
Thanks to CommonsWare for the answer!
GitHub repo with code
I'm trying to make a transition with simple animation of shared element between Fragments. In the first fragment I have elements in RecyclerView, in second - exactly the same element (defined in separate xml layout, in the list elements are also of this type) on top and details in the rest of the view. I'm giving various transitionNames for all elements in bindViewHolder and in onCreateView of target fragment I'm reading them and set them to element I want make transition. Anyway animation is not happening and I don't have any other ideas. Here below I'm putting my code snippets from source and target fragments and list adapter:
ListAdapter:
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = list[position]
ViewCompat.setTransitionName(holder.view, item.id)
holder.view.setOnClickListener {
listener?.onItemSelected(item, holder.view)
}
...
}
interface interactionListener {
fun onItemSelected(item: ItemData, view: View)
}
ListFragment (Source):
override fun onItemSelected(item: ItemData, view: View) {
val action = ListFragmentDirections.itemDetailAction(item.id)
val extras = FragmentNavigatorExtras(view to view.transitionName)
val data = Bundle()
data.putString("itemId", item.id)
findNavController().navigate(action.actionId, data, null, extras)
}
SourceFragmentLayout:
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="#+id/pullToRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="#layout/item_overview_row" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
DetailFragment (Target):
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val rootView = inflater.inflate(R.layout.fragment_detail, container, false)
val itemId = ItemDetailFragmentArgs.fromBundle(arguments).itemId
(rootView.findViewById(R.id.includeDetails) as View).transitionName = itemId
sharedElementEnterTransition = ChangeBounds().apply {
duration = 750
}
sharedElementReturnTransition= ChangeBounds().apply {
duration = 750
}
return rootView
}
DetailFragmentLayout:
<include
android:id="#+id/includeDetails"
layout="#layout/item_overview_row"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
ItemOverviewRowLayout (this one included as item in recyclerView and in target fragment as header):
<androidx.cardview.widget.CardView 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:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
android:orientation="vertical" >
I made also another application using Jetpack navigation, shared elements and elements described by the same layout.xml and it's working since I'm not making transition from recyclerView to target fragment. Maybe I'm wrong here, setting the transitionName to found view in target fragment? I don't know how to make it another way, because the IDs of target included layout should be unique because of recyclerView items.
Okay, I found that how should it looks like to have enter animation with shared element:
In DetailFragment (Target) you should run postponeEnterTransition() on start onViewCreated (my code from onCreateView can be moved to onViewCreated). Now you have time to sign target view element with transitionName. After you end with loading data and view, you HAVE TO run startPostponedEnterTransition(). If you don't do it, ui would freeze, so you can't do time consuming operations between postponeEnterTransition and startPostponedEnterTransition.
Anyway, now the problem is with return transition. Because of course it's the same situation - you have to reload recyclerView before you release animation. Of course you can also use postponeEnterTransition (even if it's return transition). In my case, I have list wrapped by LiveData. In source fragment lifecycle observer is checking data. There is another challenge - how to determine if data is loaded. Theoretically with recyclerView you can use helpful inline function:
inline fun <T : View> T.afterMeasure(crossinline f: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (measuredWidth > 0 && measuredHeight > 0) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
}
})
}
...and in code where you are applying your layout manager and adapter you can use it like this:
recyclerView.afterMeasure { startPostponedEnterTransition() }
it should do the work with determine time when return animation should start (you have to be sure if transitionNames are correct in recyclerView items so transition can have target view item)
From the answer that using ViewTreeObserver is quite consume resources a lot. and also have a lot of processes to do. so I do suggest you use doOnPreDraw instead of waiting after recyclerView was measured. the code implement will like this below.
recyclerView.doOnPreDraw {
startPostponedEnterTransition()
}