/* * Copyright 2016 Kejun Xia * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.shipdream.lib.android.mvc; import android.os.Bundle; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentTransaction; import android.support.v7.app.AppCompatActivity; import android.view.View; import com.shipdream.lib.poke.util.ReflectUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import javax.inject.Inject; public abstract class MvcActivity extends AppCompatActivity { private Logger logger = LoggerFactory.getLogger(getClass()); static final String STATE_PREFIX = "$mvc:state:"; private static final String FRAGMENT_TAG_PREFIX = "$mvc:fragment:"; protected DelegateFragment delegateFragment; boolean toPrintAppExitMessage = false; private List<Runnable> actionsOnDestroy = new CopyOnWriteArrayList<>(); String getDelegateFragmentTag() { return FRAGMENT_TAG_PREFIX + checkDelegateFragmentClass().getName(); } private EventRegister eventRegister; @SuppressWarnings("unchecked") @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); eventRegister = new EventRegister(this); eventRegister.registerEventBuses(); delegateFragment = (DelegateFragment) getSupportFragmentManager().findFragmentByTag( getDelegateFragmentTag()); if (delegateFragment == null) { //Brand new container fragment try { delegateFragment = (DelegateFragment) new ReflectUtils.newObjectByType( checkDelegateFragmentClass()).newInstance(); } catch (Exception e) { throw new RuntimeException("Failed to instantiate delegate fragment.", e); } FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); trans.replace(android.R.id.content, delegateFragment, getDelegateFragmentTag()); trans.commitNow(); } } private Class<? extends DelegateFragment> checkDelegateFragmentClass() { Class<? extends DelegateFragment> clazz = getDelegateFragmentClass(); if (clazz == null) { throw new IllegalStateException("Mvc.getDelegateFragmentClass() must return a non-null class type of a MvcActivity.DelegateFragment"); } return clazz; } @Override protected void onDestroy() { super.onDestroy(); eventRegister.unregisterEventBuses(); if (toPrintAppExitMessage && logger.isTraceEnabled()) { logger.trace("App Exits(UI): {} injected beans are still cached.", Mvc.graph().getRootComponent().getCache()); toPrintAppExitMessage = false; } if (actionsOnDestroy != null) { for (Runnable runnable : actionsOnDestroy) { runnable.run(); } } } void performSuperBackKeyPressed() { super.onBackPressed(); } /** * Specify the routing mapping from a screen controller to the fragment class of the screen. * When navigate with {@link NavigationManager}, this method will be used to find out which * fragment as a screen will be launched for the controller type in the activity. * <p> * <p> * The mapping between controller class and fragment class can be listed one by one here. However, * it needs to keep modifying this method to add new mapping when a new screen is add. To make * the mapping generic, consider to use {@link Class#forName(String)}. In most cases, the * performance reflection is negligible for navigation and this is one off invocation for every * single navigation. A couple of milliseconds are not a big deal. * </p> * * @param controllerClass The controller class type * @return The class type of the controller's corresponding {@link MvcFragment} */ protected abstract Class<? extends MvcFragment> mapFragmentRouting( Class<? extends FragmentController> controllerClass); /** * Provides the class type of the delegate fragment which is the root fragment holding fragments * during navigation. By default, {@link DelegateFragment}.class is provided. Overrides this * method to provide custom delegate fragment. * * @return The class type of the delegate fragment. Null is not allowed to be returned otherwise * an illegal exception will be thrown */ protected abstract Class<? extends DelegateFragment> getDelegateFragmentClass(); @Override public void onBackPressed() { delegateFragment.onBackButtonPressed(); } /** * Post an event from this view to other views. Events sent to views should be managed by controllers. * <p>However, it's handy in some scenarios. For example, when routing intent received by Activities to * Fragments, EventBusV is a handy solution. Note that the AndroidMvc framework is a single * Activity design and it manages views on fragment level and fragments don't have * onNewIntent(Intent intent) method. When a fragment needs to handle an intent, use eventBusV * to route the intent to fragments from the main activity.</p> * * @param event The event to views */ protected void postEvent2V(Object event) { eventRegister.postEvent2V(event); } /** * Add callback so that onViewReady will be delay to call after all instance state are restored * * @param runnable The delayed onViewReady callbacks */ void addPendingOnViewReadyActions(Runnable runnable) { delegateFragment.pendingOnViewReadyActions.add(runnable); } private static class DelegateFragmentController extends Controller { @Inject private NavigationManager navigationManager; private DelegateFragment delegateFragment; private void onEvent(final NavigationManager.Event.OnLocationForward event) { uiThreadRunner.post(new Runnable() { @Override public void run() { delegateFragment.handleForwardNavigation(event); } }); } private void onEvent(final NavigationManager.Event.OnLocationBack event) { uiThreadRunner.post(new Runnable() { @Override public void run() { delegateFragment.handleBackNavigation(event); } }); } @Override public Class modelType() { return null; } private void navigateBack(Object sender) { navigationManager.navigate(sender).back(); } private NavLocation getCurrentLocation() { return navigationManager.getModel().getCurrentLocation(); } } /** * This fragment is the container fragment as a root of the activity. When navigating by * {@link NavigationManager}, new fragments will be created and replace the root view of this * fragment or pop out the stacked history fragments. {@link NavigationManager} can be simply * injected into any fragments extending {@link MvcFragment} by fields annotated by @Inject. */ public static abstract class DelegateFragment<CONTROLLER extends FragmentController> extends MvcFragment<CONTROLLER> { private static final String MVC_STATE_BUNDLE_KEY = STATE_PREFIX + "RootBundle"; private Logger logger = LoggerFactory.getLogger(getClass()); //Track if the state is saved and not able to commit fragment transaction private boolean canCommitFragmentTransaction = false; private List<Runnable> pendingNavActions = new ArrayList<>(); private List<Runnable> pendingOnViewReadyActions = new ArrayList<>(); @Inject private MvcActivity.DelegateFragmentController delegateFragmentController; /** * Gets the id of activity layout resource. By default it's a single * {@link android.widget.FrameLayout} into which new fragment will be injected into during * navigation. Eg. During navigation, FragmentA, FragmentB and etc will replace the current * containing fragment inside this {@link android.widget.FrameLayout}. * <p> * Overrides this method to provide custom layout if complex layout is required. For * example, a {@link android.support.v4.widget.DrawerLayout} maybe needed in this fragment. * In this case, create a custom layout with the {@link android.support.v4.widget.DrawerLayout} * and corresponding components. <br><br> * <b> * Note that, once this methods is overridden to provide a custom view, * {@link #getContentLayoutResId()} MUST be overridden as well to provide the * id of the layout in the custom layout that will be used to place navigating fragments. * </b> * </p> * * @return The resource id of the root layout of the activity */ @Override protected int getLayoutResId() { return R.layout.android_mvc_delegate_fragment; } /** * Provides the id of the layout that will be used to hold navigating fragments. Note that, * when {@link #getLayoutResId()} is overridden, this method MUST be overridden as well. * * @return The content layout resource id * @throws IllegalStateException when {@link #getLayoutResId()} is overridden but this * method is not. */ protected int getContentLayoutResId() { if (getLayoutResId() != R.layout.android_mvc_delegate_fragment) { String msg = String.format("%s.getContentLayoutResId() must be overridden to " + "provide the layout that is used to hold navigating fragments.", getClass().getName()); throw new IllegalStateException(msg); } return R.id.android_mvc_delegate_fragment_content; } @Override public boolean onBackButtonPressed() { MvcFragment topFragment = null; NavLocation curLoc = delegateFragmentController.getCurrentLocation(); if (curLoc != null && curLoc.getLocationId() != null) { topFragment = (MvcFragment) getChildFragmentManager().findFragmentByTag( getFragmentTag(curLoc.getLocationId())); } boolean navigateBack = false; if (topFragment != null) { navigateBack = !topFragment.onBackButtonPressed(); } if (navigateBack) { delegateFragmentController.navigateBack(this); } return true; } private String getFragmentTag(String locationId) { return FRAGMENT_TAG_PREFIX + locationId; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); MvcActivity activity = ((MvcActivity) getActivity()); activity.delegateFragment = this; } void onPreViewReady(final View view, final Bundle savedInstanceState) { if (savedInstanceState != null) { notifyAllSubMvcFragmentsTheirStateIsManagedByMe(this, true); } delegateFragmentController.delegateFragment = this; } private boolean firstTimeRun = false; @Override public void onViewReady(View view, Bundle savedInstanceState, Reason reason) { super.onViewReady(view, savedInstanceState, reason); canCommitFragmentTransaction = true; if (reason.isFirstTime()) { firstTimeRun = true; } } @Override public void onViewStateRestored(Bundle savedInstanceState) { super.onViewStateRestored(savedInstanceState); if (savedInstanceState != null) { Bundle mvcOutState = savedInstanceState.getBundle(MVC_STATE_BUNDLE_KEY); long ts = System.currentTimeMillis(); MvcStateKeeperHolder.restoreState(mvcOutState); logger.trace("Restored state of all active controllers, {}ms used.", System.currentTimeMillis() - ts); notifyAllSubMvcFragmentsTheirStateIsManagedByMe(this, false); if (pendingOnViewReadyActions != null) { int size = pendingOnViewReadyActions.size(); for (int i = 0; i < size; i++) { pendingOnViewReadyActions.get(i).run(); } pendingOnViewReadyActions.clear(); } } } /** * Called when the app starts up for the first time. Use {@link NavigationManager} to * navigate to the initial fragment in this callback. {@link NavigationManager} can be * obtained by inject {@link NavigationManager} to the view's controller. This callback is * equivalent to override {@link #onViewReady(View, Bundle, Reason)} and perform action when * reason of view ready of this {@link DelegateFragment} is {@link Reason#isFirstTime()}. * <p/> * <p> * Note this callback will NOT be invoked on restoration after the app is killed by the OS from background. * </p> */ protected abstract void onStartUp(); @Override public void onResume() { super.onResume(); canCommitFragmentTransaction = true; runPendingNavigationActions(); if (firstTimeRun) { //Run onStartUp() in onResume after onViewReady to make sure the extending fragments //views are ready before do the startup action onStartUp(); } firstTimeRun = false; } private void runPendingNavigationActions() { if (!pendingNavActions.isEmpty()) { for (Runnable r : pendingNavActions) { r.run(); } pendingNavActions.clear(); } } @Override public void onPause() { super.onPause(); canCommitFragmentTransaction = false; } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); long ts = System.currentTimeMillis(); Bundle mvcOutState = new Bundle(); MvcStateKeeperHolder.saveState(mvcOutState); outState.putBundle(MVC_STATE_BUNDLE_KEY, mvcOutState); logger.trace("Save state of all active controllers, {}ms used.", System.currentTimeMillis() - ts); notifyAllSubMvcFragmentsTheirStateIsManagedByMe(this, true); } /** * Notify all sub MvcFragments theirs state is managed by this root fragment. So all * {@link Bean} objects those fragments holding will be saved into this root * fragment's outState bundle. */ private void notifyAllSubMvcFragmentsTheirStateIsManagedByMe(MvcFragment fragment, final boolean selfManaged) { traverseFragmentAndSubFragments(fragment, new FragmentManipulator() { @Override public void manipulate(Fragment fragment) { if (fragment != null && fragment.isAdded() && fragment instanceof MvcFragment) { ((MvcFragment) fragment).isStateManagedByRootDelegateFragment = selfManaged; } } }); } /** * Handle the forward navigation event call back * * @param event The forward navigation event */ private void handleForwardNavigation(final NavigationManager.Event.OnLocationForward event) { if (!canCommitFragmentTransaction) { pendingNavActions.add(new Runnable() { @Override public void run() { performForwardNav(event); } }); } else { performForwardNav(event); } } private void traverseFragmentAndSubFragments(Fragment fragment, FragmentManipulator manipulator) { if (fragment != null) { manipulator.manipulate(fragment); List<Fragment> frags = fragment.getChildFragmentManager().getFragments(); if (frags != null) { int size = frags.size(); for (int i = 0; i < size; i++) { Fragment frag = frags.get(i); if (frag != null) { traverseFragmentAndSubFragments(frag, manipulator); } } } } } interface FragmentManipulator { void manipulate(Fragment fragment); } @SuppressWarnings("unchecked") private void performForwardNav(final NavigationManager.Event.OnLocationForward event) { FragmentManager fm = getChildFragmentManager(); MvcActivity activity = ((MvcActivity) getActivity()); Class<? extends MvcFragment> fragmentClass = null; try { fragmentClass = activity.mapFragmentRouting( (Class<? extends FragmentController>) Class.forName(event.getCurrentValue().getLocationId())); } catch (ClassNotFoundException e) { e.printStackTrace(); } if (fragmentClass == null) { throw new RuntimeException("Cannot find fragment class mapped in MvcActivity.mapFragmentRouting(location) for location: " + event.getCurrentValue().getLocationId()); } else { MvcFragment lastFragment = null; if (event.getLastValue() != null && event.getLastValue().getLocationId() != null) { lastFragment = (MvcFragment) fm.findFragmentByTag( getFragmentTag(event.getLastValue().getLocationId())); } final MvcFragment currentFragment; try { currentFragment = (MvcFragment) new ReflectUtils.newObjectByType(fragmentClass).newInstance(); } catch (Exception e) { throw new RuntimeException("Failed to instantiate fragment: " + fragmentClass.getName(), e); } if (event.isClearHistory()) { NavLocation clearTopToLocation = event.getLocationWhereHistoryClearedUpTo(); String tagPopTo = clearTopToLocation == null ? null : getFragmentTag(clearTopToLocation.getLocationId()); //clear back stack fragments if (tagPopTo == null) { //Clear all, must use flag FragmentManager.POP_BACK_STACK_INCLUSIVE fm.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); } else { //Clear to specific fragment. Use 0 to leave the given fragment in the stack as //the last one. fm.popBackStack(tagPopTo, 0); } logger.trace("Cleared fragment back stack up to {}", tagPopTo); } final FragmentTransaction transaction = fm.beginTransaction(); currentFragment.registerOnViewReadyListener(new Runnable() { @Override public void run() { destroyNavigator(event.getNavigator()); logger.trace("Fragment ready: " + currentFragment.getClass().getSimpleName()); currentFragment.unregisterOnViewReadyListener(this); } }); String fragmentTag = getFragmentTag(event.getCurrentValue().getLocationId()); transaction.replace(getContentLayoutResId(), currentFragment, fragmentTag); transaction.addToBackStack(fragmentTag); traverseFragmentAndSubFragments(lastFragment, new FragmentManipulator() { @Override public void manipulate(Fragment fragment) { if (fragment != null && fragment instanceof MvcFragment) { ((MvcFragment) fragment).onPushToBackStack(); } } }); if (lastFragment != null) { //Invoke OnPreTransactionCommit for fragment and its child fragments recursively traverseFragmentAndSubFragments(lastFragment, new FragmentManipulator() { @Override public void manipulate(Fragment fragment) { if (fragment != null && fragment instanceof MvcFragment) { ((MvcFragment) fragment).onPreNavigationTransaction(transaction, currentFragment); } } }); } transaction.commit(); } } /** * Handle the backward navigation event call back * * @param event The backward navigation event */ private void handleBackNavigation(final NavigationManager.Event.OnLocationBack event) { if (!canCommitFragmentTransaction) { pendingNavActions.add(new Runnable() { @Override public void run() { performBackNav(event); } }); } else { performBackNav(event); } } private void performBackNav(final NavigationManager.Event.OnLocationBack event) { FragmentManager fm = getChildFragmentManager(); NavLocation lastLoc = event.getLastValue(); if (lastLoc != null) { String lastFragTag = getFragmentTag(lastLoc.getLocationId()); final MvcFragment lastFrag = (MvcFragment) fm.findFragmentByTag(lastFragTag); if (lastFrag != null) { lastFrag.onPopAway(); } } NavLocation currentLoc = event.getCurrentValue(); if (currentLoc == null) { MvcActivity act = (MvcActivity) getActivity(); act.actionsOnDestroy.add(new Runnable() { @Override public void run() { destroyNavigator(event.getNavigator()); pendingNavActions.remove(this); } }); MvcActivity mvcActivity = ((MvcActivity) getActivity()); //Back to null which should finish the current activity mvcActivity.performSuperBackKeyPressed(); mvcActivity.toPrintAppExitMessage = true; } else { String currentFragTag = getFragmentTag(currentLoc.getLocationId()); final MvcFragment currentFrag = (MvcFragment) fm.findFragmentByTag(currentFragTag); if (currentFrag != null) { traverseFragmentAndSubFragments(currentFrag, new FragmentManipulator() { @Override public void manipulate(Fragment fragment) { if (fragment != null && fragment instanceof MvcFragment) { final MvcFragment frag = ((MvcFragment) fragment); frag.aboutToPopOut = true; frag.registerOnViewReadyListener(new Runnable() { @Override public void run() { destroyNavigator(event.getNavigator()); frag.unregisterOnViewReadyListener(this); } }); } } }); } if (event.isFastRewind()) { if (currentLoc.getPreviousLocation() == null) { if (fm.getBackStackEntryCount() <= 1) { //Has reached bottom. Does nothing in this case return; } //Pop fragments to the last int stackCount = fm.getBackStackEntryCount(); int timesNeedToPop = 0; for (int i = 0; i < stackCount; i++) { if (currentFragTag.equals(fm.getBackStackEntryAt(i).getName())) { timesNeedToPop++; } } if (timesNeedToPop > 1) { for (int i = 0; i < stackCount - 1; i++) { fm.popBackStack(); } fm.executePendingTransactions(); } else { fm.popBackStack(currentFragTag, 0); } logger.trace("Navigation back: Fast rewind to home location {}", currentLoc.getLocationId()); } else { String tag = getFragmentTag(currentLoc.getLocationId()); fm.popBackStack(tag, 0); logger.trace("Navigation back: Fast rewind to given location {}", currentLoc.getLocationId()); } } else { fm.popBackStack(); logger.trace("Navigation back: On step back from {} to location {}", event.getLastValue() != null ? event.getLastValue().getLocationId() : null, currentLoc.getLocationId()); } } } private static void destroyNavigator(Navigator navigator) { if (navigator != null) { navigator.destroy(); } } } }