Androidx BottomNavigationView 不会相应地播放过渡动画

Androidx BottomNavigationView does not play transition animations accordingly

我使用导航组件设置了底部导航视图。片段之间的用户导航工作正常。

问题是通过底部导航视图导航不会播放在导航组件中配置的动画,即当正确触摸卡片时以滑动样式动画,单击底部导航视图中的按钮以淡入淡出动画样式,覆盖导航组件中定义的操作属性。

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

解决方案

After trying the first attempt, the default animations still play, instead of the ones specified in the action. (fade-in/fade-out)

显然,action_id 仅用于 destination,而不是 anims

由于默认动画还在播放,我打开了NavigationUI.java的代码。以下是:

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

如您所知,在 NavOptions.Builder 中,正在设置默认值 anims

您使用的解决方法对我来说并不令人满意。因此,我冒昧地创建了一个 BottomNavigationUI class 来执行 NavigationUI 的功能,但在可用时使用自定义 anims

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

现在,你需要做一些改变

MainActivity

之后
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).

我已经在您与我分享的项目中尝试了这个解决方案,它完美。享受 :)


其他解决方案

您可以设置自定义 setOnNavigationItemSelectedListener 以执行您需要的操作 (使用 xml 中指定的自定义动画).

现在,在您的代码 (您与我分享的代码) 中,您已经创建了一个解决方法,如下所示:

bottomNavigationView.setOnNavigationItemSelectedListener {
    when (it.itemId) {
        R.id.schedule_fragment -> navController.navigate(R.id.home_fragment)
        else -> navController.popBackStack()
    }
    true
}

您可以选择保留它,或者执行以下操作(使用更新后的 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.


第一次尝试

好的,如果您查看 NavigationUI AndroidDocs,您会注意到以下内容:

  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.