Architecture Components / Navigation

Adventures in Navigation Land – Part 2

In the recent series on Maintainable Architecture, the final task that we covered was separating out the Navigation logic by means of the Jetpack Navigation architecture component. Those that read the article will be aware that not only did it help to solve the issues that we were looking address, but it is actually a really well structured solution which I was really impressed with. That said, my initial experimentation with the library was not all plain sailing for the most part because of some fundamental errors and misunderstandings that I had. Once I overcame those the result was really nice. In this article we’ll take a look at some of those issues and how I overcame them which may hopefully help prevent others from making the same mistakes that I did.

Previously we looked at how mistakenly setting our NavHostFragment as the startDestination caused some really weird Activity re-spawning which completely destroyed our runtime permissions handling. By changing the startDestination to instead be our CurrentWeatherFragment we managed to resolve this, but then hit another problem: The Fragment which is displayed when runtime permission has been denied, now has unwanted ‘Up’ behaviour which was added to the ActionBar automatically by the Navigation library.

The reason that we are getting the ‘Up’ behaviour appearing is that a navigation graph can only have a single startDestination which we have already defined as CurrentWeatherFragment. If a particular Fragment is defined within a navigation graph but is not the startDestination then it is still considered a part of the navigation tree, so having ‘Up’ is appropriate.

One solution would be one I mentioned in the previous article: To remove it from the navigation graph and do not use the navigation library to navigate to it. But it is also possible to override the behaviour that we get by default from the Navigation library. The fix is a single line to manually disable ‘home as up’:

class NoPermissionFragment : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
            inflater.inflate(R.layout.fragment_no_permission, container, false)

    override fun onResume() {
        super.onResume()

        (context as? AppCompatActivity)?.supportActionBar?.setDisplayHomeAsUpEnabled(false)
    }
}

So while we get some quite nice default behaviours from the Navigation library, there may be times when we need to override those behaviours and it is easy enough to do so. One word of warning though: Sometimes we need to be careful about the correct position in the Activity or Fragment lifecycle we need to override things. In Weather Station, the navigation graph will be created during layout inflation, which happens in the Activity onCreate() method. Also in this same method we perform some other initialisation:

class MainActivity : AppCompatActivity() {

    private lateinit var navController: NavController

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

        navController = Navigation.findNavController(this, R.id.nav_controller)

        setSupportActionBar(toolbar)
        setupActionBarWithNavController(this, navController)
        setupWithNavController(toolbar, navController)

        if (REQUIRED_PERMISSIONS.any { checkSelfPermission(it) == PERMISSION_DENIED }) {
            ActivityCompat.requestPermissions(this, REQUIRED_PERMISSIONS, 0)
        } else {
            navController.navigate(R.id.currentWeather)
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        if (requestCode == 0) {
            if (REQUIRED_PERMISSIONS.any { checkSelfPermission(it) == PERMISSION_DENIED }) {
                navController.navigate(R.id.noPermission)
            } else {
                navController.navigate(R.id.currentWeather)
            }
        }
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when(item.itemId) {
            R.id.to_preferences -> item.onNavDestinationSelected(navController)
            else -> super.onOptionsItemSelected(item)
        }
    }
}

As a result of performing this initialisation early the Navigation library gets everything initialised early, so overriding things later in the lifecycle is easy enough. However, if we were to initialise the navigation and UI state later on in the lifecycle then we may also need to delay any behavioural overrides until after the initialisation has been completed.

The final thing that we’ll cover is a small gotcha with SafeArgs. SafeArgs is an add-on to the Navigation library which provides a strongly-typed, type safe way of passing arguments in to a Bundle. It works by generating code to perform the Bundle creation. We get compile-time type checking as a result, which is really nice.

To use it we declare arguments within our navigation graph:

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/weather_navigation"
  app:startDestination="@id/currentWeatherFragment">

  <fragment
    android:id="@+id/currentWeatherFragment"
    android:name="com.stylingandroid.weatherstation.ui.CurrentWeatherFragment"
    android:label="@string/current_weather">
    <action
      android:id="@+id/action_currentWeatherFragment_to_dailyForecastFragment"
      app:destination="@id/dailyForecastFragment"
      app:popUpTo="@+id/currentWeatherFragment" />
  </fragment>
  <fragment
    android:id="@+id/dailyForecastFragment"
    android:name="com.stylingandroid.weatherstation.ui.DailyForecastFragment"
    android:label="@string/daily_forecast">
    <argument
      android:name="city"
      app:argType="string" />
    <argument
      android:name="forecastId"
      app:argType="long" />
    <argument
      android:name="date"
      app:argType="long" />
  </fragment>
  ...
</navigation>

This generates a class named CurrentWeatherFragmentDirections which contains a convenience method that we can call with the necessary arguments:

    private fun showDailyForecast(date: LocalDate) {
        currentFiveDayForecast?.also {
            val direction = CurrentWeatherFragmentDirections.actionCurrentWeatherFragmentToDailyForecastFragment(
                    it.city,
                    it.forecastId,
                    date.toEpochDay()
            )

            navController.navigate(direction)
        }
    }

This all works extremely well, but there was one issue that I hit. The org.threeten.bp.LocalDate class implements Serializable, so it is possible to store it within a Bundle. However, if we try and persist it as-is the generated class actually contains an error so the project no longer builds. The reason for this become apparent if we look at the generated code:

@NonNull
public Bundle getArguments() {
  Bundle __outBundle = new Bundle();
  __outBundle.putString("city", this.city);
  __outBundle.putLong("forecastId", this.forecastId);
  __outBundle.putParcelable("date", this.date);
  return __outBundle;
}

Although org.threeten.bp.LocalDate implements Serializable, the generated code tries to store it as a Parcelable, and it isn’t Parecelable so we get the error. The reason for this is that in the current version (at the time of writing this is 1.0.0-alpha04) of SafeArgs storing Serializable is not supported. Googling the issue did not help me, but Ian Lake was kind enough to point me to the issue tracker issue.

In this case it’s not such a big deal. LocalDate can easily be converted to and from an ‘Epoc Day’ which is represented as a long, and long is supported. In the above code snippets this is how it has been implemented and it results in the following generated code which works correctly:

@NonNull
public Bundle getArguments() {
  Bundle __outBundle = new Bundle();
  __outBundle.putString("city", this.city);
  __outBundle.putLong("forecastId", this.forecastId);
  __outBundle.putLong("date", this.date);
  return __outBundle;
}

There’s one further thing that you should be aware of if you’re considering using the Navigation component: shared element transitions are not supported. I have raised a bug for this but it has been marked as a duplicate even though I don’t believe that it is a duplicate because the scope of my bug report is greater than the one that it’s been marked as a duplicate of. For me this is an issue which severely limits the usefulness of this component. If I were to consider using this library I would have to have degree of certainty that shared element transition would not be required in the project I was intending to use it on. Even if it wasn’t, experience tells me that new UI designs may suddenly require shared element transitions to be used and it could prove to be more difficult to maintain a code base which was forced to use different navigation implementations in different places.

So that brings this short series to an end. The Navigation library is actually really stable (I’m lead to understand that the reason that it is still in alpha is more to do with the APIs potentially changing slightly than that there are any runtime stability issues) and my only hesitation in using it in a real world project based upon my experiences thus far is the lack of support for shared element transitions. I also hit a couple of bumps along the road but they are ones that now, hopefully, others will be able to avoid.

There is no source code that has been specifically written in support of this article, but the existing project upon which these experiences are based is available here.

© 2018, Mark Allison. All rights reserved.

Copyright © 2018 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

1 Comment

Leave a Reply to Tobias Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.