I'm using custom Pages in my ViewPager and entire App is based on those screens.
There are 2 main abstract functions in those Pages.
First one is getScreen() which suppose to copy function of onCreateView from Fragment. Its called inside ViewPagerAdapters function to initialize layout for that screen.
Example from adapter:
override fun instantiateItem(container: ViewGroup, position: Int): View {
val page = pageList[position]
val layout = page.getScreen(container)
App.log("flowSwitch: instantiateItem: getScreen: ${page::class.java}")
container.addView(layout)
page.screenLayoutWasInitialized = true
return layout
}
Another function is onScreenSwitched(). This one suppose to be called only if I switch to the screen manually by swiping/clicking on tab/calling it in code to get into next screen.
There is initializing some values for Views, sometimes based on payload provided from previous screens.
I call this onScreenSwitched() function inside switchScreen() function which is part of my Navigation class. I just pass there screen name and payload. Its always called after mainViewPager.setCurrentItem(index, useAnim).
Example:
fun switchScreen(
screen: Class<out FlowScreen>,
payload: List<ScreenPayload>? = null,
action: (() -> Unit)? = null,
useAnim: Boolean = true,
){
App.log("AppNavigationFlow: MainTabActivity: switchScreen: $screen")
try {
val index = mainFlowList.indexOfFirst { item -> item::class.java == screen }
App.log("AppNavigationFlow: MainTabActivity: switchScreen: index: $index")
if (index >= 0){
delayedScreenSelection {
mainTabLayout.getTabAt(index)?.select()
App.log("AppNavigationFlow: MainTabActivity: switchScreen: pageNameAtPos: ${mainViewPager.adapter?.getPageTitle(index)}")
mainViewPager.setCurrentItem(index, useAnim)
mainPagerAdapter.getPageAtPos(index)?.apply {
App.log("AppNavigationFlow: MainTabActivity: switchScreen: page: ${this::class.java}")
mainCurrentPage?.apply {
setScreenVisibleState(false)
resetToDefault()
removeBottomSheet(false)
this#MainTabActivity.removeBottomSheet(false)
}
mainCurrentPage = this
mainCurrentPage?.apply {
setScreenVisibleState(true)
clearPayload()
clearAction()
}
payload?.let { mainCurrentPage?.sendPayload(payload) }
action?.let { mainCurrentPage?.setAction(action) }
onScreenSwitched()
onPageChanged()
}?:kotlin.run {
setTabsEnabled(true)
}
}
} else {
setTabsEnabled(true)
}
}catch (e: IndexOutOfBoundsException){
App.log("AppNavigationFlow: MainTabActivity: switchScreen: $e")
setTabsEnabled(true)
}
}
98% of users are getting always called getScreen function before onScreenSwitched function, therefore my layout is completely initialized by that time onScreenSwitched is called.
But for 2% of users, they are getting kotlin.UninitializedPropertyAccessException because for example I'm trying to setup text for Button which was not initialized yet in getScreen function.
How to prevent this? I'm not sure if ViewPager should allow that to happen. How can setContentView ignore instantiateItem call if layout was not initialized yet for that screen I'm switching to? I ditched Fragments because of this bug happening in Fragments too and its happening again with fully customized logic. How can I build something functional when I cant even rely on basic native components to work as it suppose to at first place? There is possibly something I'm missing but 98% of time its working and I personally cant simulate those crashes but I want to fix it for those 2% of users.
Example usage in Page:
private lateinit var toolbarTitle: TextView
private lateinit var acceptButton: LoadingButton
override fun getScreen(collection: ViewGroup): View {
val layout = CustomResources.inflateLayout(inflater, l, collection, false) as ViewGroup
toolbarTitle = layout.findText(R.id.actionbarTitle)
acceptButton = layout.findViewById(R.id.acceptButton)
return layout
}
override fun onScreenSwitched() {
super.onScreenSwitched()
acceptButton.setText(if(payload.ok) "Yes" else "No")
}
I have several views inside another view.
I need to show the container view if at least one view is visible. So, if none of the view's visibility is VISIBLE, then the container should itself hide.
It could be done by using constraintlayout group or any other ways in fragment.
But I am using Data Binding and I needed to handle it in ViewModel with LiveData. So I tried using MediatorLiveData. And it is not working as expected.
Here is how my code looks like:
class MyViewModel: ViewModel() {
val firstViewVisibility: LiveData<Int> = checkVisibility(firstView)
val secondViewVisibility: LiveData<Int> = checkVisibility(secondView)
val thirdViewVisibility: LiveData<Int> = checkVisibility(thirdView)
// and so on
val viewContainerVisibility = MediatorLiveData<Int>.apply {
fun update(visibility: Int) {
value = visibility
}
addSource(firstViewVisibility) {
update(it)
}
addSource(secondViewVisibility) {
update(it)
}
addSource(thirdViewVisibility) {
update(it)
}
// and so on
}
}
CheckVisibility function:
private fun checkVisibility(viewType: String) =
Transformations.map(myLiveData) { value ->
if(some logic involving value returns true) View.VISIBLE
else View.GONE
}
This is not working as the parent view's visibility depends upon the visibility added by last addSource in MediatorLiveData. So, if the last view's visibility is VISIBLE then the parent will be Visible and if it is GONE, the parent will be gone even though other view's visibility are VISIBLE.
Is MediatorLiveData not best fit here? OR I mis-utilized it?
What could be the best solution for my case?
Currently, when you update Visibility of the container, if the latest update of any view out of three is invisible, it set value as invisible even though previously any of three was visible. SO you need to update the Update() method. Something similar like this
val viewContainerVisibility = MediatorLiveData<Int>.apply {
fun update() {
if(firstViewVisibility.value == View.Visible || secondViewVisibility.value == View.Visible || thirdViewVisibility.value == View.Visible)
{View.Visible}
else{
View.GONE //or INVISIBLE as required}
}
addSource(firstViewVisibility) {
update()
}
addSource(secondViewVisibility) {
update()
}
addSource(thirdViewVisibility) {
update()
}
// and so on
}
First: I created a sample project showing this problem. By now I begin to think that this is a bug in either RecyclerView or MotionLayout.
https://github.com/muetzenflo/SampleRecyclerView
This project is set up a little bit different than what is described below: It uses data binding to toggle between the MotionLayout states. But the outcome is the same. Just play around with toggling the state and swiping between the items. Sooner than later you'll come upon a ViewHolder with the wrong MotionLayout state.
So the main problem is:
ViewHolders outside of the screen are not updated correctly when transition from one MotionLayout state to another.
So here is the problem / What I've found so far:
I am using a RecyclerView.
It has only 1 item type which is a MotionLayout (so every item of the RV is a MotionLayout).
This MotionLayout has 2 states, let's call them State big and State small
All items should always have the same State. So whenever the state is switched for example from big => small then ALL items should be in small from then on.
But what happens is that the state changes to small and most(!) of the items are also updated correctly. But one or two items are always left with the old State. I am pretty sure it has to do with recycled ViewHolders. These steps produce the issue reliably when using the adapter code below (not in the sample project):
swipe from item 1 to the right to item 2
change from big to small
change back from small to big
swipe from item 2 to the left to item 1
=> item 1 is now in the small state, but should be in the big state
Additional findings:
After step 4 if I continue swiping to the left, there comes 1 more item in the small state (probably the recycled ViewHolder from step 4). After that no other item is wrong.
Starting from step 4, I continue swiping for a few items (let's say 10) and then swipe all the way back, no item is in the wrong small state anymore. The faulty recycled ViewHolder seems to be corrected then.
What did I try?
I tried to call notifyDataSetChanged() whenever the transition has completed
I tried keeping a local Set of created ViewHolders to call the transition on them directly
I tried to use data-binding to set the motionProgress to the MotionLayout
I tried to set viewHolder.isRecycable(true|false) to block recycling during the transition
I searched this great in-depth article about RVs for hint what to try next
Anyone had this problem and found a good solution?
Just to avoid confusion: big and small does not indicate that I want to collapse or expand each item! It is just a name for different arrangement of the motionlayouts' children.
class MatchCardAdapter() : DataBindingAdapter<Match>(DiffCallback, clickListener) {
private val viewHolders = ArrayList<RecyclerView.ViewHolder>()
private var direction = Direction.UNDEFINED
fun setMotionProgress(direction: MatchCardViewModel.Direction) {
if (this.direction == direction) return
this.direction = direction
viewHolders.forEach {
updateItemView(it)
}
}
private fun updateItemView(viewHolder: RecyclerView.ViewHolder) {
if (viewHolder.adapterPosition >= 0) {
val motionLayout = viewHolder.itemView as MotionLayout
when (direction) {
Direction.TO_END -> motionLayout.transitionToEnd()
Direction.TO_START -> motionLayout.transitionToStart()
Direction.UNDEFINED -> motionLayout.transitionToStart()
}
}
}
override fun onBindViewHolder(holder: DataBindingViewHolder<Match>, position: Int) {
val item = getItem(position)
holder.bind(item, clickListener)
val itemView = holder.itemView
if (itemView is MotionLayout) {
if (!viewHolders.contains(holder)) {
viewHolders.add(holder)
}
updateItemView(holder)
}
}
override fun onViewRecycled(holder: DataBindingViewHolder<Match>) {
if (holder.adapterPosition >= 0 && viewHolders.contains(holder)) {
viewHolders.remove(holder)
}
super.onViewRecycled(holder)
}
}
I made some progress but this is not a final solution, it has a few quirks to polish. Like the animation from end to start doesn't work properly, it just jumps to the final position.
https://github.com/fmatosqg/SampleRecyclerView/commit/907ec696a96bb4a817df20c78ebd5cb2156c8424
Some things that I changed but are not relevant to the solution, but help with finding the problem:
made duration 1sec
more items in recycler view
recyclerView.setItemViewCacheSize(0) to try to keep as few unseen items as possible, although if you track it closely you know they tend to stick around
eliminated data binding for handling transitions. Because I don't trust it in view holders in general, I could never make them work without a bad side-effect
upgraded constraint library with implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"
Going into details about what made it work better:
all calls to motion layout are done in a post manner
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
Compared actual vs desired properties
val goalProgress =
if (currentState) 1f
else 0f
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
if (motionLayout.currentState != desiredState) {
safeRunBlock {
startTransition(currentState)
}
}
}
This would be the full class of the partial solution
class DataBindingViewHolder<T>(private val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
val motionLayout: MotionLayout =
binding.root.findViewById<MotionLayout>(R.id.root_item_recycler_view)
.also {
it.setTransitionDuration(1_000)
it.setDebugMode(DEBUG_SHOW_PROGRESS or DEBUG_SHOW_PATH)
}
var lastPosition: Int = -1
fun bind(item: T, position: Int, layoutState: Boolean) {
if (position != lastPosition)
Log.i(
"OnBind",
"Position=$position lastPosition=$lastPosition - $layoutState "
)
lastPosition = position
setMotionLayoutState(layoutState)
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
fun setMotionLayoutState(currentState: Boolean) {
val goalProgress =
if (currentState) 1f
else 0f
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
if (motionLayout.currentState != desiredState) {
Log.i("Pprogress", "Desired doesn't match at position $lastPosition")
safeRunBlock {
startTransition(currentState)
}
}
}
}
fun startTransition(currentState: Boolean) {
if (currentState) {
motionLayout.transitionToStart()
} else {
motionLayout.transitionToEnd()
}
}
}
Edit: added constraint layout version
I have a ViewSwitcher inside a DialogFragment. The switcher holds 2 views, a "1. selection view" and a "2. selected view".
The initial view is the defaulted to "1. selection view". When fromType is non-zero from the observing event database, I want the Livedata observer to call dialogViewSwitcher.showNext(), but the showNext() function is not called on dialog's initialization.
DialogFragment, inside onCreateDialog:
viewModel.event.observe(this, Observer {
it?.let {
viewModel.setCurrentFromType(it.fromPlaceType)
}
})
viewModel.currentFromType.observe(this, Observer {
if (it == 0) {
viewModel.setCurrentFromStatus(false)
} else {
viewModel.setCurrentFromStatus(true)
}
})
viewModel.currentFromStatus.observe(this, Observer {
binding.dialogViewSwitcher.showNext()
})
ViewModel:
private val _currentFromType = MutableLiveData(0)
val currentFromType: LiveData<Int>
get() = _currentFromType
fun setCurrentFromType(fromType: Int) {
_currentFromType.value = fromType
}
private val _currentFromStatus = MutableLiveData(false)
val currentFromStatus: LiveData<Boolean>
get() = _currentFromStatus
fun setCurrentFromStatus(status: Boolean) {
_currentFromStatus.value = status
}
Log shows the currentFromStatus observed the change (from false to true) when the dialog opens, but I'm thinking that because the dialogViewSwitcher hasn't initialized yet inside onCreatDialog when the change to currentFromStatus was observed, so showNext() did nothing. I also looked into data binding and didn't find a ViewSwitcher property in xml that shows the second view. What should I do to fix this behaviour?
Didn't find a good way to programmatically showNext() at onCreate. Instead I deleted the viewswitcher and use databinding on the visibility of each of the 2 child views, and this way works well.
I have an activity which contains 2 group of views, which CAN'T be located into same LAYOUT group but belong to same LOGIC group, meaning that they should be shown or hidden and bind click event at same time. The thing is that I feel really awful to write something like this:
fun hide() {
view1.visibility = View.GONE
view2.visibility = View.GONE
view3.visibility = View.GONE
// ...
view9.visibility = View.GONE
}
fun show() {
view1.visibility = View.VISIBLE
view2.visibility = View.VISIBLE
view3.visibility = View.VISIBLE
// ...
view9.visibility = View.VISIBLE
view1.setOnClickListener{ run() }
view2.setOnClickListener{ run() }
view3.setOnClickListener{ run() }
// ...
view9.setOnClickListener{ run() }
}
I did read a post which describes a kotlin skill to simplify this mess by somehow grouping those views then just handle the groups, but unfortunately I can no longer find that post..
Help would be appreciated!
========= Update 2019-07-31 =========
I found the solution but forgot to update this question, the 'grouping' I was looking for, is in fact not a Kotlin specific feature but simply using vararg, and we can use Kotlin extension (which is AWESOME) to simplify a bit more:
// assume we have a base activity or fragment, then put below functions in there
fun View.show() {
visibility = View.VISIBLE
}
fun show(vararg views: View) {
views.forEach { it.show() }
}
fun View.hide() {
visibility = View.GONE
}
fun hide(vararg views: View) {
views.forEach { it.hide() }
}
// then in any activity or fragment
show(v1, v2, v3, v4)
v9.hide()
============= updated 2020-03-07 ================
This is exactly androidx.constraintlayout.widget.Group designed to do, which can logically group a bunch of views from anywhere and control their visibility by only changing group's visibility.
Since ConstraintLayout 1.1 you can use Group instead of LayoutGroup.
You can simply add this code to you XML layout
<android.support.constraint.Group
android:id="#+id/profile"
app:constraint_referenced_ids="profile_name,profile_image" />
And then you can call it from code to achieve behavior, that you need
profile.visibility = GONE
profile.visibility = VISIBLE
For more details read this article https://medium.com/androiddevelopers/introducing-constraint-layout-1-1-d07fc02406bc
You need to create extension functions.
For example:
fun View.showGroupViews(vararg view: View) {
view.forEach {
it.show()
}
}
fun View.hideGroupViews(vararg view: View) {
view.forEach {
it.hide()
}
}
Create a list of views and loop on it
val views = listOf<View>(view1, view2, ...)
views.forEach {
it.visibility = View.GONE
}
You can also create extension function of Iterable<View> to simplify any kind of action on listed views
fun Iterable<View>.visibility(visibility: Int) = this.forEach {
it.visibility = visibility
}
//usage
views.visibility(View.GONE)
Maybe you want to locate all views from tags in XML. Take a look at this answer
Depending on how is your layout structured you might want to group those views in a ViewGroup like LinearLayout, RelativeLayout, FrameLayout or ConstraintLayout.
Then you can change visibility just on the ViewGroup and all of its children will change it too.
Edit:
Without ViewGroup the only solution to eliminating this boilerplate is to enable databinding in your project and set it like this:
In your Activity/Fragment:
val groupVisible = ObservableBoolean()
fun changeVisibility(show: Boolean) {
groupVisible.set(show)
}
In your xml:
<layout>
<data>
<variable name="groupVisible" type="Boolean"/>
</data>
<View
android:visibility="#{groupVisible ? View.VISIBLE : View.GONE}"/>
</layout>
Why don't you create an array:
val views = arrayOf(view1, view2, view3, view4, view5, view6, view7, view8, view9)
then:
fun show() {
views.forEach {
it.visibility = View.VISIBLE
it.setOnClickListener{ }
}
}
fun hide() {
views.forEach { it.visibility = View.INVISIBLE }
}
Or without an array if the names of the views are surely like view1, view2, ...
for (i in 1..9) {
val id = resources.getIdentifier("view$i", "id", context.getPackageName())
val view = findViewById<View>(id)
view.visibility = View.VISIBLE
view.setOnClickListener{ }
}
You can define a function with three parameters and use vararg like following code:
fun changeVisiblityAndAddClickListeners(visibility: Int,
listener: View.OnClickListener,
vararg views: View) {
for (view: View in views) {
view.visibility = visibility
if (visibility == View.VISIBLE) {
view.setOnClickListener(listener)
}
}
}
Of course if you have too many views, this is not a effective solution. I just added this code snippet for an alternative way especially for problems with dynamic view set.
If your views are not inside a view group you can use an extension function. You could create one to toggle the visibility of the views:
fun View.toggleVisibility() {
if (visibility == View.VISIBLE) {
visibility = View.GONE
} else {
visibility = View.VISIBLE
}
}
And you can use it like this:
view.toggleVisibility()
First in your xml layout, group your views by android:tag="group_1" attribute.
Then inside your activity use a for loop to implement whatever logic you need:
val root: ViewGroup = TODO("find your root layout")
for (i in 0 until root.childCount) {
val v = root.getChildAt(i)
when (v.tag) {
"group_1" -> {
TODO()
}
"group_2" -> {
TODO()
}
else -> {
TODO()
}
}
}
You can create a LinearLayout or any other ViewGroup containing your child Views and give it an ID in the XML file, then in your Activity or Fragment class define these functions:
fun disableViewGroup(viewGroup: ViewGroup) {
viewGroup.children.iterator().forEach {
it.isEnabled = false
}
}
fun enableViewGroup(viewGroup: ViewGroup) {
viewGroup.children.iterator().forEach {
it.isEnabled = true
}
}
And then in onCreate() or onStart() call it as following:
disableViewGroup(idOfViewGroup)
The children method returns a Sequence of children Views in the ViewGroup which you can iterate by forEach and apply whatever operation applicable to Views.
Hope it helps!