Estaba buscando una solución a este problema sin suerte, así que tuve que rodar la mía que me gustaría compartir aquí con ustedes. (Disculpe mi mal inglés) (Es un poco loco responder a otro checo en inglés :-))
Lo primero que intenté fue usar un buen viejo PopupWindow
. Es bastante fácil: solo hay que escuchar OnMarkerClickListener
y luego mostrar una costumbre PopupWindow
sobre el marcador. Algunos otros tipos aquí en StackOverflow sugirieron esta solución y en realidad se ve bastante bien a primera vista. Pero el problema con esta solución aparece cuando comienzas a mover el mapa. Tienes que moverlo de PopupWindow
alguna manera, lo cual es posible (escuchando algunos eventos onTouch), pero en mi humilde opinión, no puedes hacer que se vea lo suficientemente bien, especialmente en algunos dispositivos lentos. Si lo hace de la manera simple, "salta" de un lugar a otro. También podría usar algunas animaciones para pulir esos saltos, pero de esta manera PopupWindow
siempre estará "un paso atrás" donde debería estar en el mapa, lo que simplemente no me gusta.
En este punto, estaba pensando en alguna otra solución. Me di cuenta de que en realidad no necesito tanta libertad, para mostrar mis vistas personalizadas con todas las posibilidades que conlleva (como barras de progreso animadas, etc.). Creo que hay una buena razón por la cual incluso los ingenieros de Google no lo hacen de esta manera en la aplicación Google Maps. Todo lo que necesito es un botón o dos en la ventana de información que mostrará un estado presionado y desencadenará algunas acciones al hacer clic. Entonces se me ocurrió otra solución que se divide en dos partes:
Primera parte:
la primera parte es poder capturar los clics en los botones para activar alguna acción. Mi idea es la siguiente:
- Mantenga una referencia a la infoWindow personalizada creada en el InfoWindowAdapter.
- Envuelva el
MapFragment
(o MapView
) dentro de un ViewGroup personalizado (el mío se llama MapWrapperLayout)
- Anule el
MapWrapperLayout
'dispatchTouchEvent' y (si la ventana de información se muestra actualmente) primero enrute los eventos de movimiento a la ventana de información creada anteriormente. Si no consume los MotionEvents (como porque no hizo clic en ningún área en la que se pueda hacer clic dentro de InfoWindow, etc.), entonces (y solo entonces) deje que los eventos pasen a la superclase de MapWrapperLayout para que finalmente se entregue al mapa.
Aquí está el código fuente de MapWrapperLayout:
package com.circlegate.tt.cg.an.lib.map;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.model.Marker;
import android.content.Context;
import android.graphics.Point;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.RelativeLayout;
public class MapWrapperLayout extends RelativeLayout {
/**
* Reference to a GoogleMap object
*/
private GoogleMap map;
/**
* Vertical offset in pixels between the bottom edge of our InfoWindow
* and the marker position (by default it's bottom edge too).
* It's a good idea to use custom markers and also the InfoWindow frame,
* because we probably can't rely on the sizes of the default marker and frame.
*/
private int bottomOffsetPixels;
/**
* A currently selected marker
*/
private Marker marker;
/**
* Our custom view which is returned from either the InfoWindowAdapter.getInfoContents
* or InfoWindowAdapter.getInfoWindow
*/
private View infoWindow;
public MapWrapperLayout(Context context) {
super(context);
}
public MapWrapperLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MapWrapperLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
/**
* Must be called before we can route the touch events
*/
public void init(GoogleMap map, int bottomOffsetPixels) {
this.map = map;
this.bottomOffsetPixels = bottomOffsetPixels;
}
/**
* Best to be called from either the InfoWindowAdapter.getInfoContents
* or InfoWindowAdapter.getInfoWindow.
*/
public void setMarkerWithInfoWindow(Marker marker, View infoWindow) {
this.marker = marker;
this.infoWindow = infoWindow;
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean ret = false;
// Make sure that the infoWindow is shown and we have all the needed references
if (marker != null && marker.isInfoWindowShown() && map != null && infoWindow != null) {
// Get a marker position on the screen
Point point = map.getProjection().toScreenLocation(marker.getPosition());
// Make a copy of the MotionEvent and adjust it's location
// so it is relative to the infoWindow left top corner
MotionEvent copyEv = MotionEvent.obtain(ev);
copyEv.offsetLocation(
-point.x + (infoWindow.getWidth() / 2),
-point.y + infoWindow.getHeight() + bottomOffsetPixels);
// Dispatch the adjusted MotionEvent to the infoWindow
ret = infoWindow.dispatchTouchEvent(copyEv);
}
// If the infoWindow consumed the touch event, then just return true.
// Otherwise pass this event to the super class and return it's result
return ret || super.dispatchTouchEvent(ev);
}
}
Todo esto hará que las vistas dentro de InfoView estén "en vivo" nuevamente: los OnClickListeners comenzarán a activarse, etc.
Segunda parte:
el problema restante es que, obviamente, no puede ver ningún cambio en la interfaz de usuario de su InfoWindow en la pantalla. Para hacerlo, debe llamar manualmente a Marker.showInfoWindow. Ahora, si realiza algún cambio permanente en su InfoWindow (como cambiar la etiqueta de su botón a otra cosa), esto es lo suficientemente bueno.
Pero mostrar un estado de botón presionado o algo de esa naturaleza es más complicado. El primer problema es que (al menos) no pude hacer que la ventana de información muestre el estado presionado del botón normal. Incluso si presioné el botón durante mucho tiempo, simplemente no se presionó en la pantalla. Creo que esto es algo que es manejado por el marco del mapa en sí mismo que probablemente se asegura de no mostrar ningún estado transitorio en las ventanas de información. Pero podría estar equivocado, no intenté descubrir esto.
Lo que hice fue otro truco desagradable: adjunté un OnTouchListener
botón al botón y cambié manualmente su fondo cuando se presionó el botón o se soltó a dos elementos dibujables personalizados, uno con un botón en un estado normal y el otro en un estado presionado. Esto no es muy agradable, pero funciona :). Ahora pude ver cómo el botón cambiaba de estado normal a presionado en la pantalla.
Todavía hay una última falla: si hace clic en el botón demasiado rápido, no muestra el estado presionado, simplemente permanece en su estado normal (aunque el clic en sí mismo se activa para que el botón "funcione"). Al menos así es como aparece en mi Galaxy Nexus. Así que lo último que hice es retrasar un poco el botón cuando está presionado. Esto también es bastante feo y no estoy seguro de cómo funcionaría en algunos dispositivos más antiguos y lentos, pero sospecho que incluso el marco del mapa hace algo como esto. Puede probarlo usted mismo: cuando hace clic en toda la ventana de información, permanece presionada un poco más, luego los botones normales lo hacen (nuevamente, al menos en mi teléfono). Y así es como funciona incluso en la aplicación original de Google Maps.
De todos modos, escribí una clase personalizada que maneja los cambios de estado de los botones y todas las otras cosas que mencioné, así que aquí está el código:
package com.circlegate.tt.cg.an.lib.map;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import com.google.android.gms.maps.model.Marker;
public abstract class OnInfoWindowElemTouchListener implements OnTouchListener {
private final View view;
private final Drawable bgDrawableNormal;
private final Drawable bgDrawablePressed;
private final Handler handler = new Handler();
private Marker marker;
private boolean pressed = false;
public OnInfoWindowElemTouchListener(View view, Drawable bgDrawableNormal, Drawable bgDrawablePressed) {
this.view = view;
this.bgDrawableNormal = bgDrawableNormal;
this.bgDrawablePressed = bgDrawablePressed;
}
public void setMarker(Marker marker) {
this.marker = marker;
}
@Override
public boolean onTouch(View vv, MotionEvent event) {
if (0 <= event.getX() && event.getX() <= view.getWidth() &&
0 <= event.getY() && event.getY() <= view.getHeight())
{
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: startPress(); break;
// We need to delay releasing of the view a little so it shows the pressed state on the screen
case MotionEvent.ACTION_UP: handler.postDelayed(confirmClickRunnable, 150); break;
case MotionEvent.ACTION_CANCEL: endPress(); break;
default: break;
}
}
else {
// If the touch goes outside of the view's area
// (like when moving finger out of the pressed button)
// just release the press
endPress();
}
return false;
}
private void startPress() {
if (!pressed) {
pressed = true;
handler.removeCallbacks(confirmClickRunnable);
view.setBackground(bgDrawablePressed);
if (marker != null)
marker.showInfoWindow();
}
}
private boolean endPress() {
if (pressed) {
this.pressed = false;
handler.removeCallbacks(confirmClickRunnable);
view.setBackground(bgDrawableNormal);
if (marker != null)
marker.showInfoWindow();
return true;
}
else
return false;
}
private final Runnable confirmClickRunnable = new Runnable() {
public void run() {
if (endPress()) {
onClickConfirmed(view, marker);
}
}
};
/**
* This is called after a successful click
*/
protected abstract void onClickConfirmed(View v, Marker marker);
}
Aquí hay un archivo de diseño personalizado de InfoWindow que utilicé:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical" >
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginRight="10dp" >
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:text="Title" />
<TextView
android:id="@+id/snippet"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="snippet" />
</LinearLayout>
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Button" />
</LinearLayout>
Probar el archivo de diseño de actividad ( MapFragment
estar dentro del MapWrapperLayout
):
<com.circlegate.tt.cg.an.lib.map.MapWrapperLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/map_relative_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity" >
<fragment
android:id="@+id/map"
android:layout_width="match_parent"
android:layout_height="match_parent"
class="com.google.android.gms.maps.MapFragment" />
</com.circlegate.tt.cg.an.lib.map.MapWrapperLayout>
Y finalmente, el código fuente de una actividad de prueba, que une todo esto:
package com.circlegate.testapp;
import com.circlegate.tt.cg.an.lib.map.MapWrapperLayout;
import com.circlegate.tt.cg.an.lib.map.OnInfoWindowElemTouchListener;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.GoogleMap.InfoWindowAdapter;
import com.google.android.gms.maps.MapFragment;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;
import android.os.Bundle;
import android.app.Activity;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
public class MainActivity extends Activity {
private ViewGroup infoWindow;
private TextView infoTitle;
private TextView infoSnippet;
private Button infoButton;
private OnInfoWindowElemTouchListener infoButtonListener;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final MapFragment mapFragment = (MapFragment)getFragmentManager().findFragmentById(R.id.map);
final MapWrapperLayout mapWrapperLayout = (MapWrapperLayout)findViewById(R.id.map_relative_layout);
final GoogleMap map = mapFragment.getMap();
// MapWrapperLayout initialization
// 39 - default marker height
// 20 - offset between the default InfoWindow bottom edge and it's content bottom edge
mapWrapperLayout.init(map, getPixelsFromDp(this, 39 + 20));
// We want to reuse the info window for all the markers,
// so let's create only one class member instance
this.infoWindow = (ViewGroup)getLayoutInflater().inflate(R.layout.info_window, null);
this.infoTitle = (TextView)infoWindow.findViewById(R.id.title);
this.infoSnippet = (TextView)infoWindow.findViewById(R.id.snippet);
this.infoButton = (Button)infoWindow.findViewById(R.id.button);
// Setting custom OnTouchListener which deals with the pressed state
// so it shows up
this.infoButtonListener = new OnInfoWindowElemTouchListener(infoButton,
getResources().getDrawable(R.drawable.btn_default_normal_holo_light),
getResources().getDrawable(R.drawable.btn_default_pressed_holo_light))
{
@Override
protected void onClickConfirmed(View v, Marker marker) {
// Here we can perform some action triggered after clicking the button
Toast.makeText(MainActivity.this, marker.getTitle() + "'s button clicked!", Toast.LENGTH_SHORT).show();
}
};
this.infoButton.setOnTouchListener(infoButtonListener);
map.setInfoWindowAdapter(new InfoWindowAdapter() {
@Override
public View getInfoWindow(Marker marker) {
return null;
}
@Override
public View getInfoContents(Marker marker) {
// Setting up the infoWindow with current's marker info
infoTitle.setText(marker.getTitle());
infoSnippet.setText(marker.getSnippet());
infoButtonListener.setMarker(marker);
// We must call this to set the current marker and infoWindow references
// to the MapWrapperLayout
mapWrapperLayout.setMarkerWithInfoWindow(marker, infoWindow);
return infoWindow;
}
});
// Let's add a couple of markers
map.addMarker(new MarkerOptions()
.title("Prague")
.snippet("Czech Republic")
.position(new LatLng(50.08, 14.43)));
map.addMarker(new MarkerOptions()
.title("Paris")
.snippet("France")
.position(new LatLng(48.86,2.33)));
map.addMarker(new MarkerOptions()
.title("London")
.snippet("United Kingdom")
.position(new LatLng(51.51,-0.1)));
}
public static int getPixelsFromDp(Context context, float dp) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int)(dp * scale + 0.5f);
}
}
Eso es. Hasta ahora solo probé esto en mi Galaxy Nexus (4.2.1) y Nexus 7 (también 4.2.1), lo intentaré en algún teléfono Gingerbread cuando tenga la oportunidad. Una limitación que encontré hasta ahora es que no puedes arrastrar el mapa desde donde está tu botón en la pantalla y mover el mapa. Probablemente podría superarse de alguna manera, pero por ahora, puedo vivir con eso.
Sé que este es un truco feo, pero simplemente no encontré nada mejor y necesito tanto este patrón de diseño que realmente sería una razón para volver al marco del mapa v1 (que por cierto, realmente me gustaría evitar para una nueva aplicación con fragmentos, etc.). Simplemente no entiendo por qué Google no ofrece a los desarrolladores alguna forma oficial de tener un botón en InfoWindows. Es un patrón de diseño tan común, además, este patrón se usa incluso en la aplicación oficial de Google Maps :). Entiendo las razones por las que no pueden simplemente hacer que sus vistas "vivan" en InfoWindows; esto probablemente mataría el rendimiento al mover y desplazar el mapa. Pero debería haber alguna forma de lograr este efecto sin usar vistas.