Fragment Management

The purpose of this sample is two-fold: to demonstrate switching between one Fragment when in portrait orientation and two Fragments when in landscape orientation and to demonstrate use of Fragment.setRetainInstance() to retain map layer objects when orientation is changed.

It displays a list of basemaps, then displays a map containing the selected basemap plus 3 feature layers. In portrait orientation the map takes up the whole screen and you must press the Back key or the Home button on the action bar to return to the list. But in landscape orientation the list and the map are displayed side by side. The MapView has to be recreated when orientation changes, but the objects containing the map layers are retained so data does not need to be fetched from the network again.

Sample Design

FragmentManagementActivity

This sample has one activity, FragmentManagementActivity, which is responsible for creating and managing two fragments, one to display the list of basemaps and the other to display the map.

It sets the content view to R.layout.main. When the device is in portrait orientation the single-frame layout in layout/main.xml is used, but in landscape orientation the two-frame layout in layout-land/main.xml is used instead.

The activity onCreate() method determines how many panes to use by trying to find a View called map_fragment_container_twopane which is present only in the two-pane layout.

In single-pane mode there is just one container view, called main_fragment_container. The activity initially places a list fragment in it, then replaces it by a map fragment when a basemap is selected. This is replaced by the list fragment again when the map is dismissed, but the map fragment is retained for future use.

In two-pane mode there are 2 container views: main_fragment_container for the list fragment and map_fragment_container_twopane for the map fragment.

When orientation changes, the activity is destroyed and onCreate() is called again. It removes/detaches the current fragment(s) and replaces them in container(s) as appropriate for the new orientation.

The activity acts upon the following UI events:

  • Selection of a basemap from the list is handled by onBasemapSelected(). It is passed on to the map fragment and the map fragment is displayed if it's not already on screen.
  • A press on the Home button in the action bar is handled by onOptionsItemSelected(). This causes return from map fragment to list fragment when in single-pane mode.
  • A press on the Back key is handled by onBackPressed(). This causes return from map fragment to list fragment when in single-pane mode, or finishes the activity if the list fragment was already displayed.

BasemapListFragment

BasemapListFragment extends ListFragment and presents a list of basemaps. The host activity passes in an integer argument being the currently selected position in the list. This is used to highlight the current basemap when in two-pane mode. The activity must also implement a BasemapListListener interface consisting of an onBasemapSelected() method that is called when a basemap is selected.

MapFragment

MapFragment extends Fragment and displays map layers in a MapView. The host activity passes in a String argument being the name of the basemap to use.

The onCreate() method calls setRetainInstance(true) which causes the fragment instance to be retained when the Activity is destroyed and recreated. This allows map Layer objects to be retained when the orientation changes.

The onCreateView() method always creates a new MapView object, because any old MapView would be tied to an old Activity that has been destroyed, but it reuses existing Layer objects (if any) so the data they contain will not need to be fetched from the network again.

The destroyView() method releases the MapView resources by calling MapView.recycle(), but first it must remove all the layers or they too will be released by recycle().

A public method changeBasemap() is used by the activity to update the basemap when a new one is selected from the list when in two-pane mode.

Sample Code

package com.esri.arcgis.android.samples.fragmanagement;

import android.app.Activity;
import android.app.FragmentManager;
import android.os.Bundle;
import android.view.MenuItem;

import com.esri.arcgis.android.samples.fragmanagement.BasemapListFragment.BasemapListListener;

/**
 * The purpose of this sample is two-fold: to demonstrate switching between one Fragment when in portrait orientation
 * and two Fragments when in landscape orientation and to demonstrate use of Fragment.setRetainInstance() to retain map
 * layer objects when orientation is changed.
 * <p>
 * It displays a list of basemaps, then displays a map containing the selected basemap plus 3 feature layers. In
 * portrait orientation the map takes up the whole screen and you must press the Back key or the Home button on the
 * action bar to return to the list. But in landscape orientation the list and the map are displayed side by side. The
 * MapView has to be recreated when orientation changes, but the objects containing the map layers are retained so data
 * does not need to be fetched from the network again.
 */
public class FragmentManagementActivity extends Activity implements BasemapListListener {

  private static final String TAG_LIST_FRAGMENT = "BasemapListFragment";

  private static final String TAG_MAP_FRAGMENT = "MapFragment";

  private static final String KEY_NUM_PANES = "NumPanes";

  private static final String KEY_ONLY_THE_MAP = "OnlyTheMap";

  private static final String KEY_ACTIVATED_POSITION = "ActivatedPosition";

  private boolean mTwoPane;

  private boolean mOnlyTheMapIsDisplayed;

  private BasemapListFragment mListFragment;

  private MapFragment mMapFragment;

  private int mActivatedPosition = 0;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Set content view and setup action bar
    setContentView(R.layout.main);
    FragmentManager fragMgr = getFragmentManager();
    getActionBar().setDisplayHomeAsUpEnabled(false);
    getActionBar().setHomeButtonEnabled(false);

    // Reinstate saved instance state (if any)
    int numPanes = 0;
    if (savedInstanceState != null) {
      numPanes = savedInstanceState.getInt(KEY_NUM_PANES);
      mOnlyTheMapIsDisplayed = savedInstanceState.getBoolean(KEY_ONLY_THE_MAP);
      mActivatedPosition = savedInstanceState.getInt(KEY_ACTIVATED_POSITION);
    }

    // Find existing fragments (if any)
    mMapFragment = (MapFragment) fragMgr.findFragmentByTag(TAG_MAP_FRAGMENT);
    mListFragment = (BasemapListFragment) fragMgr.findFragmentByTag(TAG_LIST_FRAGMENT);

    // Check how many panes we have
    if (findViewById(R.id.map_fragment_container_twopane) != null) {
      // We have 2 panes - list on left and map on right
      mTwoPane = true;
      mOnlyTheMapIsDisplayed = false;

      // The system will display the fragments for us if numPanes indicates the activity is being recreated and there
      // were 2 panes beforehand
      if (numPanes != 2) {
        // Display list fragment in one pane
        displayListFragment();

        if (mMapFragment == null) {
          // There's no existing map fragment, so create one
          createMapFragment(MapFragment.BASEMAP_NAME_STREETS);
        } else {
          // There's an existing map fragment - need to remove it from main_fragment_container before we can add it to
          // map_fragment_container_twopane
          fragMgr.beginTransaction().remove(mMapFragment).commit();
          fragMgr.executePendingTransactions();
        }
        // Display map fragment in map_fragment_container_twopane
        fragMgr.beginTransaction().add(R.id.map_fragment_container_twopane, mMapFragment, TAG_MAP_FRAGMENT).commit();
      }
    } else {
      // We have just one pane
      mTwoPane = false;
      switch (numPanes) {
        case 0:
          // It's a fresh start - just display the list fragment
          displayListFragment();
          break;
        case 2:
          // The activity is being recreated and there were 2 panes beforehand.
          // If there's an existing map fragment, move it from map_fragment_container_twopane to main_fragment_container
          if (mMapFragment != null) {
            // Need to remove it from its previous container before we can add it to a different container
            fragMgr.beginTransaction().remove(mMapFragment).commit();
            fragMgr.executePendingTransactions();
            fragMgr.beginTransaction().replace(R.id.main_fragment_container, mMapFragment, TAG_MAP_FRAGMENT).commit();
            mOnlyTheMapIsDisplayed = true;
            getActionBar().setDisplayHomeAsUpEnabled(true);
          }
          break;
        default:
          // The activity is being recreated and there was just 1 pane beforehand. The system displays the appropriate
          // fragment for us
      }
    }
  }

  /**
   * Callback method from {@link BasemapListFragment.BasemapListListener} indicating that the basemap with the given
   * position and ID was selected from the list.
   */
  @Override
  public void onBasemapSelected(int position, String id) {
    mActivatedPosition = position;
    FragmentManager fragMgr = getFragmentManager();
    boolean newFragment = false;

    // Create new map fragment or pass ID of selected basemap to existing fragment
    if (mMapFragment == null) {
      createMapFragment(id);
      newFragment = true;
    } else {
      mMapFragment.changeBasemap(id);
    }

    if (mTwoPane) {
      // Two-pane mode - if new map fragment created, display it in map_fragment_container_twopane
      if (newFragment) {
        fragMgr.beginTransaction().replace(R.id.map_fragment_container_twopane, mMapFragment, TAG_MAP_FRAGMENT)
            .commit();
      }
    } else {
      // Single-pane mode - replace the list fragment in main_fragment_container by the map fragment
      fragMgr.beginTransaction().replace(R.id.main_fragment_container, mMapFragment, TAG_MAP_FRAGMENT).commit();
      mOnlyTheMapIsDisplayed = true;
      getActionBar().setDisplayHomeAsUpEnabled(true);
    }

    // If map fragment was previously detached from the UI, in displayListFragment(), need to attach it again
    if (mMapFragment.isDetached()) {
      fragMgr.beginTransaction().attach(mMapFragment).commit();
    }
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
      case android.R.id.home:
        // This ID represents the Home or Up button. In the case of this activity,
        // the Home button is shown only when we're in single-pane mode and the map
        // fragment is displayed.
        // Disable Home button and display list fragment in place of map fragment.
        getActionBar().setHomeButtonEnabled(false);
        getActionBar().setDisplayHomeAsUpEnabled(false);
        displayListFragment();
        return true;
    }
    return super.onOptionsItemSelected(item);
  }

  @Override
  public void onBackPressed() {
    getActionBar().setHomeButtonEnabled(false);
    getActionBar().setDisplayHomeAsUpEnabled(false);

    if (mOnlyTheMapIsDisplayed) {
      // Single-pane mode and map fragment displayed - Back returns us to list fragment
      displayListFragment();
    } else {
      // Otherwise Back finishes the activity
      finish();
    }
  }

  @Override
  protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    int numPanes = 1;
    if (mTwoPane) {
      numPanes = 2;
    }
    outState.putInt(KEY_NUM_PANES, numPanes);
    outState.putBoolean(KEY_ONLY_THE_MAP, mOnlyTheMapIsDisplayed);
    outState.putInt(KEY_ACTIVATED_POSITION, mActivatedPosition);
  }

  @Override
  public void onResume() {
    super.onResume();
    // If in two-pane mode, tell list fragment to highlight the currently selected item
    if (mTwoPane && mListFragment != null) {
      mListFragment.setActivateOnItemClick(true);
    }
  }

  /**
   * Displays the list fragment.
   */
  private void displayListFragment() {
    FragmentManager fragMgr = getFragmentManager();

    // Create the list fragment only if it's not created yet (platform recreates fragments after they're destroyed)
    if (mListFragment == null) {
      Bundle arguments = new Bundle();
      arguments.putInt(BasemapListFragment.ARG_ACTIVATED_POSITION, mActivatedPosition);
      mListFragment = new BasemapListFragment();
      mListFragment.setArguments(arguments);
    }

    // If there's a map fragment, detach it now to ensure it's not lost when the list fragment is displayed below
    if (mMapFragment != null) {
      fragMgr.beginTransaction().detach(mMapFragment).commit();
      fragMgr.executePendingTransactions();
    }

    fragMgr.beginTransaction().replace(R.id.main_fragment_container, mListFragment, TAG_LIST_FRAGMENT).commit();
    mOnlyTheMapIsDisplayed = false;
  }

  /**
   * Creates a new map fragment.
   * 
   * @param id String identifier of basemap to display.
   */
  private void createMapFragment(String id) {
    Bundle arguments = new Bundle();
    arguments.putString(MapFragment.ARG_BASEMAP_ID, id);
    mMapFragment = new MapFragment();
    mMapFragment.setArguments(arguments);
  }

}
package com.esri.arcgis.android.samples.fragmanagement;

import android.app.Fragment;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import com.esri.android.map.MapView;
import com.esri.android.map.ags.ArcGISFeatureLayer;
import com.esri.android.map.ags.ArcGISTiledMapServiceLayer;
import com.esri.core.geometry.Envelope;
import com.esri.core.geometry.GeometryEngine;
import com.esri.core.geometry.Point;
import com.esri.core.geometry.SpatialReference;

/**
 * A fragment representing a map view. Displays a basemap layer and a number of feature layers in a MapView.
 */
public class MapFragment extends Fragment {
  /** Fragment argument representing a String ID of the currently selected basemap */
  public static final String ARG_BASEMAP_ID = "BasemapId";

  public static final String BASEMAP_NAME_STREETS = "Streets";

  public static final String BASEMAP_NAME_TOPO = "Topographic";

  public static final String BASEMAP_NAME_GRAY = "Gray";

  public static final String BASEMAP_NAME_OCEANS = "Oceans";

  private final String KEY_MAP_STATE = "MapState";

  private String mBasemapName;

  private MapView mMapView = null;

  private ArcGISTiledMapServiceLayer mBasemapLayer;

  private ArcGISFeatureLayer mFeatureLayer0 = null;

  private ArcGISFeatureLayer mFeatureLayer1 = null;

  private ArcGISFeatureLayer mFeatureLayer2 = null;

  private String mMapState = null;

  /**
   * Mandatory empty constructor for the fragment manager to instantiate the fragment (e.g. upon screen orientation
   * changes).
   */
  public MapFragment() {
  }

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Calling setRetainInstance() causes the Fragment instance to be retained when its Activity is destroyed and
    // recreated. This allows map Layer objects to be retained so data will not need to be fetched from the network
    // again.
    setRetainInstance(true);

    // Retrieve arguments
    if (mBasemapName == null && getArguments().containsKey(ARG_BASEMAP_ID)) {
      mBasemapName = getArguments().getString(ARG_BASEMAP_ID);
    }
  }

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {

    // Reinstate saved instance state (if any)
    if (savedInstanceState != null) {
      mMapState = savedInstanceState.getString(KEY_MAP_STATE, null);
    }

    // Create new MapView object. Note that, unlike Layers objects, the MapView can't be retained when the Activity is
    // destroyed and recreated, because the old MapView is tied to the old Activity.
    mMapView = new MapView(getActivity());

    // Restore map state (center and resolution) if a previously saved state is available, otherwise set initial extent
    if (mMapState == null) {
      SpatialReference mSR = SpatialReference.create(3857);
      Point p1 = GeometryEngine.project(-120.0, 0.0, mSR);
      Point p2 = GeometryEngine.project(-60.0, 50.0, mSR);
      Envelope mInitExtent = new Envelope(p1.getX(), p1.getY(), p2.getX(), p2.getY());
      mMapView.setExtent(mInitExtent);
    } else {
      mMapView.restoreState(mMapState);
    }

    // Create layers unless retained objects are available
    if (mBasemapLayer == null) {
      mBasemapLayer = createBasemapLayer(mBasemapName);
    }

    if (mFeatureLayer0 == null) {
      mFeatureLayer0 = new ArcGISFeatureLayer(
          "http://sampleserver5.arcgisonline.com/ArcGIS/rest/services/LocalGovernment/Recreation/FeatureServer/0",
          ArcGISFeatureLayer.MODE.ONDEMAND);
    }

    if (mFeatureLayer1 == null) {
      mFeatureLayer1 = new ArcGISFeatureLayer(
          "http://sampleserver5.arcgisonline.com/ArcGIS/rest/services/LocalGovernment/Recreation/FeatureServer/1",
          ArcGISFeatureLayer.MODE.ONDEMAND);
    }

    if (mFeatureLayer2 == null) {
      mFeatureLayer2 = new ArcGISFeatureLayer(
          "http://sampleserver5.arcgisonline.com/ArcGIS/rest/services/LocalGovernment/Recreation/FeatureServer/2",
          ArcGISFeatureLayer.MODE.ONDEMAND);
    }

    // Add layers to MapView
    mMapView.addLayer(mBasemapLayer);
    mMapView.addLayer(mFeatureLayer0);
    mMapView.addLayer(mFeatureLayer1);
    mMapView.addLayer(mFeatureLayer2);
    return mMapView;
  }

  @Override
  public void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);

    // Save the map state (map center and resolution).
    if (mMapState != null) {
      outState.putString(KEY_MAP_STATE, mMapState);
    }
  }

  @Override
  public void onPause() {
    super.onPause();

    // Save map state and pause the MapView to save battery
    mMapState = mMapView.retainState();
    mMapView.pause();
  }

  @Override
  public void onResume() {
    super.onResume();

    // Start the MapView threads running again
    mMapView.unpause();
  }

  @Override
  public void onDestroyView() {
    super.onDestroyView();

    // Must remove our layers from MapView before calling recycle(), or we won't be able to reuse them
    mMapView.removeLayer(mBasemapLayer);
    mMapView.removeLayer(mFeatureLayer0);
    mMapView.removeLayer(mFeatureLayer1);
    mMapView.removeLayer(mFeatureLayer2);

    // Release MapView resources
    mMapView.recycle();
    mMapView = null;
  }

  /**
   * Changes the basemap.
   * 
   * @param basemapName String ID of the basemap to use.
   */
  public void changeBasemap(String basemapName) {
    mBasemapName = basemapName;
    if (mMapView == null) {
      mBasemapLayer = null;
    } else {
      // Remove old basemap layer and add a new one as the first layer to be drawn
      mMapView.removeLayer(mBasemapLayer);
      mBasemapLayer = createBasemapLayer(mBasemapName);
      mMapView.addLayer(mBasemapLayer, 0);
    }
  }

  /**
   * Creates a basemap layer.
   * 
   * @param basemapName String ID of the basemap to use.
   * @return ArcGISTiledMapServiceLayer for the requested basemap.
   */
  private ArcGISTiledMapServiceLayer createBasemapLayer(String basemapName) {
    String url = "http://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer";
    if (basemapName.equalsIgnoreCase(BASEMAP_NAME_STREETS)) {
      url = "http://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer";
    } else if (basemapName.equalsIgnoreCase(BASEMAP_NAME_GRAY)) {
      url = "http://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer";
    } else if (basemapName.equalsIgnoreCase(BASEMAP_NAME_OCEANS)) {
      url = "http://services.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Base/MapServer";
    }
    return new ArcGISTiledMapServiceLayer(url);
  }

}
package com.esri.arcgis.android.samples.fragmanagement;

import android.app.Activity;
import android.app.ListFragment;
import android.os.Bundle;
import android.view.View;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;

/**
 * A fragment representing a list of basemaps. This fragment also supports a two-pane view by allowing list items to be
 * given an 'activated' state upon selection. This helps indicate which item is currently being viewed in a
 * {@link MapFragment}.
 * <p>
 * Activities containing this fragment MUST implement the {@link BasemapListListener} interface.
 */
public class BasemapListFragment extends ListFragment {

  /** Fragment argument representing currently selected position in the list */
  public static final String ARG_ACTIVATED_POSITION = "ActivatedPosition";

  private static final String KEY_ACTIVATED_POSITION = "ActivatedPosition";

  private BasemapListListener mBasemapListListener = sDummyListener;

  private int mActivatedPosition = AdapterView.INVALID_POSITION;

  /**
   * A callback interface that all activities containing this fragment must implement. This mechanism allows activities
   * to be notified of basemap selections.
   */
  public interface BasemapListListener {
    /**
     * Callback for when a basemap has been selected.
     * 
     * @param position Position of selected basemap in list.
     * @param id String identifier of selected basemap.
     */
    public void onBasemapSelected(int position, String id);
  }

  /**
   * A dummy implementation of the {@link BasemapListListener} interface that does nothing. Used only when this fragment
   * is not attached to an activity.
   */
  private static BasemapListListener sDummyListener = new BasemapListListener() {
    @Override
    public void onBasemapSelected(int position, String id) {
    }
  };

  /**
   * Mandatory empty constructor for the fragment manager to instantiate the fragment (e.g. upon screen orientation
   * changes).
   */
  public BasemapListFragment() {
  }

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    setRetainInstance(true);

    // Retrieve arguments
    if (mActivatedPosition == AdapterView.INVALID_POSITION && getArguments().containsKey(ARG_ACTIVATED_POSITION)) {
      mActivatedPosition = getArguments().getInt(ARG_ACTIVATED_POSITION);
    }

    // Setup list adapter
    ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(getActivity(), R.array.map_types,
        android.R.layout.simple_list_item_activated_1);
    setListAdapter(adapter);
  }

  @Override
  public void onViewCreated(View view, Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    // Reinstate saved instance state (if any)
    if (savedInstanceState != null && savedInstanceState.containsKey(KEY_ACTIVATED_POSITION)) {
      setActivatedPosition(savedInstanceState.getInt(KEY_ACTIVATED_POSITION));
    }

  }

  @Override
  public void onAttach(Activity activity) {
    super.onAttach(activity);

    // Check the host activity implements the mandatory callback listener.
    if (!(activity instanceof BasemapListListener)) {
      throw new IllegalStateException("Activity must implement BasemapListListener");
    }

    mBasemapListListener = (BasemapListListener) activity;
  }

  @Override
  public void onDetach() {
    super.onDetach();

    // Reset the active listener interface to the dummy implementation
    mBasemapListListener = sDummyListener;
  }

  @Override
  public void onListItemClick(ListView listView, View view, int position, long id) {
    super.onListItemClick(listView, view, position, id);

    // Notify the active listener interface (the activity, if the fragment is attached to one) that an item has been
    // selected
    CharSequence text = ((TextView) view).getText();
    mBasemapListListener.onBasemapSelected(position, text.toString());
    mActivatedPosition = position;
  }

  @Override
  public void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    if (mActivatedPosition != AdapterView.INVALID_POSITION) {
      outState.putInt(KEY_ACTIVATED_POSITION, mActivatedPosition);
    }
  }

  /**
   * Turns on activate-on-click mode. When this mode is on, list items will be given the 'activated' state when touched.
   */
  public void setActivateOnItemClick(boolean activateOnItemClick) {
    getListView().setChoiceMode(activateOnItemClick ? AbsListView.CHOICE_MODE_SINGLE : AbsListView.CHOICE_MODE_NONE);
    if (activateOnItemClick && mActivatedPosition != AdapterView.INVALID_POSITION) {
      getListView().setItemChecked(mActivatedPosition, true);
    }
  }

  /**
   * Sets the activated position and highlights it in the list.
   * 
   * @param position The activated position.
   */
  private void setActivatedPosition(int position) {
    if (position == AdapterView.INVALID_POSITION) {
      getListView().setItemChecked(mActivatedPosition, false);
    } else {
      getListView().setItemChecked(position, true);
    }
    mActivatedPosition = position;
  }
}
Feedback on this topic?