Search code examples
androidandroidxandroid-architecture-navigationandroid-jetpack-navigationandroid-bottomnavigationview

Androidx BottomNavigationView does not play transition animations accordingly


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
    }
}

Solution

  • 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.