I am trying to make a RecyclerView of a list of Tasks with done and undone states and I want to create a beautiful transition between those states.
So I created an animated-selector to make the transition between the states, but the transitions is not working when I use it inside the RecyclerView, when I test it on a Button/ImageView outside the RecyclerView it works as it should.
Screen with the RecyclerView
aslv_status.xml
<?xml version="1.0" encoding="utf-8"?>
<animated-selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="#+id/done"
android:drawable="#drawable/ic_done"
android:state_selected="true" />
<item
android:id="#+id/undone"
android:drawable="#drawable/ic_undone" />
<transition
android:drawable="#drawable/avd_done_to_undone"
android:fromId="#id/done"
android:toId="#id/undone" />
<transition
android:drawable="#drawable/avd_undone_to_undone"
android:fromId="#id/undone"
android:toId="#id/done" />
</animated-selector>
I toggle the attribute isDone of the Task in the adapter item onClickListener and seted the animated-selector as the android:src of the AppCompatImageView of my recyclerView adapter item:
item_task.xml
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="#{() -> viewModel.toggleTaskState(task)}" >
...
<androidx.appcompat.widget.AppCompatImageView
android:id="#+id/imageview_status_image"
android:layout_width="35dp"
android:layout_height="35dp"
android:src="#drawable/aslv_status"
.../>
...
TaskViewModel.kt
fun toggleTaskState(task: Task) = viewModelScope.launch {
task.isDone = !task.isDone
taskRepository.updateTask(task)
}
On TaskAdapter on init I call notifyDataSetChanged() when the liveData allTasks change, and on TaskViewHolder binding I updated the AppCompatImageView selected state.
TaskAdapter.kt
private var allTasks = viewModel.allTasks
init {
viewModel.allTasks.observe(lifecycle, {
notifyDataSetChanged()
})
}
...
class TaskViewHolder(private var binding: ItemTaskBinding, private val viewModel: TaskViewModel)
: RecyclerView.ViewHolder(binding.root) {
fun bind(task: Task) {
binding.task = task
binding.viewModel = viewModel
binding.imageviewStatusImage.isSelected = task.isDone
binding.executePendingBindings()
}
}
I think the problem is the notifyDataSetChanged() called when I change the state of the Task object, because I imagine the RecyclerView updates the layout abruptly and doesn't show the transition state animation, but if I don't call the notifyDataSetChanged() the RecyclerView won't change the list items state.
Responding in case someone goes through the same situation as me.
In the notifyDataSetChanged() method documentation:
RecyclerView will attempt to synthesize visible structural change
events for adapters that report that they have {#link #hasStableIds()
stable IDs} when this method is used. This can help for the purposes
of animation and visual object persistence but individual item views
will still need to be rebound and relaid out.
So I setted hasStableIds() to my Adapter and it worked!
val taskAdapter = TaskAdapter(this, taskViewModel)
taskAdapter.setHasStableIds(true)
Related
I have a ListView and trying to use two-way data binding to set the selectedItemPosition in a ViewModel using Two-Way Attributes
But the problem is it doesn't work, the selected item doesn't set in the Value of the liveData, I tried to observe it and the value never changes when i select an item in the listView
data binding in XML:
<ListView
android:id="#+id/listView"
android:layout_width="match_parent"
android:layout_height="#dimen/_150sdp"
android:nestedScrollingEnabled="true"
tools:listheader="#tools:sample/lorem"
tools:visibility="visible"
android:choiceMode="singleChoice"
android:selectedItemPosition="#={viewModel.chosenPosition}" />
in the ViewModel:
val chosenPosition = MutableLiveData<Int>()
in the Fragment:
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
binding.teamsListView.adapter = ArrayAdapter(
context,
R.layout.list_item_choice, teamsNames
)
viewModel.chosenPosition.observe(viewLifecycleOwner) {
Timber.d("chosen position = $it") //never triggers when I select an item in the ListView
}
Problem:
android:selectedItemPosition is triggered whenever an item is selected (this doesn't implicitly include that the item is clicked/checked.
Using android:selectedItemPosition as a two-way data binding in a ListView doesn't actually automatically triggered when an item is selected, and therefore the LiveData doesn't get triggered.
You can see that when you create a normal ListView without any data binding; when you click an item, this won't trigger the selection, notice the below when an item is clicked (nothing get highlighted with a different color):
Solution:
In order to solve that for the sake of data binding, you need to explicitly do select the that item whenever it is clicked by registering OnItemClickListener to the ListView:
binding.listView.setOnItemClickListener { _, _, position, _ ->
if (position >= 0) { // avoid -1 position calls
binding.listview.requestFocusFromTouch()
binding.listview.setItemChecked(position, true)
binding.listview.setSelection(position)
}
}
This way the live data will be set to the current selected position:
Notice when an item is selected, it's now highlighted with a light grey that is because the selection is enabled:
Make sure that you have not forgetten to set your binding lifecycleOwner in your fragment
binding.lifecycleOwner = this
I have a button. When the button is clicked, the button and a textView are animated. The question is: how to get multiple views on the binding adapter? Is the way I did it correct?
<variable
name="variableTextViewDescription"
type="androidx.appcompat.widget.AppCompatTextView" />
fun bind(task: Task, viewModel: ToDoListViewModel) {
binding.task = task
binding.viewModel = viewModel
binding.variableTextViewDescription = binding.textViewDescription
binding.executePendingBindings()
}
#BindingAdapter(value = ["task", "textViewDescription"], requireAll = true)
fun ImageButton.setOnClickButtonMore(task: Task, textViewDescription: AppCompatTextView) {
if (task.isExpanded) {
toggleArrow(this, false, textViewDescription)
} else {
toggleArrow(this, true, textViewDescription)
}
this.setOnClickListener {
task.isExpanded = toggleArrow(it, task.isExpanded, textViewDescription)
}
}
<ImageButton
android:id="#+id/buttonMore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:task="#{task}"
app:textViewDescription="#{variableTextViewDescription}"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="#drawable/ic_baseline_keyboard_arrow_down_24"
tools:ignore="ContentDescription" />
I can propose a solution for you, that maybe different from adding multiple Views to the same Binding Adapter.
You can add a MutableLiveData when changed by Button click, it starts the animation.
So, we will have a single MutableLiveData added to 2 Binding Adapters (the button binding adapter and the ImageView binding adapter).
when the value of the MutableLiveData changed, both binding adapters will fire and in both adapters load your animation.
I've made a list which gets items from a Room-database using LiveData. This liveData<List> is then bound to a recyclerView, using a BindingAdapter.
The lists adapter is listAdapter, not `RecyclerView.Adapter.
I need help holding onto the scroll-state or somehow returning to the scroll-index I was at before the recyclerView reloaded:
//In ViewModel
val movieList = moviesRepository.movies
..
//in Repo
val movies: LiveData<List<Movie>> =
Transformations.map(database.movieDao.getMovies()) {
it.asDomainModel()
}
Every time the DB updates, the recyclerView shoots back up to the top.
And here's the bindingAdapter for the RecyclerView and the list.
#BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<Movie>?) {
val adapter = recyclerView.adapter as MovieListAdapter
//Log.d("listData binding", "${data}")
adapter.submitList(data)
}
I think I need to use something like recyclerView.smoothScrollToPosition(la.getItemCount());
after the update has occured, but I don't know how to automatically call it when the update has occured
Project Repo
First of all don't use ListAdapter RecyclerView has a more optimized adapter here
in your adapter provide a function that overrides the item list and there is where you notify the data change
Use smoothScrollToPosition(lastVisiblePosition) to scroll to the last visible position where lastVisiblePosition = layoutManager.findFirstVisibleItemPosition()
Update lastVisiblePosition before you push new items to the adapter notifyDatasetChanged()
Step 2
fun updateList(newItems:List<Movie>) {
moviesList.addAll(newItems)
lastVisiblePosition = layoutManager.findFirstVisibleItemPosition()
notifyDataSetChanged()
}
from your view when you call adapter.updateList(newItems) just call recyclerView.smoothScrollToPosition(adapter.lastVisiblePosition)
figured it out.
All of the examples I could find were using Activities, not fragments. So all I had to do was pop this into the onCreateView() function in the relevant fragment.kt file.
binding.movieList.layoutManager =
object : LinearLayoutManager(getActivity(), VERTICAL, false) {
override fun onLayoutCompleted(state: RecyclerView.State) {
super.onLayoutCompleted(state)
val lastVisibleItemPosition = findLastVisibleItemPosition()
val count = (binding.movieList.adapter as MovieListAdapter).itemCount
//speed the scroll up a bit, but make it look smooth at the end
binding.movieList.scrollToPosition(count - 5)
binding.movieList.smoothScrollToPosition(count)
}
}
here, the binding.movieList is referring to this xml element
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/movie_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="#+id/load_more_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:listData="#{viewModel.movieList}"
tools:listitem="#layout/movie_list_item" />
I am trying to use last features from android - Kotlin, mvvm, architecture components, jetpack, databinding, one activity - many fragments approach with new navigation graph, but I am struggling with handling UI events in Fragments
In activity it is simple with kotlin-android-extensions
In XML I create a Button like this:
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="clicked"/>
and in Activity I just write
fun clicked(view : View){
}
That's perfect, but unfortunately does not work in Fragment. Yes it is possible to still handle event in Activity and send it to fragment but that's ugly.
Next option is to use an interface,
public interface MyClickCallback{
void onLoginButtonClick();
}
implement it in fragment.
In xml it looks like this:
<variable
name="clickCallback"
type="com.test.MyClickCallback" />
then in fragment's onCreateView I have to set clickCallback to the fragment and finally I can use it
#Override fun onLoginButtonClick() {
}
Problem I have with this is to declare interface and on each new UI event enhance this interface and update fragment which implements it
Next option is RxView.clicks what looks really great with all its features. For example:
RxView.clicks(mSearchBtn)
.throttleFirst(2, TimeUnit.SECONDS)
.map(aVoid -> mSearchEdit.getText().toString().trim())
.filter(s -> !TextUtils.isEmpty(s))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(s -> {
KeyBoardUtil.closeKeybord(mSearchEdit,
SearchActivity.this);
showSearchAnim();
clearData();
content = s;
getSearchData();
});
Problem here is that I have to bind it to the UI component - mSearchBtn. I do not want this :-). I do not want to have any UI component in fragment unless I really have to. I am always communicating with layout file via variables declared in layout like this
<data>
<variable
name="items"
type="java.util.List" />
</data>
I would love to bind it to variable declared in the XML which is set in Button
android:onClick="myclick"
But I did not find the way how to do it.
Anybody can help me maybe with other simple and nice options ?
In your databinding layout create a variable that is of type View.OnClickListener:
<variable
name="onClickListener"
type="android.view.View.OnClickListener" />
Set it to your View like this:
<View
...
android:onClickListener="#{onClickListener}"
... />
In your Fragment create the onClickListener and set it to the variable:
binding.onClickListener = View.OnClickListener {
/* do things */
/* like getting the id of the clicked view: */
val idOfTheClickedView = it.id
/* or get variables from your databinding layout: */
val bankAccount = binding.bankAccount
}
Or in Java:
binding.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View view) {
/* do things */
/* like getting the id of the clicked view: */
Int idOfTheClickedView = view.getId();
/* or get variables from your databinding layout: */
Object bankAccount = binding.getBankAccount()
}
});
it is simple with kotlin-android-extensions
It is indeed simple, but you are currently not using it to its fullest potential.
Setting click listeners in Kotlin is very easy, look:
fun View.onClick(clickListener: (View) -> Unit) {
setOnClickListener(clickListener)
}
And now thanks to synthetic imports in Kotlin-Android-Extensions:
<Button
android:id="#+id/myButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="#string/click_me"/>
and
import kotlinx.synthetic.blah.* // something like that
// Activity:
override fun onCreate(bundle: Bundle?) {
super.onCreate(bundle)
setContentView(R.layout.blah)
myButton.onClick {
// handle click event
}
}
// Fragment:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, bundle: Bundle?) = inflater.inflate(R.layout.blah, container, false)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
myButton.onClick {
// handle click event
}
}
But if you really want to use databinding and layouts for this, then set the callback lambda and inside the databinding layout file.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="activity" type="com.acme.MainActivity"/>
</data>
<RelativeLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="#+id/btnOpenSecondView"
android:text="Click me for second view!"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:onClick="#{(v) -> activity.startNextActivity(v)}" />
</RelativeLayout>
</layout>
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()
}