2

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.

inconsistent animations

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
    }
}
Plinio.Santos
  • 1,709
  • 24
  • 31

1 Answers1

3

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 that NavigationUI 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 of NavigationUI since it will be able to use the custom anims 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 its action_id. Now the whole point of the action_id swap is that we need the action_id to match the destination_id, also the fragment_id and menu_item_id.

I also changed the app:popUpTo. This will be most reasonable when you try it out yourself. You want both fragments to popUpTo the home_fragment in the backStack. So, once you are at home_fragment, you don't want to go any further back. And once you are at schedule_fragment, you want to go back to home_fragment. However, I suggest you use BottomNavigationUI or NavigationUI as they dynamically specify the popUpTo (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:

  1. setupWithNavController

    Sets up a BottomNavigationView for use with a NavController. This will call onNavDestinationSelected(MenuItem, NavController) when a menu item is selected. The selected item in the BottomNavigationView will automatically be updated when the destination changes.

  2. onNavDestinationSelected

    Attempt to navigate to the NavDestination associated with the given MenuItem. This MenuItem should have been added via one of the helper methods in this class. Importantly, it assumes the menu item id matches a valid action id or destination 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.

Nizar
  • 2,034
  • 1
  • 20
  • 24
  • Nizar, very tank you for taking a time to try to solve this. I already had read the docs from where I understand that is suficient the menu item id to match the action destination - so much so that the navigation works. Unfortunatelly you suggestion not worked here and the fade animation keep overriding the slide anim. If you have a time, I can share with you a project to dag into. I already have a programmatically solution, but it should work from xml also. – Plinio.Santos Jan 21 '20 at 12:33
  • Yes, if you can, please share with me the project, I am a bit eager to make it work – Nizar Jan 21 '20 at 13:04
  • Thank you again. In my opinion, using BottomNavigationUI is a bad idea since we are getting ride of androidx further implementations - yet it is a solution :) I've filled an [issue at google bug tracker](https://issuetracker.google.com/issues/148020931) and it already has been assigned but with lowest priority. Let's hope they fix the amination override. – Plinio.Santos Jan 22 '20 at 11:22
  • No, you won't be getting rid of `androidx` further implementations. I implemented it exactly like google did. If we can start a discussion chat, I can explain why – Nizar Jan 22 '20 at 11:23
  • [Let us continue this discussion in chat.](https://chat.stackoverflow.com/rooms/206440/room-for-nizar-and-plinio-santos) – Nizar Jan 22 '20 at 11:26
  • 1
    So, the issue I repported was marked as duplicated of [138665563](https://issuetracker.google.com/issues/138665563), in here was said that NavigationUI should not allow override the transition animations. Personally, I think that once navigation view is setup to use the navigation component, the transition views animation should behave as described by nav component, but Material Team thinks different – Plinio.Santos Jan 31 '20 at 11:00
  • I agree with you @Plinio.Santos, Once you specify in the XML the animations for the Action, it should take the animations from the action. Otherwise use the default ones. Look, **you can make the BottomNavigationUI auto generated on Build**. That way it remains updated with the latest NavigationUI. – Nizar Jan 31 '20 at 11:12