Harin
Harin

Reputation: 2423

Navigation Architecture Component - how to set/change custom back or hamburger icon with navigation controller?

I am trying to implement the newly introduced Navigation Architecture Component provided with Jetpack. as far it's very cool and useful for managing navigation flow of your app.

I have already setup the basic navigation including drawer layout with the toolbar in MainActivity like this:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val navController = Navigation.findNavController(this, R.id.mainNavFragment)

        // Set up ActionBar
        setSupportActionBar(toolbar)
        NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout)

        // Set up navigation menu
        navigationView.setupWithNavController(navController)
    }

    override fun onSupportNavigateUp(): Boolean {
        return NavigationUI.navigateUp(Navigation.findNavController(this, R.id.mainNavFragment), drawerLayout)
    }
}

With this layout:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:popupTheme="@style/AppTheme.PopupOverlay"
            app:navigationIcon="@drawable/ic_home_black_24dp"/>

    </android.support.design.widget.AppBarLayout>

    <fragment
        android:id="@+id/mainNavFragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_main"/>

</LinearLayout>

It works fins. But, the real question is when provided custom design for an app,

how can I set custom icon for hamburger or the back icon?

which is as of now, being handled by NavigatoinController itself.

enter image description here

I already tried options below, but it doesn't work:

app:navigationIcon="@drawable/ic_home_black_24dp" //1
supportActionBar.setHomeAsUpIndicator(R.drawable.ic_android_black_24dp) //2

Thanks!

Upvotes: 10

Views: 10926

Answers (8)

Andrew
Andrew

Reputation: 4712

I don't know since when this is possible but I just found out, that you can call toolbar.setNavigationIcon(R.drawable.your_icon) after calling toolbar.setupWithNavController(findNavController()). This will remove the default DrawerArrowDrawable and instantly override it with your custom drawable. There is no need to rewrite any sdk code or do some crazy other stuff

Code with default findViewById

fun Fragment.initToolbarWithCustomDrawable(@DrawableRes resId: Int) {
    val toolbar = requireView().findViewById<MaterialToolbar>(R.id.your_toolbar)
    with(toolbar) {
        setupWithNavController(findNavController(), AppBarConfiguration(findNavController().graph))
        setNavigationIcon(resId)
    }  
}

Code without findViewById

fun Fragment.initToolbarWithCustomDrawable(
     toolbar: Toolbar
     @DrawableRes resId: Int
) {
   with(toolbar) {
        setupWithNavController(findNavController(), AppBarConfiguration(findNavController().graph))
        setNavigationIcon(resId)
   }
}

Upvotes: -1

NhatVM
NhatVM

Reputation: 2124

Actually, I don't know this is a feature or a bug of Google.

Before giving my solution to change a custom back icon. Let see how Google implemented.

Everything starts when we call :

val navController = findNavController(R.id.nav_host_fragment)
        appBarConfiguration = AppBarConfiguration(
            setOf(
                R.id.homeFragment,
                R.id.categoryFragment,
                R.id.cartFragment,
                R.id.myAccountFragment,
                R.id.moreFragment
            ),
        )
 toolbar.setupWithNavController(navController, appBarConfiguration)

setupWithNavController() is an extension of Toolbar.

fun Toolbar.setupWithNavController(
    navController: NavController,
    configuration: AppBarConfiguration = AppBarConfiguration(navController.graph)
) {
    NavigationUI.setupWithNavController(this, navController, configuration)
}

And in the NavigationUI, you can see that Google just listen the change of destination and set a click event when the user click on back button.

public static void setupWithNavController(@NonNull Toolbar toolbar,
            @NonNull final NavController navController,
            @NonNull final AppBarConfiguration configuration) {
        navController.addOnDestinationChangedListener(
                new ToolbarOnDestinationChangedListener(toolbar, configuration));
        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                navigateUp(navController, configuration);
            }
        });
    }

And go more detail, you can see this function:

private void setActionBarUpIndicator(boolean showAsDrawerIndicator) {
        boolean animate = true;
        if (mArrowDrawable == null) {
            mArrowDrawable = new DrawerArrowDrawable(mContext);
            // We're setting the initial state, so skip the animation
            animate = false;
        }
        setNavigationIcon(mArrowDrawable, showAsDrawerIndicator
                ? R.string.nav_app_bar_open_drawer_description
                : R.string.nav_app_bar_navigate_up_description);
        float endValue = showAsDrawerIndicator ? 0f : 1f;
        if (animate) {
            float startValue = mArrowDrawable.getProgress();
            if (mAnimator != null) {
                mAnimator.cancel();
            }
            mAnimator = ObjectAnimator.ofFloat(mArrowDrawable, "progress",
                    startValue, endValue);
            mAnimator.start();
        } else {
            mArrowDrawable.setProgress(endValue);
        }
    }

and here is setNavigationIcon:

@Override
    protected void setNavigationIcon(Drawable icon,
            @StringRes int contentDescription) {
        Toolbar toolbar = mToolbarWeakReference.get();
        if (toolbar != null) {
            boolean useTransition = icon == null && toolbar.getNavigationIcon() != null;
            toolbar.setNavigationIcon(icon);
            toolbar.setNavigationContentDescription(contentDescription);
            if (useTransition) {
                TransitionManager.beginDelayedTransition(toolbar);
            }
        }
    }

I just saw that every time a destination change, and the destination is not a root page. Google creates a DrawerArrowDrawable object and then Google set this icon for navigation Icon. I think it is reason why Toolbar doesn't show our custom icon.

At this point, I think we have some solutions to deal with it. One simple solution is add addOnDestinationChangedListener() method in onCreate() method of Activity and whenever the user changes to new page (destination) where we will check and decide whether we will show the custom back icon or not. Like this:

val navController = findNavController(R.id.nav_host_fragment)
        navController.addOnDestinationChangedListener { _, destination, _ ->
          (appBarConfiguration.topLevelDestinations.contains(destination.id)) {
                    toolbar.navigationIcon = null

                } else {
                    toolbar.setNavigationIcon(R.drawable.ic_arrow_back_white)

                }
        }

Upvotes: 2

Igor
Igor

Reputation: 51

I faced the same problem. I just rewrote some class from the SDK:

class ToolbarOnDestinationChangedListener(toolbar: Toolbar) : OnDestinationChangedListener {
    private val toolbarWeakReference = WeakReference(toolbar)

    override fun onDestinationChanged(
        controller: NavController,
        destination: NavDestination,
        arguments: Bundle?
    ) {
        if (toolbarWeakReference.get() == null) {
            controller.removeOnDestinationChangedListener(this)
            return
        }

        if (destination is FloatingWindow) {
            return
        }

        destination.label?.let {
            val title = pickTitleFromArgs(it, arguments)
            setTitle(title)
        }
    }

    private fun pickTitleFromArgs(label: CharSequence, arguments: Bundle?): StringBuffer {
        val title = StringBuffer()
        val matcher = Pattern.compile("\\{(.+?)\\}").matcher(label)
        while (matcher.find()) {
            val argName = matcher.group(1)
            if (arguments != null && arguments.containsKey(argName)) {
                matcher.appendReplacement(title, "")
                title.append(arguments[argName].toString())
            } else {
                error("Could not find $argName in $arguments to fill label $label")
            }
        }
        matcher.appendTail(title)
        return title
    }

    private fun setTitle(title: CharSequence) {
        toolbarWeakReference.get()?.let {
            it.title = title
        }
    }
}

And following extension:

fun Toolbar.setNavController(navController: NavController) {
    navController.addOnDestinationChangedListener(ToolbarOnDestinationChangedListener(this))
    toolbar.setNavigationOnClickListener { navController.navigateUp() }
}

Use:

//Fragment
toolbar.setNavController(findNavController())

This code does not control icons. But it considers label, args, destination changes. You should set icons with XML.

Upvotes: 1

because_im_batman
because_im_batman

Reputation: 1103

Since the style AppTheme uses the style Widget.AppCompat.DrawerArrowToggle for the navigationUI hamburger and back button icons, You need to create your custom style by inheriting from the Widget.AppCompat.DrawerArrowToggle.

I'll try to exhaust all the possible options you have and what each one of them does:

<style name="CustomNavigationIcons" parent="Widget.AppCompat.DrawerArrowToggle">
        <!-- The drawing color for the bars -->
        <item name="color">@color/colorGrey</item>
        <!-- The total size of the drawable -->
        <item name="drawableSize">64dp</item>

        <!-- The thickness (stroke size) for the bar paint -->
        <item name="thickness">3dp</item>
        <!-- Whether bars should rotate or not during transition -->
        <item name="spinBars">true</item>
        <!-- The max gap between the bars when they are parallel to each other -->
        <item name="gapBetweenBars">4dp</item>
        <!-- The length of the bars when they are parallel to each other -->
        <item name="barLength">27dp</item>

        <!-- The length of the shaft when formed to make an arrow -->
        <item name="arrowShaftLength">23dp</item>
        <!-- The length of the arrow head when formed to make an arrow -->
        <item name="arrowHeadLength">7dp</item>
</style>

Finally, you have to make sure AppTheme now uses your custom style as the DrawerArrowToggle, like so:

<style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <!-- Other styles -->
        <item name="drawerArrowStyle">@style/CustomNavigationIcons</item>

</style>

Upvotes: 1

bdemuth
bdemuth

Reputation: 71

Navigation Component uses DrawerArrowDrawable as the underlying implementation, which actually animates between hamburger icon and back arrow icon. The appearance of both icons can be modified to a certain degree via styles, see

How to style the DrawerArrowToggle from Android appcompat v7 21 library

For our app, we used the following attributes to obtain a long thin arrow:

    <style name="ToolbarArrow" parent="Widget.AppCompat.DrawerArrowToggle">
        <item name="color">@color/primary</item>
        <item name="drawableSize">32dp</item>
        <item name="arrowShaftLength">22dp</item>
        <item name="thickness">1.7dp</item>
        <item name="arrowHeadLength">8dp</item>
    </style>

This style must be set in your app's theme, like this:

    <item name="drawerArrowStyle">@style/ToolbarArrow</item>

Upvotes: 7

zoha131
zoha131

Reputation: 1888

I have faced the same issue recently. I wanted to use the default setup with NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout) and also didn't want to write the logic in every fragment. And that's the use case of having a navigation component in a project. After digging the source code of the navigation component I came up with the following solution:

findNavController(R.id.nav_host_fragment)
        .addOnDestinationChangedListener { _, destination, _ ->
            when (destination.id) {

                R.id.nav_home,
                R.id.nav_gallery -> {
                    drawerToggleDelegate!!
                        .setActionBarUpIndicator(
                            ContextCompat.getDrawable(
                                this,
                                R.drawable.custom_hamburger
                            ),
                            androidx.navigation.ui.R.string.nav_app_bar_open_drawer_description
                        )
                }

                else -> {
                    drawerToggleDelegate!!
                        .setActionBarUpIndicator(
                            ContextCompat.getDrawable(
                                this,
                                R.drawable.custom_back
                            ),
                            androidx.navigation.ui.R.string.nav_app_bar_navigate_up_description
                        )
                }
            }

        }

I have added DestinationChangedListener to the navController and used drawerToggleDelegate property of the AppCompatActivity to change the icon. Here, R.id.nav_home & R.id.nav_gallery are my top-level destinations; for which the custom hamburger will be shown and for others, the custom back icon will be shown.
The only drawback of this approach is that you will lose the default animation.

But you can implement your logic here pretty easily to get the animation effect.

Upvotes: 4

James Porter
James Porter

Reputation: 183

I had the same issue with navigation version 1.0.0-alpha08. I have solved it by re-implementing setupActionBarWithNavController to provide the custom behaviour that I needed.

setDisplayShowTitleEnabled(false) //Disable the default title

container.findNavController().addOnDestinationChangedListener { _, destination: NavDestination, _ ->
    //Set the toolbar text (I've placed a TextView within the AppBar, which is being referenced here)
    toolbar_text.text = destination.label

    //Set home icon
    supportActionBar?.apply {
        setDisplayHomeAsUpEnabled((destination.id in NO_HOME_ICON_FRAGMENTS).not())
        setHomeAsUpIndicator(if (destination.id in TOP_LEVEL_FRAGMENTS) R.drawable.app_close_ic else 0)
    }
}

All you need to do is add an onDestinationChangedListener to your nav controller, and set the title and icon for each destination.

This code should be placed within the onCreate() method within the activity that contains your nav graph, and NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout) should be removed from your code.

Note this will remove the default functionality for a navigation drawer.

I hope this helps!

Upvotes: 14

Drew Hamilton
Drew Hamilton

Reputation: 56

I'm having the same issue using navigation version 1.0.0-alpha07. I've found the following workaround:

In my activity, I set the toolbar view both as the support action bar and with the nav controller:

val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
toolbar.setupWithNavController(navController, appBarConfiguration)

Then in onViewCreated of my fragment where I want to replace the arrow icon, I set the icon and set up as enabled (it's probably possible to do this in onAttached):

(requireActivity() as AppCompatActivity).supportActionBar?.apply {
  setHomeAsUpIndicator(R.drawable.ic_close_24dp)
  setDisplayHomeAsUpEnabled(true)
}

The navigation component still seems to correctly handle the backstack, animations, and removing the icon on the start fragment when I do this. Hopefully before leaving alpha the navigation component supports custom up icons without having to resort to the old-style action bar API.

Upvotes: 2

Related Questions