/* * Copyright 2014 Google Inc. All rights reserved. * * 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.google.samples.apps.iosched.map; import android.app.Activity; import android.app.LoaderManager; import android.app.LoaderManager.LoaderCallbacks; import android.content.Loader; import android.database.ContentObserver; import android.database.Cursor; import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.google.android.gms.maps.CameraUpdate; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.UiSettings; import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.CameraPosition; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.TileOverlay; import com.google.android.gms.maps.model.TileOverlayOptions; import com.google.samples.apps.iosched.BuildConfig; import com.google.samples.apps.iosched.R; import com.google.samples.apps.iosched.map.util.CachedTileProvider; import com.google.samples.apps.iosched.map.util.MarkerLoadingTask; import com.google.samples.apps.iosched.map.util.MarkerModel; import com.google.samples.apps.iosched.map.util.TileLoadingTask; import com.google.samples.apps.iosched.provider.ScheduleContract; import com.google.samples.apps.iosched.util.AnalyticsHelper; import com.google.samples.apps.iosched.util.MapUtils; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import static com.google.samples.apps.iosched.util.LogUtils.LOGD; import static com.google.samples.apps.iosched.util.LogUtils.makeLogTag; /** * Shows a map of the conference venue. */ public class MapFragment extends com.google.android.gms.maps.MapFragment implements GoogleMap.OnMarkerClickListener, GoogleMap.OnMapClickListener, OnMapReadyCallback, GoogleMap.OnCameraChangeListener { /** * Extras parameter for highlighting a specific room when the map is loaded. */ private static final String EXTRAS_HIGHLIGHT_ROOM = "EXTRAS_HIGHLIGHT_ROOM"; /** * Extras parameter for displaying a specific floor when the map is loaded. */ private static final String EXTRAS_ACTIVE_FLOOR = "EXTRAS_ACTIVE_FLOOR"; /** * Area covered by the venue. Determines if the venue is currently visible on screen. */ private static final LatLngBounds VENUE_AREA = new LatLngBounds(BuildConfig.MAP_AREA_NW, BuildConfig.MAP_AREA_SE); /** * Default position of the camera that shows the venue. */ private static final CameraPosition VENUE_CAMERA = new CameraPosition.Builder().bearing(BuildConfig.MAP_DEFAULTCAMERA_BEARING) .target(BuildConfig.MAP_DEFAULTCAMERA_TARGET) .zoom(BuildConfig.MAP_DEFAULTCAMERA_ZOOM) .tilt(BuildConfig.MAP_DEFAULTCAMERA_TILT) .build(); /** * Value that denotes an invalid floor. */ private static final int INVALID_FLOOR = Integer.MIN_VALUE; /** * Estimated number of floors used to initialise data structures with appropriate capacity. */ private static final int INITIAL_FLOOR_COUNT = 1; /** * Default floor level to display. In the current implementation there is no support to switch * floor levels, so this is always set to 0. */ private static final int VENUE_DEFAULT_LEVEL_INDEX = 0; private static final String TAG = makeLogTag(MapFragment.class); private boolean mMyLocationEnabled = false; // Tile Providers private SparseArray<CachedTileProvider> mTileProviders = new SparseArray<>(INITIAL_FLOOR_COUNT); private SparseArray<TileOverlay> mTileOverlays = new SparseArray<>(INITIAL_FLOOR_COUNT); // Markers stored by id protected HashMap<String, MarkerModel> mMarkers = new HashMap<>(); // Markers stored by floor protected SparseArray<ArrayList<Marker>> mMarkersFloor = new SparseArray<>(INITIAL_FLOOR_COUNT); // Screen DPI private float mDPI = 0; // currently displayed floor private int mFloor = INVALID_FLOOR; /** * Indicates if the venue is active and its markers and floor plan is being displayed. Set to * false by default, as the venue marker is shown first. */ private boolean mVenueIsActive = false; private Marker mActiveMarker = null; private BitmapDescriptor ICON_ACTIVE; private BitmapDescriptor ICON_NORMAL; private Marker mVenueMaker = null; protected GoogleMap mMap; private Rect mMapInsets = new Rect(); private String mHighlightedRoomId = null; private MarkerModel mHighlightedRoom = null; private int mInitialFloor = VENUE_DEFAULT_LEVEL_INDEX; private static final int TOKEN_LOADER_MARKERS = 0x1; private static final int TOKEN_LOADER_TILES = 0x2; //For Analytics tracking public static final String SCREEN_LABEL = "Map"; public interface Callbacks { void onInfoHide(); void onInfoShowVenue(); void onInfoShowTitle(String label, int roomType); void onInfoShowSessionlist(String roomId, String roomTitle, int roomType); void onInfoShowFirstSessionTitle(String roomId, String roomTitle, int roomType); } private static Callbacks sDummyCallbacks = new Callbacks() { @Override public void onInfoHide() { } @Override public void onInfoShowVenue() { } @Override public void onInfoShowTitle(String label, int roomType) { } @Override public void onInfoShowSessionlist(String roomId, String roomTitle, int roomType) { } @Override public void onInfoShowFirstSessionTitle(String roomId, String roomTitle, int roomType) { } }; private Callbacks mCallbacks = sDummyCallbacks; public static MapFragment newInstance() { return new MapFragment(); } public static MapFragment newInstance(String highlightedRoomId) { MapFragment fragment = new MapFragment(); Bundle arguments = new Bundle(); arguments.putString(EXTRAS_HIGHLIGHT_ROOM, highlightedRoomId); fragment.setArguments(arguments); return fragment; } public static MapFragment newInstance(Bundle savedState) { MapFragment fragment = new MapFragment(); fragment.setArguments(savedState); return fragment; } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mActiveMarker != null) { // A marker is currently selected, restore its selection. outState.putString(EXTRAS_HIGHLIGHT_ROOM, mActiveMarker.getTitle()); outState.putInt(EXTRAS_ACTIVE_FLOOR, INVALID_FLOOR); } else { // No marker is selected, store the active floor if at venue. outState.putInt(EXTRAS_ACTIVE_FLOOR, mFloor); outState.putString(EXTRAS_HIGHLIGHT_ROOM, null); } LOGD(TAG, "Saved state: " + outState); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // ANALYTICS SCREEN: View the Map screen // Contains: Nothing (Page name is a constant) AnalyticsHelper.sendScreenView(SCREEN_LABEL); // get DPI mDPI = getActivity().getResources().getDisplayMetrics().densityDpi / 160f; // Get the arguments and restore the highlighted room or displayed floor. Bundle data = getArguments(); if (data != null) { mHighlightedRoomId = data.getString(EXTRAS_HIGHLIGHT_ROOM, null); mInitialFloor = data.getInt(EXTRAS_ACTIVE_FLOOR, VENUE_DEFAULT_LEVEL_INDEX); } getMapAsync(this); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View mapView = super.onCreateView(inflater, container, savedInstanceState); setMapInsets(mMapInsets); return mapView; } public void setMapInsets(int left, int top, int right, int bottom) { mMapInsets.set(left, top, right, bottom); if (mMap != null) { mMap.setPadding(mMapInsets.left, mMapInsets.top, mMapInsets.right, mMapInsets.bottom); } } public void setMapInsets(Rect insets) { mMapInsets.set(insets.left, insets.top, insets.right, insets.bottom); if (mMap != null) { mMap.setPadding(mMapInsets.left, mMapInsets.top, mMapInsets.right, mMapInsets.bottom); } } /** * Toggles the 'my location' button. Note that the location permission <b>must</b> have already * been granted when this call is made. * * @param setEnabled */ public void setMyLocationEnabled(final boolean setEnabled) { mMyLocationEnabled = setEnabled; if (mMap == null) { return; } //noinspection MissingPermission mMap.setMyLocationEnabled(mMyLocationEnabled); } @Override public void onStop() { super.onStop(); closeTileCache(); } /** * Closes the caches of all allocated tile providers. * * @see CachedTileProvider#closeCache() */ private void closeTileCache() { for (int i = 0; i < mTileProviders.size(); i++) { try { mTileProviders.valueAt(i).closeCache(); } catch (IOException e) { } } } /** * Clears the map and initialises all map variables that hold markers and overlays. */ private void clearMap() { if (mMap != null) { mMap.clear(); } // Close all tile provider caches closeTileCache(); // Clear all map elements mTileProviders.clear(); mTileOverlays.clear(); mMarkers.clear(); mMarkersFloor.clear(); mFloor = INVALID_FLOOR; } @Override public void onMapReady(GoogleMap googleMap) { // Initialise marker icons. ICON_ACTIVE = BitmapDescriptorFactory.fromResource(R.drawable.map_marker_selected); ICON_NORMAL = BitmapDescriptorFactory.fromResource(R.drawable.map_marker_unselected); mMap = googleMap; mMap.setIndoorEnabled(false); mMap.setOnMarkerClickListener(this); mMap.setOnMapClickListener(this); mMap.setOnCameraChangeListener(this); UiSettings mapUiSettings = mMap.getUiSettings(); mapUiSettings.setZoomControlsEnabled(false); mapUiSettings.setMapToolbarEnabled(false); // This state is set via 'setMyLocationLayerEnabled. //noinspection MissingPermission mMap.setMyLocationEnabled(mMyLocationEnabled); addVenueMarker(); // Move camera directly to the venue centerOnVenue(false); loadMapData(); LOGD(TAG, "Map setup complete."); } /** * Loads markers and tiles from the content provider. * * @see #mMarkerLoader * @see #mTileLoader */ private void loadMapData() { // load all markers LoaderManager lm = getLoaderManager(); lm.initLoader(TOKEN_LOADER_MARKERS, null, mMarkerLoader).forceLoad(); // load the tile overlays lm.initLoader(TOKEN_LOADER_TILES, null, mTileLoader).forceLoad(); } private void addVenueMarker() { mVenueMaker = mMap.addMarker( MapUtils.createVenueMarker(BuildConfig.MAP_VENUEMARKER).visible(false)); } @Override public void onAttach(Activity activity) { super.onAttach(activity); if (!(activity instanceof Callbacks)) { throw new ClassCastException( "Activity must implement fragment's callbacks."); } mCallbacks = (Callbacks) activity; activity.getContentResolver().registerContentObserver( ScheduleContract.MapMarkers.CONTENT_URI, true, mObserver); activity.getContentResolver().registerContentObserver( ScheduleContract.MapTiles.CONTENT_URI, true, mObserver); } @Override public void onDetach() { super.onDetach(); mCallbacks = sDummyCallbacks; getActivity().getContentResolver().unregisterContentObserver(mObserver); } @Override public void onCameraChange(final CameraPosition cameraPosition) { boolean isVenueInFocus = cameraPosition.zoom >= (double) BuildConfig.MAP_MAXRENDERED_ZOOM && isVenueVisible(); // Check if the camera is focused on the venue. Trigger a callback if the state has changed. if (isVenueInFocus && !mVenueIsActive) { onFocusVenue(); mVenueIsActive = true; } else if (!isVenueInFocus && mVenueIsActive) { onDefocusVenue(); mVenueIsActive = false; } } /** * Moves the camera to the {@link #VENUE_CAMERA} positon. * * @param animate Animates the camera if true, otherwise it is moved */ private void centerOnVenue(boolean animate) { CameraUpdate camera = CameraUpdateFactory.newCameraPosition(VENUE_CAMERA); if (animate) { mMap.animateCamera(camera); } else { mMap.moveCamera(camera); } } /** * Switches the displayed floor for which elements are displayed. If the map is not initialised * yet or no data has been loaded, nothing will be displayed. If an invalid floor is specified * and elements are currently on the map, all visible elements will be hidden. * * @param floor index of the floor to display. It requires an overlay or least one Marker to be * valid. */ private void showFloorElementsIndex(int floor) { LOGD(TAG, "Show floor " + floor); // Hide previous floor elements if the floor has changed if (mFloor != floor) { setFloorElementsVisible(mFloor, false); } mFloor = floor; if (isValidFloor(mFloor)) { // Always hide the venue marker if a floor is shown mVenueMaker.setVisible(false); setFloorElementsVisible(mFloor, true); } else { // Show venue marker if at an invalid floor mVenueMaker.setVisible(true); } } /** * Change the visibility of all Markers and TileOverlays for a floor. */ private void setFloorElementsVisible(int floor, boolean visible) { // Overlays final TileOverlay overlay = mTileOverlays.get(floor); if (overlay != null) { overlay.setVisible(visible); } // Markers final ArrayList<Marker> markers = mMarkersFloor.get(floor); if (markers != null) { for (Marker m : markers) { m.setVisible(visible); } } } /** * A floor is valid if the venue contains that floor. It is not required for a floor to have a * tile overlay AND markers. */ private boolean isValidFloor(int floor) { return mTileOverlays.get(floor) != null || mMarkersFloor.get(floor) != null; } /** * Display map features if at the venue. This explicitly enables all elements that should be * displayed at the default floor. * * @see #isVenueVisible() */ private void enableMapElements() { if (isVenueVisible()) { showFloorElementsIndex(VENUE_DEFAULT_LEVEL_INDEX); } } private void onDefocusVenue() { // Hide all markers and tile overlays deselectActiveMarker(); showFloorElementsIndex(INVALID_FLOOR); mCallbacks.onInfoShowVenue(); } private void onFocusVenue() { // Highlight a room if argument is set and it exists, otherwise show the default floor if (mHighlightedRoomId != null && mMarkers.containsKey(mHighlightedRoomId)) { highlightRoom(mHighlightedRoomId); onFloorActivated(mHighlightedRoom.floor); // Reset highlighted room because it has just been displayed. mHighlightedRoomId = null; } else { // Hide the bottom sheet that is displaying the venue details at this point mCallbacks.onInfoHide(); // Switch to the default level for the venue and reset its value onFloorActivated(mInitialFloor); } mInitialFloor = VENUE_DEFAULT_LEVEL_INDEX; } public boolean isVenueVisible() { if (mMap == null) { return false; } LatLngBounds visibleBounds = mMap.getProjection().getVisibleRegion().latLngBounds; return MapUtils.boundsIntersect(visibleBounds, VENUE_AREA); } /** * Called when a floor level in the venue building has been activated. If a room is to be * highlighted, the map is centered and its marker is activated. */ private void onFloorActivated(int activeLevelIndex) { if (mHighlightedRoom != null && mFloor == mHighlightedRoom.floor) { // A room highlight is pending. Highlight the marker and display info details. onMarkerClick(mHighlightedRoom.marker); centerMap(mHighlightedRoom.marker.getPosition()); // Remove the highlight room flag, because the room has just been highlighted. mHighlightedRoom = null; mHighlightedRoomId = null; } else if (mFloor != activeLevelIndex) { // Deselect and hide the info details. deselectActiveMarker(); mCallbacks.onInfoHide(); } // Show map elements for this floor showFloorElementsIndex(activeLevelIndex); } @Override public void onMapClick(LatLng latLng) { deselectActiveMarker(); mCallbacks.onInfoHide(); } private void deselectActiveMarker() { if (mActiveMarker != null) { mActiveMarker.setIcon(ICON_NORMAL); mActiveMarker = null; } } private void selectActiveMarker(Marker marker) { if (mActiveMarker == marker) { return; } if (marker != null) { mActiveMarker = marker; mActiveMarker.setIcon(ICON_ACTIVE); } } @Override public boolean onMarkerClick(Marker marker) { final String title = marker.getTitle(); final MarkerModel model = mMarkers.get(title); // Log clicks on all markers (regardless of type) // ANALYTICS EVENT: Click on marker on the map. // Contains: Marker ID (for example room UUID) AnalyticsHelper.sendEvent("Map", "markerclick", title); deselectActiveMarker(); // The venue marker can be compared directly. // For all other markers the model needs to be looked up first. if (marker.equals(mVenueMaker)) { // Return camera to the venue LOGD(TAG, "Clicked on the venue marker, return to initial display."); centerOnVenue(true); } else if (model != null && MapUtils.hasInfoTitleOnly(model.type)) { // Show a basic info window with a title only mCallbacks.onInfoShowTitle(model.label, model.type); selectActiveMarker(marker); } else if (model != null && MapUtils.hasInfoSessionList(model.type)) { // Type has sessions to display mCallbacks.onInfoShowSessionlist(model.id, model.label, model.type); selectActiveMarker(marker); } else if (model != null && MapUtils.hasInfoFirstDescriptionOnly(model.type)) { // Display the description of the first session only mCallbacks.onInfoShowFirstSessionTitle(model.id, model.label, model.type); selectActiveMarker(marker); } else { // Hide the bottom sheet for unknown markers mCallbacks.onInfoHide(); } return true; } private void centerMap(LatLng position) { mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, BuildConfig.MAP_VENUECAMERA_ZOOM)); } private void highlightRoom(String roomId) { MarkerModel m = mMarkers.get(roomId); if (m != null) { mHighlightedRoom = m; showFloorElementsIndex(m.floor); } } private void onMarkersLoaded(List<MarkerLoadingTask.MarkerEntry> list) { if (list != null) { for (MarkerLoadingTask.MarkerEntry entry : list) { // Skip incomplete entries if (entry.options == null || entry.model == null) { break; } // Add marker to the map Marker m = mMap.addMarker(entry.options); MarkerModel model = entry.model; model.marker = m; // Store the marker and its model ArrayList<Marker> markerList = mMarkersFloor.get(model.floor); if (markerList == null) { // Initialise the list of Markers for this floor markerList = new ArrayList<>(); mMarkersFloor.put(model.floor, markerList); } markerList.add(m); mMarkers.put(model.id, model); } } enableMapElements(); } private void onTilesLoaded(List<TileLoadingTask.TileEntry> list) { if (list != null) { // Display tiles if they have been loaded, skip them otherwise but display the rest of // the map. for (TileLoadingTask.TileEntry entry : list) { TileOverlayOptions tileOverlay = new TileOverlayOptions() .tileProvider(entry.provider).visible(false); // Store the tile overlay and provider mTileProviders.put(entry.floor, entry.provider); mTileOverlays.put(entry.floor, mMap.addTileOverlay(tileOverlay)); } } enableMapElements(); } private final ContentObserver mObserver = new ContentObserver(new Handler()) { @Override public void onChange(boolean selfChange) { onChange(selfChange, null); } @Override public void onChange(final boolean selfChange, final Uri uri) { if (!isAdded()) { return; } // Clear the map, but don't reset the camera. clearMap(); addVenueMarker(); // Reload data from loaders. Initialise the loaders first if they are not active yet. LoaderManager lm = getActivity().getLoaderManager(); lm.initLoader(TOKEN_LOADER_MARKERS, null, mMarkerLoader).forceLoad(); lm.initLoader(TOKEN_LOADER_TILES, null, mTileLoader).forceLoad(); } }; /** * LoaderCallbacks for the {@link MarkerLoadingTask} that loads all markers for the map. */ private LoaderCallbacks<List<MarkerLoadingTask.MarkerEntry>> mMarkerLoader = new LoaderCallbacks<List<MarkerLoadingTask.MarkerEntry>>() { @Override public Loader<List<MarkerLoadingTask.MarkerEntry>> onCreateLoader(int id, Bundle args) { return new MarkerLoadingTask(getActivity()); } @Override public void onLoadFinished(Loader<List<MarkerLoadingTask.MarkerEntry>> loader, List<MarkerLoadingTask.MarkerEntry> data) { onMarkersLoaded(data); } @Override public void onLoaderReset(Loader<List<MarkerLoadingTask.MarkerEntry>> loader) { } }; /** * LoaderCallbacks for the {@link TileLoadingTask} that loads all tile overlays for the map. */ private LoaderCallbacks<List<TileLoadingTask.TileEntry>> mTileLoader = new LoaderCallbacks<List<TileLoadingTask.TileEntry>>() { @Override public Loader<List<TileLoadingTask.TileEntry>> onCreateLoader(int id, Bundle args) { return new TileLoadingTask(getActivity(), mDPI); } @Override public void onLoadFinished(Loader<List<TileLoadingTask.TileEntry>> loader, List<TileLoadingTask.TileEntry> data) { onTilesLoaded(data); } @Override public void onLoaderReset(Loader<List<TileLoadingTask.TileEntry>> loader) { } }; }