-
Notifications
You must be signed in to change notification settings - Fork 72
Thinking in Magellan
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.
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.
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 best place to start exploring Magellan is with the Step
component. Step
s are the most basic building block of Magellan navigation. Each Step
encapsulates some business logic and its associated UI.
Suppose that we are building a virtual travel guide for famous landmarks. Let's first define a simple welcome screen:
class HomeStep : Step<HomeStepBinding>(HomeStepBinding::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 static screen needs no logic and requires very little boilerplate.
-
Step
s use ViewBinding to attach their XML views. TheStep
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)
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)
}
}
}
To show multiple Steps sequentially, i.e. navigate around the app, Magellan provides the Journey
s component.
Under the hood, Journey
s are Step
s 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 Step
s are left in the process.
Step
s andJourney
s are provided as minimal implementations of theNavigable
interface. To learn more about the class structure and implementation details of Magellan, you can skip to Library architecture and design.
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(/* ... */))
}
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 Step
s via constructor arguments, e.g. the onNextButtonClicked
callback above.
As a result of this structure, each Journey maintains its own backstack; Journey
s generally don't mutate the backstacks of other Journey
s. (This is one of the major changes from Magellan 1, which you can find out more about at Migrating from Magellan 1.)
Since Journey
s are just fancy Step
s, Journey
s can have other Journey
s on their backstack. This kind of nesting allows for encapsulation and reuse of Journey
s in the same manner as Step
s. It also means that Magellan's navigation "back stack" is more accurately represented as a tree.
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'sshow()
andhide()
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 areShown
orResumed
. 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 Journey
s 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.Step
s and most other built-in classes in this library implement this interface.
Note that the
LifecycleAware
interface has methods named likeshow()
andresume()
, whereas the provided components likeStep
exposeonShow()
andonResume()
. This is to avoid accidental missing calls tosuper()
.
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
).
- When the component is available at
Step
instantiation, you can use the property delegateby 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
}
}
- 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. TheLifecycleAware
component will be attached to the lifecycle when this field is set. This delegate works very similarly to a standardlateinit 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()
}
}
- 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 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 Step
s into smaller ones usually makes your code more manageable, and allows for Step
reuse in different flows.
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
.
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 aView
, 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.
Made with 💚 by the Wealthfront Android Team.