Sam szukałem rozwiązania tego problemu bez powodzenia, więc musiałem rzucić własny, którym chciałbym się tutaj z Wami podzielić. (Proszę wybaczyć mój zły angielski) (To trochę szalone, aby odpowiedzieć innemu Czechowi po angielsku :-))
Pierwszą rzeczą, jakiej próbowałem, było użycie starego, dobrego PopupWindow
. To całkiem proste - wystarczy tylko wysłuchać, OnMarkerClickListener
a następnie pokazać niestandardowy PopupWindow
znak nad znacznikiem. Inni faceci na StackOverflow zasugerowali to rozwiązanie i na pierwszy rzut oka wygląda całkiem dobrze. Ale problem z tym rozwiązaniem pojawia się, gdy zaczynasz przesuwać mapę. Musisz przenieść PopupWindow
jakoś samemu, co jest możliwe (słuchając niektórych wydarzeń onTouch), ale IMHO nie może sprawić, aby wyglądał wystarczająco dobrze, szczególnie na niektórych powolnych urządzeniach. Jeśli zrobisz to w prosty sposób, „przeskakuje” z jednego miejsca do drugiego. Możesz także użyć animacji, aby dopracować te skoki, ale w ten sposóbPopupWindow
zawsze będzie to „krok do tyłu” tam, gdzie powinno być na mapie, co mi się nie podoba.
W tym momencie myślałem o innym rozwiązaniu. Uświadomiłem sobie, że tak naprawdę nie potrzebuję tak dużo swobody - aby pokazać swoje niestandardowe widoki ze wszystkimi dostępnymi możliwościami (takimi jak animowane paski postępu itp.). Myślę, że istnieje dobry powód, dla którego nawet inżynierowie Google nie robią tego w aplikacji Mapy Google. Wszystko, czego potrzebuję, to przycisk lub dwa w oknie InfoWindow, które pokażą stan naciśnięcia i wywołają pewne działania po kliknięciu. Wymyśliłem więc inne rozwiązanie, które dzieli się na dwie części:
Pierwsza część:
pierwszą częścią jest możliwość złapania kliknięć przycisków, aby wywołać akcję. Mój pomysł jest następujący:
- Zachowaj odniesienie do niestandardowego infoWindow utworzonego w InfoWindowAdapter.
- Zawiń
MapFragment
(lubMapView
) wewnątrz niestandardowej grupy ViewGroup (moja nazywa się MapWrapperLayout)
- Zastąp
MapWrapperLayout
'dispatchTouchEvent i (jeśli aktualnie wyświetla się InfoWindow) najpierw skieruj MotionEvent do wcześniej utworzonego InfoWindow. Jeśli nie zużywa MotionEvents (na przykład dlatego, że nie kliknąłeś żadnego klikalnego obszaru w InfoWindow itp.), To (i tylko wtedy) pozwól, by zdarzenia zejść do nadklasy MapWrapperLayout, aby ostatecznie zostały dostarczone na mapę.
Oto kod źródłowy 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);
}
}
Wszystko to sprawi, że widoki wewnątrz InfoView znów będą „na żywo” - OnClickListeners zacznie się uruchamiać itp.
Druga część:
Pozostały problem polega na tym, że na ekranie nie widać żadnych zmian interfejsu użytkownika programu InfoWindow. Aby to zrobić, musisz ręcznie wywołać Marker.showInfoWindow. Teraz, jeśli wykonasz jakąś stałą zmianę w swoim InfoWindow (np. Zmieniając etykietę przycisku na coś innego), to wystarczy.
Ale pokazanie stanu naciśnięcia przycisku lub czegoś takiego jest bardziej skomplikowane. Pierwszym problemem jest to, że (przynajmniej) nie byłem w stanie sprawić, aby InfoWindow pokazywał stan naciśnięcia przycisku normalnego. Nawet jeśli długo naciskałem przycisk, po prostu nie został wciśnięty na ekranie. Wierzę, że jest to coś, co jest obsługiwane przez samą strukturę mapy, co prawdopodobnie upewnia się, że nie pokazuje żadnego stanu przejściowego w oknach informacyjnych. Ale mogłem się mylić, nie próbowałem tego dowiedzieć.
To, co zrobiłem, to kolejny paskudny hack - załączyłem OnTouchListener
przycisk do przycisku i ręcznie przełączyłem jego tło, gdy przycisk został naciśnięty lub zwolniony do dwóch niestandardowych drawables - jednego z przyciskiem w normalnym stanie, a drugiego w stanie wciśniętym. To nie jest bardzo miłe, ale działa :). Teraz mogłem zobaczyć, jak przycisk przełącza się między stanem normalnym a naciśnięciem na ekranie.
Jest jeszcze jedna usterka - jeśli klikniesz przycisk zbyt szybko, nie pokaże on stanu naciśnięcia - po prostu pozostanie w swoim normalnym stanie (chociaż samo kliknięcie jest uruchamiane, więc przycisk „działa”). Przynajmniej tak pokazuje się na moim Galaxy Nexusie. Ostatnią rzeczą, którą zrobiłem, jest to, że nieco opóźniłem przycisk w stanie wciśniętym. Jest to również dość brzydkie i nie jestem pewien, jak to działałoby na niektórych starszych, wolnych urządzeniach, ale podejrzewam, że nawet sama struktura mapy robi coś takiego. Możesz spróbować sam - po kliknięciu całego okna InfoWindow pozostaje on dłużej w stanie wciśniętym, niż zwykłe przyciski (ponownie - przynajmniej na moim telefonie). I tak to działa nawet w oryginalnej aplikacji Google Maps.
W każdym razie napisałem sobie niestandardową klasę, która obsługuje zmiany stanu przycisków i wszystkie inne rzeczy, o których wspomniałem, więc oto kod:
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);
}
Oto niestandardowy plik układu InfoWindow, którego użyłem:
<?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>
Testuj plik układu działania ( MapFragment
znajdujący się w 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>
I na koniec kod źródłowy działania testowego, który łączy to wszystko razem:
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);
}
}
Otóż to. Do tej pory testowałem to tylko na moim Galaxy Nexusie (4.2.1) i Nexusie 7 (również 4.2.1), wypróbuję to na niektórych telefonach z Piernika, kiedy będę miał okazję. Ograniczeniem, które do tej pory znalazłem, jest to, że nie możesz przeciągać mapy z miejsca, w którym znajduje się przycisk na ekranie i przesuwać mapy. Prawdopodobnie można to jakoś pokonać, ale na razie mogę z tym żyć.
Wiem, że to brzydki hack, ale po prostu nie znalazłem nic lepszego i tak bardzo potrzebuję tego wzorca projektowego, że naprawdę byłby to powód, aby wrócić do frameworku map v1 (który przy okazji. Naprawdę bardzo chciałbym uniknąć dla nowej aplikacji z fragmentami itp.). Po prostu nie rozumiem, dlaczego Google nie oferuje programistom oficjalnego sposobu na przycisk w InfoWindows. To taki powszechny wzorzec projektowy, zresztą ten wzór jest używany nawet w oficjalnej aplikacji Google Maps :). Rozumiem powody, dla których nie mogą po prostu sprawić, by Twoje widoki były „na żywo” w InfoWindows - prawdopodobnie zabiłoby to wydajność podczas przesuwania i przewijania mapy. Ale powinien istnieć sposób na osiągnięcie tego efektu bez korzystania z widoków.