I set up a bottom navigation view with the navigation component. The user navigation between fragments works fine.
The issue is that navigation through the bottom navigation view does not play the animations configured at the navigation component, i.e., while touching the card correctly animates with a slide style, clicking the buttons at bottom navigation view animates with a fade style, overriding the action properties defined in navigation component.
res/menu/bottom_navigation.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/home_fragment"
android:title="@string/bottom_navigation_home_title"
android:icon="@drawable/ic_home"
app:showAsAction="ifRoom" />
<item android:id="@+id/schedule_fragment"
android:title="@string/bottom_navigation_schedule_title"
android:icon="@drawable/ic_schedule"
app:showAsAction="ifRoom" />
</menu>
res/anim/slide_in_left.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="-100%"
android:toXDelta="0%"
android:fromYDelta="0%"
android:toYDelta="0%"
android:duration="700" />
</set>
res/anim/slide_in_right.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="100%"
android:toXDelta="0%"
android:fromYDelta="0%"
android:toYDelta="0%"
android:duration="700" />
</set>
res/anim/slide_out_left.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0%"
android:toXDelta="-100%"
android:fromYDelta="0%"
android:toYDelta="0%"
android:duration="700" />
</set>
res/anim/slide_out_right.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0%"
android:toXDelta="100%"
android:fromYDelta="0%"
android:toYDelta="0%"
android:duration="700" />
</set>
res/navigation/nav_graph.xml
<?xml version="1.0" encoding="utf-8"?>
<navigation
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/nav_graph"
app:startDestination="@id/home_fragment">
<fragment
android:id="@+id/home_fragment"
android:name="com.sslabs.whatsappcleaner.ui.HomeFragment"
android:label="home_fragment"
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_home_fragment_to_schedule_fragment"
app:destination="@id/schedule_fragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:popUpTo="@id/home_fragment" />
</fragment>
<fragment
android:id="@+id/schedule_fragment"
android:name="com.sslabs.whatsappcleaner.ui.ScheduleFragment"
android:label="schedule_fragment"
tools:layout="@layout/fragment_schedule">
</fragment>
</navigation>
res/layout/fragment_home.xml
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<com.google.android.material.card.MaterialCardView
android:id="@+id/home_schedule_card"
android:layout_width="344dp"
android:layout_height="148dp"
app:cardBackgroundColor="@android:color/holo_blue_dark"
app:rippleColor="@android:color/holo_orange_dark" />
</layout>
res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
<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.MainActivity">
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_height="0dp"
android:layout_width="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"/>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:menu="@menu/bottom_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
com.sslabs.whatsappcleaner.ui.MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val navController: NavController = Navigation.findNavController(this, R.id.nav_host_fragment)
NavigationUI.setupWithNavController(binding.bottomNavigation, navController)
}
}
com.sslabs.whatsappcleaner.ui.HomeFragment
class HomeFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding: FragmentHomeBinding = DataBindingUtil.inflate(
inflater, R.layout.fragment_home, container, false)
binding.homeScheduleCard.setOnClickListener {
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToScheduleFragment())
}
return binding.root
}
}
Solution
After trying the first attempt, the default animations still play, instead of the ones specified in the action. (fade-in/fade-out)
Apparently, action_id
is just used for the destination
, not the anims
.
Since the default animation was still playing, I opened the code for NavigationUI.java
. Which is the following:
public static boolean onNavDestinationSelected(@NonNull MenuItem item,
@NonNull NavController navController) {
NavOptions.Builder builder = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim);
if ((item.getOrder() & Menu.CATEGORY_SECONDARY) == 0) {
builder.setPopUpTo(findStartDestination(navController.getGraph()).getId(), false);
}
NavOptions options = builder.build();
try {
//TODO provide proper API instead of using Exceptions as Control-Flow.
navController.navigate(item.getItemId(), null, options);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
As you can tell, in the NavOptions.Builder
, the default anims
are being set.
The workaround you used was not satisfactory for me. So, I took the liberty in creating a BottomNavigationUI
class that would do the function of the NavigationUI
, but using custom anims
when available.
The difference is in the
onNavDestinationSelected
. Please note thatNavigationUI
is final, so I couldn't override it.
BottomNavigationUI.class
// don't forget your package
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.navigation.NavAction;
import androidx.navigation.NavController;
import androidx.navigation.NavDestination;
import androidx.navigation.NavGraph;
import androidx.navigation.NavOptions;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import java.lang.ref.WeakReference;
import java.util.Set;
public class BottomNavigationUI {
private BottomNavigationUI() {
}
public static boolean onNavDestinationSelected(@NonNull MenuItem item,
@NonNull NavController navController) {
int resId = item.getItemId();
Bundle args = null;
NavOptions options;
NavOptions.Builder optionsBuilder = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim);
if ((item.getOrder() & Menu.CATEGORY_SECONDARY) == 0) {
optionsBuilder.setPopUpTo(findStartDestination(navController.getGraph()).getId(), false);
}
final NavAction navAction = navController.getCurrentDestination().getAction(resId);
if (navAction != null) {
NavOptions navOptions = navAction.getNavOptions();
// Note : You can Add *setLaunchSingleTop* and *setPopUpTo* from *navOptions* to *builder*
if (navOptions.getEnterAnim() != -1) {
optionsBuilder.setEnterAnim(navOptions.getEnterAnim());
}
if (navOptions.getExitAnim() != -1) {
optionsBuilder.setExitAnim(navOptions.getExitAnim());
}
if (navOptions.getPopEnterAnim() != -1) {
optionsBuilder.setPopEnterAnim(navOptions.getPopEnterAnim());
}
if (navOptions.getPopExitAnim() != -1) {
optionsBuilder.setPopExitAnim(navOptions.getPopExitAnim());
}
Bundle navActionArgs = navAction.getDefaultArguments();
if (navActionArgs != null) {
args = new Bundle();
args.putAll(navActionArgs);
}
}
options = optionsBuilder.build();
try {
//TODO provide proper API instead of using Exceptions as Control-Flow.
navController.navigate(resId, args, options);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
public static void setupWithNavController(
@NonNull final BottomNavigationView bottomNavigationView,
@NonNull final NavController navController) {
bottomNavigationView.setOnNavigationItemSelectedListener(
new BottomNavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
return onNavDestinationSelected(item, navController);
}
});
final WeakReference<BottomNavigationView> weakReference =
new WeakReference<>(bottomNavigationView);
navController.addOnDestinationChangedListener(
new NavController.OnDestinationChangedListener() {
@Override
public void onDestinationChanged(@NonNull NavController controller,
@NonNull NavDestination destination, @Nullable Bundle arguments) {
BottomNavigationView view = weakReference.get();
if (view == null) {
navController.removeOnDestinationChangedListener(this);
return;
}
Menu menu = view.getMenu();
for (int h = 0, size = menu.size(); h < size; h++) {
MenuItem item = menu.getItem(h);
if (matchDestination(destination, item.getItemId())) {
item.setChecked(true);
}
}
}
});
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
static boolean matchDestination(@NonNull NavDestination destination,
@IdRes int destId) {
NavDestination currentDestination = destination;
while (currentDestination.getId() != destId && currentDestination.getParent() != null) {
currentDestination = currentDestination.getParent();
}
return currentDestination.getId() == destId;
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
static boolean matchDestinations(@NonNull NavDestination destination,
@NonNull Set<Integer> destinationIds) {
NavDestination currentDestination = destination;
do {
if (destinationIds.contains(currentDestination.getId())) {
return true;
}
currentDestination = currentDestination.getParent();
} while (currentDestination != null);
return false;
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
static NavDestination findStartDestination(@NonNull NavGraph graph) {
NavDestination startDestination = graph;
while (startDestination instanceof NavGraph) {
NavGraph parent = (NavGraph) startDestination;
startDestination = parent.findNode(parent.getStartDestination());
}
return startDestination;
}
}
Now, with that, you need to make some changes.
MainActivity : After
BottomNavigationUI.setupWithNavController(bottomNavigationView, navController)
bottomNavigationView.setOnNavigationItemReselectedListener { false }
We will be using
BottomNavigationUI
instead ofNavigationUI
since it will be able to use the customanims
instead of just the default ones.
nav_graph.xml
<?xml version="1.0" encoding="utf-8"?>
<navigation
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/nav_graph"
app:startDestination="@id/home_fragment">
<fragment
android:id="@+id/home_fragment"
android:name="com.example.android.navbottomsample.HomeFragment"
android:label="HomeFragment"
tools:layout="@layout/fragment_home">
<action
app:launchSingleTop="true"
android:id="@+id/schedule_fragment"
app:destination="@id/schedule_fragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:popUpTo="@+id/home_fragment" />
</fragment>
<fragment
android:id="@+id/schedule_fragment"
android:name="com.example.android.navbottomsample.ScheduleFragment"
android:label="ScheduleFragment"
tools:layout="@layout/fragment_schedule">
<action
android:id="@+id/home_fragment"
app:destination="@id/home_fragment"
app:enterAnim="@anim/slide_in_left"
app:exitAnim="@anim/slide_out_right"
app:popEnterAnim="@anim/slide_in_right"
app:popExitAnim="@anim/slide_out_left"
app:popUpTo="@+id/home_fragment" />
</fragment>
</navigation>
So you don't have to compare the code, the
action_id
is flipped now. You also have 2 actions, since we will be needing itsaction_id
. Now the whole point of theaction_id
swap is that we need theaction_id
to match thedestination_id
, also thefragment_id
andmenu_item_id
.I also changed the
app:popUpTo
. This will be most reasonable when you try it out yourself. You want both fragments topopUpTo
thehome_fragment
in thebackStack
. So, once you are athome_fragment
, you don't want to go any further back. And once you are atschedule_fragment
, you want to go back tohome_fragment
. However, I suggest you useBottomNavigationUI
orNavigationUI
as they dynamically specify thepopUpTo
(this will be most useful when you have more than 2 bottom navigation tabs).
I have tried this solution on the project that you shared with me, and it works flawlessly. Enjoy :)
Other Solution
You can set a custom setOnNavigationItemSelectedListener
in order to perform what you need (use custom animations as specified in the xml).
Now, in your code (the one that you shared with me) you have already created a workaround, which is the following:
bottomNavigationView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.schedule_fragment -> navController.navigate(R.id.home_fragment)
else -> navController.popBackStack()
}
true
}
You can choose to keep that, or do the following (with the updated nav_graph.xml
):
bottomNavigationView.setOnNavigationItemSelectedListener {
navController.navigate(it.itemId)
true
}
But, you should not forget to change the
nav_graph.xml
to the new one.
First Attempt
Alright, so if you check the NavigationUI AndroidDocs, you will notice the following:
setupWithNavController
Sets up a
BottomNavigationView
for use with aNavController
. This will callonNavDestinationSelected(MenuItem, NavController)
when a menu item is selected. The selected item in theBottomNavigationView
will automatically be updated when the destination changes.
onNavDestinationSelected
Attempt to navigate to the
NavDestination
associated with the givenMenuItem
. ThisMenuItem
should have been added via one of the helper methods in this class. Importantly, it assumes themenu item id
matches a validaction id
ordestination id
to be navigated to.
The first attempt did not solve the issue. The default animations still play, instead of the ones specified in the action.