Skip to content

Thinking in Magellan

Ryan Moelter edited this page Dec 2, 2021 · 13 revisions

Welcome to Magellan! We're working hard on an update; this is the work-in-progress documentation for that update. For the old documentation, see our old wiki page, Magellan 1.x Home. If you have questions/comments/suggestions for the new documentation, please submit an issue and we'll explain ourselves better.

Preface

This page assumes no knowledge of Magellan, but should still be helpful for those familiar with the first version of the library. We also have a Migrating from Magellan 1.x page that focuses on the differences between the first and second versions.

Why would I use Magellan?

Magellan is a simple, flexible, and practical navigation framework.

  • Simple: Intuitive abstractions and encapsulation make it easy to reason through code.
  • Flexible: Magellan's infinitely-nestable structure allows for many different styles of structuring an app and navigating between pages.
  • Practical: We pay special attention to simplifying common patterns and removing day-to-day boilerplate.
  • Testable: Plain objects that are easy to instantiate make testing simple.

The first Step

The best place to start exploring Magellan is with the Step component. Steps are the most basic building block of Magellan navigation. Each Step encapsulates some business logic and its associated UI.

Hello world!

Suppose that we are building a virtual travel guide for famous landmarks. Let's first define a simple welcome screen:

MyFirstStep.kt

class HomeStep : Step<HomeStepBinding>(HomeStepBinding::inflate)

home_step.xml

<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:text="Hello world!"
    />

You’ll notice a few things here right off the bat:

  • A static screen needs no logic and requires very little boilerplate.
  • Steps use ViewBinding to attach their XML views. The Step is parameterized with its associated binding class, which allows this binding to be propagated into lifecycle methods. We'll explore this in the next section. (Jetpack Compose support is coming soon)

Component lifecycle and adding logic

Step's lifecycle is similar to fragments or activities, but simpler.

State Enter event Exit event Description
Destroyed N/A N/A Happens directly after init or after being destroyed.
Created create() destroy() This object is created, but is not currently displayed. At this point, but no views exist and no Activity context is held. This is a good time to perform dependency injection.
Shown show() hide() This object is currently displayed (i.e. if it has an associated view, it exists and is being shown to the user). Analogous to Activity.onStart()/Activity.onStop().
Resumed resume() pause() This object is currently in focus (i.e. if it has an associated view, it is receiving touch events from the user).

Add logic to a screen by overriding the appropriate method, just like you would in an Activity, Fragment, or lifecycle observer.

class AustraliaStep : Step<AustraliaStepBinding>(AustraliaStepBinding::inflate) {
  override fun onShow(context: Context, binding: AustraliaStepBinding) {
    apiClient.fetchDestinationsForContinent(AUSTRALIA) { destinations ->
      binding.destinationList.setDestinations(destinations)
    }
  }
}

A Journey has multiple Steps

To show multiple Steps sequentially, i.e. navigate around the app, Magellan provides the Journeys component.

Under the hood, Journeys are Steps with their own Navigator attached. They have the same lifecycle as a Step, and the same ability to display UI to represent state. For example, a Journey for user signup could display a progress bar to show how many Steps are left in the process.

Steps and Journeys are provided as minimal implementations of the Navigable interface. To learn more about the class structure and implementation details of Magellan, you can skip to Library architecture and design.

Navigation is simple and safe

To navigate to the next Step in a Journey, you can simply call Navigator.goTo(…), passing the instance of the next Step, like so:

class RootJourney : Journey<RootJourneyBinding>(
  RootJourneyBinding::inflate,
  // Journeys need to have a ScreenContainer in which to perform navigation
  RootJourneyBinding::screenContainer
) {

  override fun onCreate(context: Context) {
    // Navigating while not shown is supported; any transitions will be skipped.
    // Using goTo while nothing is on the backstack is also fine, and a good way to set up initial state.
    // Navigation events between Steps are usually handled by passing lambdas into Steps to handle
    // user events. This convention has makes Steps highly reusable.
    navigator.goTo(HomeStep(onNextButtonClicked = { goToNextStep() })
  }

  private fun goToNextStep() = navigator.goTo(MyNextStep(/* ... */))
}

Each journey maintains its own backstack

Notice in the example above that all navigation occurs at the Journey level. Any dependencies required to interact with the Journey's navigator are provided to Steps via constructor arguments, e.g. the onNextButtonClicked callback above.

As a result of this structure, each Journey maintains its own backstack; Journeys generally don't mutate the backstacks of other Journeys. (This is one of the major changes from Magellan 1, which you can find out more about at Migrating from Magellan 1.)

A Journey can contain other Journeys

Since Journeys are just fancy Steps, Journeys can have other Journeys on their backstack. This kind of nesting allows for encapsulation and reuse of Journeys in the same manner as Steps. It also means that Magellan's navigation "back stack" is more accurately represented as a tree.

LifecycleAware and "child" components

Like the Jetpack Android lifecycle components, Magellan allows you to pass lifecycle events from lifecycle-owning objects (usually called the "parent") to arbitrary listeners (usually called "children" or "dependencies"). This process is called “lifecycle propagation".

However, Magellan's lifecycle event propagation differs from the standard Android lifecycle components in small but important ways. In Magellan:

  • Children get set up before and torn down after their parent. This means, for example, that all children are in the Shown state during the parent's show() and hide() functions, allowing you to access their views if they have any. This is the opposite of how standard Android lifecycle components work.
  • Once all active transitions have settled, children shall never be in a later lifecycle state than their parent. For example, a parent in the Created state will never have children who are Shown or Resumed. Children can be in an earlier lifecycle state than their parent, in order to support navigation and other patterns.

If you dig into the code examples above, you will find that each Journeys propagates its lifecycle to an attached Navigator child. The system is flexible to support arbitrary parent-child lifecycle relationships. In the next couple of sections, we'll look at how we can attach attach service objects to Steps, and even attach Steps to other Steps.

In order to receive lifecycle events, an object must implement the LifecycleAware interface. Steps and most other built-in classes in this library implement this interface.

Note that the LifecycleAware interface has methods named like show() and resume(), whereas the provided components like Step expose onShow() and onResume(). This is to avoid accidental missing calls to super().

Adding LifecycleAware components to a Step

Imagine that you want to measure how long users spend on a particular Step. You could factor this concern into a custom LifecycleAware class that pauses or resumes an internal timer whenever the corresponding lifecycle event is fired.

After you've built your metrics-gathering component, you have a few options for attaching it to a LifecycleOwner (e.g. Step).

  1. When the component is available at Step instantiation, you can use the property delegate by attachFieldToLifecycle(…):
class MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate) {

  var myLifecycleAwareComponent by attachFieldToLifecycle(MyLifecycleAwareComponent())
    @VisibleForTesting set  // Allows for overriding with a mock or fake in tests

  override fun onShow(context: Context, binding: MyFirstStepBinding) {
    // The component is added at instantiation, so it's available in all lifecycle methods
    binding.headlineTextView.text = myLifecycleAwareComponent.getHeadline
  }
}
  1. When injecting e.g. with Dagger, or for other times when the component isn't available until after instantiation, you can use the by attachLateinitFieldToLifecycle<…>() property delegate. The LifecycleAware component will be attached to the lifecycle when this field is set. This delegate works very similarly to a standard lateinit var; it will throw an exception if accessed before it is set.
class MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate) {

  var myLifecycleAwareComponent by attachLateinitFieldToLifecycle(MyLifecycleAwareComponent())
    @Inject @VisibleForTesting set  // Allows for overriding with a mock or fake in tests

  // The view isn't created until show(), so no view binding is passed in
  override fun onCreate(context: Context) {
    getApp().daggerComponent.inject(this)
    // components are attached to the lifecycle when set, so we can use them right away.
    // Here, myLifecycleAwareComponent's create() has been called already, so it's in the Created state.
    myLifecycleAwareComponent.doSomethingCool()
  }

  override fun onShow(context: Context, binding: MyFirstStepBinding) {
    // The component is added at instantiation, so it's available in all lifecycle methods
    binding.headlineTextView.text = myLifecycleAwareComponent.getHeadline()
  }
}
  1. If you want full control over when a component is attached or detached, use LifecycleOwner.attachToLifecycle(LifecycleAware):
class MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate) {

  override fun onShow(context: Context, binding: MyFirstStepBinding) {
    // The component is added at instantiation, so it's available in all lifecycle methods
    binding.headlineTextView.setOnClickListener {
      val myTemporaryLifecycleAwareComponent = MyTemporaryLifecycleAwareComponent()
      attachToLifecycle(myTemporaryLifecycleAwareComponent)
      binding.collapsibleContainer.addView(myTemporaryLifecycleAwareComponent.view)
      // TODO: Detatch myTemporaryLifecycleAwareComponent when necessary, which is why
      // using attachToLifecycle() directly isn't recommended in most cases.
    }
  }
}

Adding child Steps

Adding a child Step is the same as adding any other LifecyleAware component - you can use any of the three methods above. Additionally, you'll include logic to add your child Step's view into its parent Step's view hierarchy. This is typically done within show().

Breaking complex Steps into smaller ones usually makes your code more manageable, and allows for Step reuse in different flows.

Attaching to an Activity

You'll need to attach your Magellan Steps and Journeys to the Activity, so that Magellan can communicate with the Android framework. You can do this using setContentStep(…) with your root Step or Journey.

Extensions and custom implementations

Every Step, Journey, and app are different, and may need different setups for navigation or composition. Some examples of this include:

  • A Journey driven by an explicit state machine rather than using a backstack
  • A Step that emits something other than a View, like a data model to be used in a recyclable list

Magellan is built to be flexible, and can easily accommodate these setups. For more information on the various extension points available in Magellan, check out Library architecture and design.