Skip to content

Thinking in Magellan

Ryan Moelter edited this page Jul 18, 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: Simple and powerful abstractions and smart encapsulation make reasoning through code simpler.
    • Or: Magellan is built on only a handful of core concepts that are easy to learn and reason about.
  • Flexible: The 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.

The first Step

The best place to start exploring Magellan is with the Step class. A Step encapsulates business logic and its associated UI.

Hello world!

Let’s start with a “Hello world” example:

class MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate)
<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 simple static screen needs no logic and very little boilerplate.
  • Steps use ViewBinding to attach their XML views. The class is parameterized with the binding class associated with it so it can be used in lifecycle methods, which we'll explore in the next section. (Jetpack Compose support is coming soon)

We’ll leave the xml files to the imagination for the rest of this doc, except where necessary. There's nothing non-standard about them.

Adding logic and a lifecycle

Steps have a lifecycle very similar to fragments or activities, but simpler.

State Enter event Exit event Description
Destroyed N/A N/A Only happens directly after init or after being destroyed.
Created create() destroy() This object is created, but is not currently displayed. At this point, dependency injection has probably happened, but no views exist and no Activity context is held.
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 onStart()/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 MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate) {
  override fun onShow(context: Context, binding: MyFirstStepBinding) {
    binding.headlineTextView.text = "Hello world!"
  }
}

LifecycleAware components and child Steps

A core concept in Magellan is lifecycle propagation.

Like the standard android lifecycle components, Magellan allows you to attach lifecycle listeners (usually called "children" or "dependencies") to lifecycle-owning objects (usually called the "parent" relative to its children). The parent is responsible for passing any lifecycle events that it receives onto its children (called “lifecycle propagation").

However, Magellan's lifecycle event propagation differs from the standard android lifecycle components in small but important ways. The rules for lifecycle propagation in Magellan are:

  • 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.)
  • While parents are responsible for their children's lifecycle events, the 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. (These children can be in an earlier lifecycle state, however, which is useful for navigation and other patterns.)

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 this interface is different than the Android architecture components LifecycleObserver interface due to the differences in lifecycle propagation.

Note: The LifecycleAware interface has methods named like show() and resume(), whereas the provided implementations like Step have onShow() and onResume() to avoid accidental missing calls to super().

Adding LifecycleAware components to a Step

Adding a LifecycleAware component to a step is easy, and there are a few options for different cases.

  1. For cases where the component is available at instantiation time, you can use the property delegate by lifecycle(…), like so:
class MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate) {

  var myLifecycleAwareComponent by lifecycle(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 lateinitLifecycle<…>() property delegate and the LifecycleAware component will be attached to the lifecycle when this field is set. This delegate works very similarly to a standard lateinit var, and will throw an exception if accessed before it is set.
class MyFirstStep : Step<MyFirstStepBinding>(MyFirstStepBinding::inflate) {

  var myLifecycleAwareComponent by lateinitLifecycle(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, and you can use any of the three methods above. The only difference is that you have to add the Step's view to the place you want it in the view hierarchy in show(), since Magellan doesn't know where you want to attach the view.

This allows you to break complex Steps into smaller, more manageable ones that can also be reused in different places.

A Journey has multiple Steps

Nesting Steps inside of other Steps works well when you want to show all of the children at the same time, but sometimes you want to show Steps sequentially, i.e. navigate around the app. This is where Journeys come in.

Journeys are simply Steps with a backstack-based Navigator attached by default. They have the same lifecycle, same ability to attach lifecycle components, and same ability to display UI to represent state (for example, a Journey for user signup could use a progress bar to show how many steps are left in the signup process).

Steps and Journeys are both provided 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 MyFirstJourney : Journey<MyFirstJourneyBinding>(
  MyFirstJourneyBinding::inflate,
  // Journeys need to have a ScreenContainer in which to perform navigation
  MyFirstJourneyBinding::screenContainer
) {

  override fun onCreate(context: Context) {
    // Navigating while not shown is fine, and 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 has the side effect of making Steps highly reusable.
    navigator.goTo(MyFirstStep(onNextButtonClicked = { goToNextStep() })
  }

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

Since we pass an instance of our next Step into goTo(), we can provide dependencies in the Step's constructor. We highly recommend you use a dependency injection framework like Dagger to handle most dependencies, but navigational events should be passed into the constructor so that the Journey can handle the responsibility of navigation between its children.

Each journey maintains its own backstack

A result of this structure is that each Journey maintains its own backstack. This means that each Journey is fully encapsulated, and that 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 the "backstack" is more accurately represented as a tree of navigation nodes.

Attaching to an Activity

At some point, you need to attach our Magellan Steps and Journeys to the Activity. 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

For more information on the various extension points available in Magellan, check out Library architecture and design.