-
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: 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 best place to start exploring Magellan is with the Step
class. A Step
encapsulates business logic and its associated UI.
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.
-
Step
s 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.
Step
s 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!"
}
}
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'sshow()
andhide()
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 areShown
orResumed
. (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.Step
s 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 likeshow()
andresume()
, whereas the provided implementations likeStep
haveonShow()
andonResume()
to avoid accidental missing calls tosuper()
.
Adding a LifecycleAware
component to a step is easy, and there are a few options for different cases.
- 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
}
}
- 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 theLifecycleAware
component will be attached to the lifecycle when this field is set. This delegate works very similarly to a standardlateinit 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()
}
}
- 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, 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 Step
s into smaller, more manageable ones that can also be reused in different places.
Nesting Step
s inside of other Step
s 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 Journey
s come in.
Journey
s are simply Step
s 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).
Step
s andJourney
s are both provided 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 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.
A result of this structure is that each Journey maintains its own backstack. This means that each Journey
is fully encapsulated, and that 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 the "backstack" is more accurately represented as a tree of navigation nodes.
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
.
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
For more information on the various extension points available in Magellan, check out Library architecture and design.
Made with 💚 by the Wealthfront Android Team.