package com.wealthfront.magellan; import android.app.Activity; import android.os.Bundle; import android.os.Handler; import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.View; import com.wealthfront.magellan.transitions.DefaultTransition; import com.wealthfront.magellan.transitions.Transition; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; import java.util.List; import static android.os.Looper.getMainLooper; import static com.wealthfront.magellan.Direction.BACKWARD; import static com.wealthfront.magellan.Direction.FORWARD; import static com.wealthfront.magellan.NavigationType.GO; import static com.wealthfront.magellan.NavigationType.NO_ANIM; import static com.wealthfront.magellan.NavigationType.SHOW; import static com.wealthfront.magellan.Views.whenMeasured; import static com.wealthfront.magellan.Preconditions.checkArgument; import static com.wealthfront.magellan.Preconditions.checkNotNull; import static com.wealthfront.magellan.Preconditions.checkState; /** * Class responsible for navigating between screens and maintaining collection of screens in a back stack. */ public class Navigator implements BackHandler { private final Deque<Screen> backStack = new ArrayDeque<>(); private Activity activity; private Menu menu; private ScreenContainer container; private final Transition transition; private Transition overridingTransition; private final List<ScreenLifecycleListener> lifecycleListeners = new ArrayList<>(); private View ghostView; // keep track of the disappearing view we are animating private boolean loggingEnabled; private EventTracker eventTracker; public static Builder withRoot(Screen root) { return new Builder(root); } Navigator(Builder builder) { backStack.push(builder.root); transition = builder.transition; loggingEnabled = builder.loggingEnabled; eventTracker = new EventTracker(builder.maxEventsTracked); } /** * Adds a lifecycle listener that will be notified when each Screen is shown and hidden. * * @param lifecycleListener Listener that receives screen lifecycle events */ public void addLifecycleListener(ScreenLifecycleListener lifecycleListener) { lifecycleListeners.add(lifecycleListener); } /** * Unregisters a Screen lifecycle listener. Removed listener will no longer receive screen lifecycle * callbacks. * * @param lifecycleListener listener to unregister from receiving screen lifecycle events */ public void removeLifecycleListener(ScreenLifecycleListener lifecycleListener) { lifecycleListeners.remove(lifecycleListener); } /** * Initializes the Navigator with Activity instance and Bundle for saved instance state. * * Call this method from {@link Activity#onCreate(Bundle) onCreate} of the Activity associated with this Navigator. * * @param activity Activity associated with application * @param savedInstanceState state to restore from previously destroyed activity * @throws IllegalStateException when no {@link ScreenContainer} view present in hierarchy with view id of container */ public void onCreate(Activity activity, Bundle savedInstanceState) { this.activity = activity; container = (ScreenContainer) activity.findViewById(R.id.magellan_container); checkState(container != null, "There must be a ScreenContainer whose id is R.id.magellan_container in the view hierarchy"); for (Screen screen : backStack) { screen.restore(savedInstanceState); screen.onRestore(savedInstanceState); } showCurrentScreen(FORWARD); } /** * Notifies all screens to save instance state in input Bundle. * * Call this method from {@link Activity#onSaveInstanceState(Bundle) onSaveInstanceState} in the Activity associated * with this Navigator * * @param outState Bundle in which to store screen state information */ public void onSaveInstanceState(Bundle outState) { for (Screen screen : backStack) { screen.save(outState); screen.onSave(outState); } } /** * Attaches options menu to Navigator to allow Screens to control which items are shown/hidden. * * All menu items are hidden by default. * * Call this method from {@link Activity#onCreateOptionsMenu(Menu) onCreateOptionsMenu} in the Activity associated * with this Navigator * * @param menu options menu to be controlled by current screen */ public void onCreateOptionsMenu(Menu menu) { this.menu = menu; updateMenu(); } /** * Updates options menu to Navigator to allow Screens to update which items are shown/hidden. * * All menu items are hidden by default. * * Call this method from {@link Activity#onPrepareOptionsMenu(Menu) onPrepareOptionsMenu} in the Activity associated * with this Navigator * * @param menu options menu to be updated by current screen */ public void onPrepareOptionsMenu(Menu menu) { this.menu = menu; updateMenu(); } /** * Notifies Navigator that the activity's onResume lifecycle callback has been hit. Call this method from * {@code onResume} of the Activity associated with this Navigator. * * This method will notify the current screen of the lifecycle event if the activity parameter is the same as the * activity provided to this Navigator in {@link #onCreate(Activity, Bundle) onCreate}. * * @param activity activity that received onResume callback */ public void onResume(Activity activity) { if (sameActivity(activity)) { currentScreen().onResume(activity); } } /** * Notifies Navigator that the activity's onPause lifecycle callback has been hit. Call this method from * {@link Activity#onPause() onPause} of the Activity associated with this Navigator. * * This method will notify the current screen of the lifecycle event if the activity parameter is the same as the * activity provided to this Navigator in {@link #onCreate(Activity, Bundle) onCreate}. * * @param activity activity that received onPause callback */ public void onPause(Activity activity) { if (sameActivity(activity)) { currentScreen().onPause(activity); } } /** * Notifies Navigator that the activity's onDestroy lifecycle callback has been hit. Call this method from * {@link Activity#onDestroy() onDestroy} of the Activity associated with this Navigator. * * This method will hid the current screen, and clear references to this Navigators associated activity, menu, and * container view, if the activity parameter is the same as the activity provided to this Navigator in * {@link #onCreate(Activity, Bundle) onCreate}. * * @param activity activity that received onDestroy callback */ public void onDestroy(Activity activity) { if (sameActivity(activity)) { hideCurrentScreen(); this.activity = null; container = null; menu = null; } } /** * Handles back button press by user. Notifies current screen on back button press and allows screen to handle * back press itself. If the current screen does not handle back press, and this Navigator has more than one screen, * this Navigator will remove the current screen from its collection of screens and display the previous screen. * If this Navigator only has one Screen when it receives a call to {@link #handleBack()} it will not handle the back * button press. * * Call this method from within {@link Activity#onBackPressed()} of the activity associated with this Navigator. * * (Example usage) In the Activity class associated with this Navigator, put: * <pre> <code> * {@literal @}Override * public void onBackPressed() { * if (!navigator.handleBack()) { * super.onBackPressed(); * } * } * </code> </pre> * * @return true if the Navigator consumed the back button click */ @Override public boolean handleBack() { Screen currentScreen = currentScreen(); if (currentScreen.handleBack()) { return true; } else { if (!atRoot()) { goBack(); return true; } else { return false; } } } /** * Gets the screen on top of this Navigator's back stack. * * @return the current screen in the back stack */ public Screen currentScreen() { checkBackStackNotEmpty(); return backStack.peek(); } /** * Returns true if this Navigator is at its root screen, false otherwise. * * @return true if this Navigator is at its root screen, false otherwise. */ public boolean atRoot() { return backStack.size() == 1; } /** * Clears the back stack of this Navigator and adds the input Screen as the new root of the back stack. * * @param activity activity used to verify this Navigator is in an acceptable state when resetWithRoot is called * @param root new root screen for this Navigator * @throws IllegalStateException if {@link #onCreate(Activity, Bundle)} has already been called on this Navigator */ public void resetWithRoot(Activity activity, final Screen root) { checkOnCreateNotYetCalled(activity, "resetWithRoot() must be called before onCreate()"); backStack.clear(); backStack.push(root); } /** * Change the elements of the back stack according to the implementation of the HistoryRewriter parameter. * <b>Note, this method cannot be called after calling {@link #onCreate(Activity, Bundle)} on this Navigator.</b> The * primary use case for this method is to change the back stack before the navigator is fully initialized (e.g. * showing a login screen if necessary). It is possible to manipulate the back stack with a {@link HistoryRewriter}, * {@link #navigate(HistoryRewriter)}. * * @param activity activity used to verify this Navigator is in an acceptable state when resetWithRoot is called * @param historyRewriter rewrites back stack to desired state * @throws IllegalStateException if {@link #onCreate(Activity, Bundle)} has already been called on this Navigator */ public void rewriteHistory(Activity activity, HistoryRewriter historyRewriter) { checkOnCreateNotYetCalled(activity, "rewriteHistory() must be called before onCreate()"); historyRewriter.rewriteHistory(backStack); } /** * Navigates to the screen on top of the back stack after the HistoryRewriter has rewritten the back stack history. * * Does not animate during the magellan. * * @param historyRewriter manipulates the back stack during magellan */ public void navigate(final HistoryRewriter historyRewriter) { navigate(historyRewriter, NO_ANIM); } /** * Navigates to the screen on top of the back stack after the HistoryRewriter has rewritten the back stack history. * * Animates the magellan according to the NavigationType parameter. * * @param historyRewriter manipulates the back stack during magellan * @param navType controls how the new screen is displayed to the user */ public void navigate(final HistoryRewriter historyRewriter, NavigationType navType) { navigate(FORWARD, navType, new Runnable() { @Override public void run() { historyRewriter.rewriteHistory(backStack); } }); } /** * Navigates to the screen on top of the back stack after the HistoryRewriter has rewritten the back stack history. * * Animates the magellan according to the NavigationType parameter, in the direction specified by the Direction * parameter. * * @param historyRewriter manipulates the back stack during magellan * @param navType controls how the new screen is displayed to the user * @param direction controls the direction in which the new screen moves in and old screen moves out during magellan */ public void navigate(final HistoryRewriter historyRewriter, NavigationType navType, Direction direction) { navigate(direction, navType, new Runnable() { @Override public void run() { historyRewriter.rewriteHistory(backStack); } }); } /** * Replaces screen on top of the back stack with the new screen. When magellan completes, previous screen on top of * the back stack will have been removed, and the Screen parameter will be the new top screen on the back stack. * * @param screen new top screen on back stack */ public void replace(Screen screen) { replace(screen, GO); } /** * Replaces screen on top of the back stack with the new screen without animation. When magellan completes, * previous screen on top of the back stack will have been removed, and the Screen parameter will be the new top * screen on the back stack. * * @param screen new top screen on back stack */ public void replaceNow(Screen screen) { replace(screen, NO_ANIM); } /** * Shows new screen by animating screen to slide up from bottom of container to cover previous screen. When magellan * completes, the Screen parameter will be on top of this Navigator's back stack. * * If the current screen is already being shown, as determined by {@code currentScreen().equals(screen)}, this method * will do nothing. * * @param screen new top screen on back stack */ public void show(Screen screen) { show(screen, SHOW); } /** * Shows new screen without animating the magellan event. When magellan completes, the Screen parameter will be * on top of this Navigator's back stack. * * If the current screen is already being shown, this method will do nothing. * * @param screen new top screen on back stack */ public void showNow(Screen screen) { show(screen, NO_ANIM); } private void show(Screen screen, NavigationType navType) { if (!isCurrentScreen(screen)) { navigateTo(screen, navType); } } /** * Navigates back to previous screen if Screen parameter is currently the top screen on this Navigator's back stack. * If the Screen parameter is not the top screen on the back stack, this method does nothing. * * @param screen screen to hide */ public void hide(Screen screen) { if (isCurrentScreen(screen)) { navigateBack(SHOW); } } /** * Returns true if the Screen parameter is the current screen on top of this Navigator's back stack, false otherwise. * Equality is determined using {@code Screen#equals(Object)}. * * @param screen screen to check if it is currently on top of this Navigator's back stack * @return true if the Screen parameter is the top screen on this Navigator's back stack */ public boolean isCurrentScreen(Screen screen) { return !backStack.isEmpty() && currentScreen().equals(screen); } /** * Navigates to the Screen parameter. Animates current top screen on this Navigator's back stack out to the left and * slides the Screen parameter screen in from the right. At the end of the magellan event, the Screen parameter will * be the top screen on this Navigator's back stack. * * @param screen new top screen for this Navigator's back stack */ public void goTo(Screen screen) { navigateTo(screen, GO); } /** * Navigates from current screen to previous screen in this Navigator's back stack. Current screen animates out of the * view by sliding out to the right and the next screen in the back stack slides in from the left. * * @throws IllegalStateException if this Navigator only has one screen in its back stack */ public void goBack() { checkState(!atRoot(), "Can't go back, this is the last screen. Did you mean to call handleBack() instead?"); navigateBack(GO); } /** * Navigates from current screen all the way to the root screen in this Navigator's back stack, removing all * intermediate screen in this Navigator's back stack along the way. The current screen animates out of the view * according to the animation specified by the NavigationType parameter. * * @param magellanType determines how the magellan event is animated */ public void goBackToRoot(NavigationType magellanType) { navigate(new HistoryRewriter() { @Override public void rewriteHistory(Deque<Screen> history) { while (history.size() > 1) { history.pop(); } } }, magellanType, BACKWARD); } /** * Navigates from the current screen back to the Screen parameter wherever it is in this Navigator's back stack. * Screens in between the current screen and the Screen parameter on the back stack are removed. If the Screen * parameter is not present in this Navigator's back stack, this method is equivalent to * {@link #goBackToRoot(NavigationType) goBackToRoot(NavigationType.GO)} * * @param screen screen to navigate back to through this Navigator's back stack */ public void goBackTo(final Screen screen) { navigate(new HistoryRewriter() { @Override public void rewriteHistory(Deque<Screen> history) { checkArgument(history.contains(screen), "Can't go back to a screen that isn't in history."); while (history.size() > 1) { if (history.peek() == screen) { break; } history.pop(); } } }, GO, BACKWARD); } /** * Navigates from the current screen back to the screen in this Navigator's back stack immediately before the * Screen parameter. Screens in between the current screen and the Screen parameter on the back stack are removed. * If the Screen parameter is not present in this Navigator's back stack, this method is equivalent to * {@link #goBackToRoot(NavigationType) goBackToRoot(NavigationType.GO)} * * @param screen screen to navigate back to through this Navigator's back stack */ public void goBackBefore(Screen screen) { goBackBefore(screen, GO); } /** * Navigates from the current screen back to the screen in this Navigator's back stack immediately before the * Screen parameter. Screens in between the current screen and the Screen parameter on the back stack are removed. * If the Screen parameter is not present in this Navigator's back stack, this method is equivalent to * {@link #goBackToRoot(NavigationType) goBackToRoot(NavigationType)} * * @param screen screen to navigate back to through this Navigator's back stack * @param magellanType determines how the magellan event is animated */ public void goBackBefore(final Screen screen, NavigationType magellanType) { navigate(new HistoryRewriter() { @Override public void rewriteHistory(Deque<Screen> history) { checkArgument(history.contains(screen), "Can't go back past a screen that isn't in history."); while (history.size() > 1) { if (history.pop() == screen) { break; } } } }, magellanType, BACKWARD); } private void replace(final Screen screen, NavigationType navType) { navigate(FORWARD, navType, new Runnable() { @Override public void run() { backStack.pop(); backStack.push(screen); } }); } private void navigateTo(final Screen screen, NavigationType navType) { navigate(FORWARD, navType, new Runnable() { @Override public void run() { backStack.push(screen); } }); } private void navigateBack(NavigationType navType) { navigate(BACKWARD, navType, new Runnable() { @Override public void run() { backStack.pop(); } }); } private void navigate(final Direction direction, final NavigationType navType, final Runnable backStackOperation) { container.setInterceptTouchEvents(true); checkNotNull(activity, "The activity cannot be null. Did you forget to call onCreate()?"); currentScreen().onPause(activity); View from = hideCurrentScreen(); backStackOperation.run(); View to = showCurrentScreen(direction); currentScreen().onResume(activity); animateAndRemove(from, to, navType, direction); reportEvent(navType, direction); } private void animateAndRemove( final View from, final View to, final NavigationType navType, final Direction direction) { ghostView = from; final Transition transitionToUse = overridingTransition != null ? overridingTransition : transition; overridingTransition = null; whenMeasured(to, new Views.OnMeasured() { @Override public void onMeasured() { transitionToUse.animate(from, to, navType, direction, new Transition.Callback() { @Override public void onAnimationEnd() { if (container != null) { container.removeView(from); if (from == ghostView) { // Only clear the ghost if it's the same as the view we just removed ghostView = null; } container.setInterceptTouchEvents(false); } } }); } }); } private boolean isAnimating() { return ghostView != null; } private View showCurrentScreen(Direction direction) { Screen currentScreen = currentScreen(); View view = currentScreen.recreateView(activity, this); container.addView(view, direction == FORWARD ? container.getChildCount() : 0); currentScreen.createDialog(); activity.setTitle(currentScreen.getTitle(activity)); currentScreen.onShow(activity); for (ScreenLifecycleListener lifecycleListener : lifecycleListeners) { lifecycleListener.onShow(currentScreen); } callOnNavigate(currentScreen); // Need to post to avoid animation bug on disappearing menu new Handler(getMainLooper()).post(new Runnable() { @Override public void run() { updateMenu(); } }); return view; } private View hideCurrentScreen() { // if we were already animating a view, just skip it and remove the view immediately if (isAnimating()) { container.removeView(ghostView); ghostView = null; } checkState(container.getChildCount() == 1, "The container view must have a single child, but it had " + container.getChildCount()); Screen currentScreen = currentScreen(); for (ScreenLifecycleListener lifecycleListener : lifecycleListeners) { lifecycleListener.onHide(currentScreen); } currentScreen.onHide(activity); currentScreen.destroyDialog(); currentScreen.destroyView(); View view = container.getChildAt(0); // will be removed at the end of the animation return view; } private void callOnNavigate(Screen currentScreen) { if (activity instanceof NavigationListener) { ((NavigationListener) activity).onNavigate( ActionBarConfig.with() .visible(currentScreen.shouldShowActionBar()) .animated(currentScreen.shouldAnimateActionBar()) .colorRes(currentScreen.getActionBarColorRes()) .build()); } } private void updateMenu() { if (menu != null) { for (int i = 0; i < menu.size(); i++) { menu.getItem(i).setVisible(false); } currentScreen().onUpdateMenu(menu); } } private boolean sameActivity(Activity activity) { return this.activity == activity; } private void checkOnCreateNotYetCalled(Activity activity, String reason) { checkState(this.activity == null || !sameActivity(activity), reason); } private void checkBackStackNotEmpty() { checkState(!backStack.isEmpty(), "There must be a least one screen in the backstack"); } private void reportEvent(NavigationType navType, Direction direction) { Event event = new Event(navType, direction, getBackStackDescription()); eventTracker.reportEvent(event); if (loggingEnabled) { Log.d(Navigator.class.getSimpleName(), event.toString()); } } /** * Returns a human-readable string describing the screens in this Navigator's back stack. * * @return a human-readable description of the back stack. */ public String getBackStackDescription() { ArrayList<Screen> backStackCopy = new ArrayList<>(backStack); Collections.reverse(backStackCopy); String currentScreen = ""; if (!backStackCopy.isEmpty()) { currentScreen = backStackCopy.remove(backStackCopy.size() - 1).toString(); } return TextUtils.join(" > ", backStackCopy) + (backStackCopy.isEmpty() ? "" : " > ") + "[" + currentScreen + "]"; } /** * Returns a human-readable string describing the magellan events that happened recently * (including the state of the backStack at the time). * How many events are kept can be configured using {@link Builder#maxEventsTracked(int)} (the default is 50). * * @return a human-readable description of the past events. */ public String getEventsDescription() { return eventTracker.getEventsDescription(); } /** * Sets a specific transition to use during the next magellan event. The overriding transition will only be used * once, subsequent magellan events will use the default transition specified during construction of this Navigator. * * @param overridingTransition transition to override default * @return this Navigator that will use the Transition param for its next magellan event */ public Navigator overrideTransition(Transition overridingTransition) { this.overridingTransition = overridingTransition; return this; } /** * Builder for constructing Navigators with particular parameters. * * Use {@link #withRoot(Screen)} to create a Builder, which can then be used to construct a navigator. */ public static class Builder { private final Screen root; private Transition transition = new DefaultTransition(); private boolean loggingEnabled; private int maxEventsTracked = 50; Builder(Screen root) { this.root = root; } public Builder transition(Transition transition) { this.transition = transition; return this; } public Builder loggingEnabled(boolean loggingEnabled) { this.loggingEnabled = loggingEnabled; return this; } public Builder maxEventsTracked(int maxEventsTracked) { this.maxEventsTracked = maxEventsTracked; return this; } public Navigator build() { return new Navigator(this); } } }