Довелось как то недавно делать проект с использованием google maps. В один из моментов, увидел я вот такую картину:
Это происходило, как вы догадались, когда рядом находилось слишком много мест, которые требовалось отображать. Ну ничего – проблема то вроде стандартная – много у кого должно было такое встречаться, наверняка разработчики предусмотрели группировку и фильтрацию… Ага, ни тут то было! Просмотрев документацию(сразу признаюсь что только бегло – возможно и упустил то что мне нужно) и спросив у гугла – к своему удивлению быстро ничего нужного найти не удалось. Ну ничего – не боги горшки обжигают – значит напишем сами.
Для начала создадим проект и подключим к нему google maps api. Тут я думаю объяснять ничего не надо – про это много уже было написано, вот кстати ссылки и на хабре – раз, два, три.
Cначала определимся что да как должно работать. Поехали…
Очевидно что нам надо придумать некий алгоритм, по которому будут выводиться на экран не все пины, а только находящиеся друг от друга на определенном расстоянии. А так же надо научиться объединять их в группы. Пересчет всего этого хозяйства надо чтобы происходил:
1) При первоначальной загрузке пинов на карту.
2) При изменении zoom
С первым пунктом в принципе все ясно – давайте определимся со вторым. Объявим интерфейс:
public interface IOnZoomListener {
void onZoomChanged();
}
И модифицируем наш MapView вот таким образом:
public class MyMapView extends MapView {
int oldZoomLevel = -1;
IOnZoomListener onZoomListener;
public MyMapView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public MyMapView(Context context, String apiKey) {
super(context, apiKey);
}
public MyMapView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public void setOnZoomListener(IOnZoomListener onZoomListener) {
this.onZoomListener = onZoomListener;
}
@Override
public void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
int newZoom = this.getZoomLevel();
if (newZoom != oldZoomLevel) {
if (oldZoomLevel != -1 && onZoomListener != null) {
onZoomListener.onZoomChanged();
}
oldZoomLevel = getZoomLevel();
}
}
}
Здесь мы просто добавили возможность регистрации нашего интерфейса и проверку при отрисовке на изменение ZoomLevel(если ZoomLevel поменялся – дергаем метод нашего интерфейса).
Теперь определимся с тем, как мы будем отображать пины на карте и как объединять их в группы. Для этого создадим class MyOverlayItem унаследованный от OverlayItem вот с такими дополнениями:
public class MyOverlayItem extends OverlayItem {
private String name;
private ArrayList<MyOverlayItem> list = new ArrayList<MyOverlayItem>();
public MyOverlayItem(GeoPoint point, String name) {
super(point, "", "");
this.name = name;
}
public String getName() {
if (list.size() > 0) {
return "There are " + (list.size() + 1) + " places.";
} else {
return name;
}
}
public void addList(MyOverlayItem item) {
list.add(item);
}
public ArrayList<MyOverlayItem> getList() {
return list;
}
}
В ArrayList list будет храниться список сгруппированных пинов, а метод getName будет возвращать нам либо имя объекта, либо их количество в группе.
Теперь опишем то, ради чего, собственно, это все и затевалось – наш модифицированный ItemizedOverlay.
Суть алгоритма фильтрации довольно таки проста: мы просто бежим в цикле по всем существующим элементам и проверяем каждый элемент на предмет близкого расстояния с уже существующей группой элементов. Если мы нашли такую группу – элемент добавляется в нее, если нет – создается новая группа с данным элементом:
boolean isImposition;
for (MyOverlayItem itemFromAll : myOverlaysAll) {
isImposition = false;
for (MyOverlayItem item : myOverlays) {
if (itemFromAll == item) {
isImposition = true;
break;
}
if (isImposition(itemFromAll, item)) {
item.addList(itemFromAll);
isImposition = true;
break;
}
}
if (!isImposition) {
myOverlays.add(itemFromAll);
}
}
Сначала для проверки расстояния, я хотел использовать просто координаты пинов(погрешностью, возникающей в зависимости от широты, можно пренебречь, так как расстояния не велики), но тогда пришлось бы еще и контролировать ZoomLevel.
Для моих задач вполне подошел метод mapView.getLatitudeSpan который возвращает расстояние видимой ширины экрана в нужной нам системе координат. Осталось только поделить это расстояние на некий коэффициент(сколько максимально пинов должно «влезать» в экран по ширине) – это и будет минимальное расстояние между пинами:
private boolean isImposition(MyOverlayItem item1, MyOverlayItem item2) {
int latspan = mapView.getLatitudeSpan();
int delta = latspan / KOEFF;
int dx = item1.getPoint().getLatitudeE6() - item2.getPoint().getLatitudeE6();
int dy = item1.getPoint().getLongitudeE6() - item2.getPoint().getLongitudeE6();
double dist = Math.sqrt(dx * dx + dy * dy);
if (dist < delta) {
return true;
} else {
return false;
}
}
Вот на всякий случай полный исходник класса:
public class PlaceOverlay extends ItemizedOverlay<MyOverlayItem> {
private static final int KOEFF = 20;
private ArrayList<MyOverlayItem> myOverlaysAll = new ArrayList<MyOverlayItem>();
private ArrayList<MyOverlayItem> myOverlays = new ArrayList<MyOverlayItem>();
private MapView mapView;
public PlaceOverlay(Drawable defaultMarker, MapView mapView) {
super(boundCenterBottom(defaultMarker));
this.mapView = mapView;
populate();
}
public void addOverlay(MyOverlayItem overlay) {
myOverlaysAll.add(overlay);
myOverlays.add(overlay);
}
public void doPopulate() {
populate();
setLastFocusedIndex(-1);
}
@Override
protected MyOverlayItem createItem(int i) {
return myOverlays.get(i);
}
@Override
public int size() {
return myOverlays.size();
}
private boolean isImposition(MyOverlayItem item1, MyOverlayItem item2) {
int latspan = mapView.getLatitudeSpan();
int delta = latspan / KOEFF;
int dx = item1.getPoint().getLatitudeE6() - item2.getPoint().getLatitudeE6();
int dy = item1.getPoint().getLongitudeE6() - item2.getPoint().getLongitudeE6();
double dist = Math.sqrt(dx * dx + dy * dy);
if (dist < delta) {
return true;
} else {
return false;
}
}
public void clear() {
myOverlaysAll.clear();
myOverlays.clear();
}
public void calculateItems() {
myOverlaysClear();
boolean isImposition;
for (MyOverlayItem itemFromAll : myOverlaysAll) {
isImposition = false;
for (MyOverlayItem item : myOverlays) {
if (itemFromAll == item) {
isImposition = true;
break;
}
if (isImposition(itemFromAll, item)) {
item.addList(itemFromAll);
isImposition = true;
break;
}
}
if (!isImposition) {
myOverlays.add(itemFromAll);
}
}
doPopulate();
}
private void myOverlaysClear() {
for (MyOverlayItem item : myOverlaysAll) {
item.getList().clear();
}
myOverlays.clear();
}
@Override
protected boolean onTap(int index) {
Toast.makeText(mapView.getContext(), myOverlays.get(index).getName(), Toast.LENGTH_SHORT).show();
return true;
}
}
Ах да – в методе onTap мы выводим Toast с именем группы – для наглядной демонстрации работы алгоритма.
Хочу добавить, что данный алгоритм не является истиной в последней инстанции – его можно и нужно улучшать – например рисовать пин не на месте первого элемента группы, а рассчитывать его местоположение в зависимости от ее наполнения. Но это вы уже реализуете сами в собственных проектах.
Теперь давайте разберемся как все это собрать воедино.
Создадим ManyPinsProjectActivity которое унаследуем от MapActivity и реализуем следующие интерфейсы: LocationListener, IOnZoomListener. Впрочем не буду расписывать все подробно – исходники все расскажут за меня:
public class ManyPinsProjectActivity extends MapActivity implements LocationListener, IOnZoomListener {
private static final int DEFAULT_ZOOM = 15;
private MyMapView mapView = null;
private Drawable myCurrentMarker = null;
private Drawable placeMarker = null;
private List<Overlay> mapOverlays;
private PlaceOverlay placeOverlay;
private MyCurrentLocationOverlay myCurrentLocationOverlay;
double currentLatitude, currentLongitude;
private MapController mapController;
private LocationManager locationManager;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
locationManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE);
mapView = (MyMapView) findViewById(R.id.mapview);
myCurrentMarker = this.getResources().getDrawable(R.drawable.my_pin_red);
placeMarker = this.getResources().getDrawable(R.drawable.my_pin);
myCurrentLocationOverlay = new MyCurrentLocationOverlay(myCurrentMarker, mapView);
placeOverlay = new PlaceOverlay(placeMarker, mapView);
mapOverlays = mapView.getOverlays();
mapController = mapView.getController();
mapView.setBuiltInZoomControls(true);
mapView.setOnZoomListener(this);
}
private void animateToPlaceOnMap(final GeoPoint geopoint) {
mapView.post(new Runnable() {
@Override
public void run() {
mapView.invalidate();
mapController.animateTo(geopoint);
mapController.setZoom(DEFAULT_ZOOM);
}
});
}
private void setCurrentGeopoint(double myLatitude, double myLongitude) {
currentLatitude = myLatitude;
currentLongitude = myLongitude;
final GeoPoint myCurrentGeoPoint = new GeoPoint((int) (myLatitude * 1E6), (int) (myLongitude * 1E6));
MyOverlayItem myCurrentItem = new MyOverlayItem(myCurrentGeoPoint, "Current Location");
myCurrentLocationOverlay.addOverlay(myCurrentItem);
mapOverlays.add(myCurrentLocationOverlay);
animateToPlaceOnMap(myCurrentGeoPoint);
}
@Override
protected void onPause() {
super.onPause();
locationManager.removeUpdates(this);
}
@Override
protected void onResume() {
super.onResume();
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 5000, 100, this);
}
private ArrayList<PlaceInfo> generatePlaces(){
Random random = new Random();
int x, y;
ArrayList<PlaceInfo> places = new ArrayList<PlaceInfo>();
PlaceInfo p;
for(int i = 0; i < 100; i++){
x = random.nextInt(2000);
y = random.nextInt(2000);
p = new PlaceInfo();
p.setLatitude(currentLatitude + x/100000f);
p.setLongitude(currentLongitude - y/100000f);
p.setName("Place № " + i);
places.add(p);
}
return places;
}
private void displayPlacesOnMap() {
ArrayList<PlaceInfo> places = generatePlaces();
mapOverlays.remove(placeOverlay);
GeoPoint point = null;
MyOverlayItem overlayitem = null;
placeOverlay.clear();
for (PlaceInfo place : places) {
point = new GeoPoint((int) (place.getLatitude() * 1E6), (int) (place.getLongitude() * 1E6));
overlayitem = new MyOverlayItem(point, place.getName());
placeOverlay.addOverlay(overlayitem);
}
placeOverlay.calculateItems();
placeOverlay.doPopulate();
if (placeOverlay.size() > 0) {
mapOverlays.add(placeOverlay);
mapView.postInvalidate();
}
}
@Override
public void onLocationChanged(Location location) {
locationManager.removeUpdates(this);
double myLatitude = location.getLatitude();
double myLongitude = location.getLongitude();
setCurrentGeopoint(myLatitude, myLongitude);
displayPlacesOnMap();
}
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
}
@Override
public void onProviderEnabled(String provider) {
}
@Override
public void onProviderDisabled(String provider) {
}
@Override
protected boolean isRouteDisplayed() {
return false;
}
@Override
public void onZoomChanged() {
if (placeOverlay != null) {
placeOverlay.calculateItems();
}
}
}
Тут стоит добавить что MyCurrentLocationOverlay – это обычный ItemizedOverlay с одним элементом, а PlaceInfo – обычный класс обертка содержащий в себе:
private String name;
private double latitude;
private double longitude;
После всех этих манипуляций – вот как стала выглядеть наша карта с пинами:
Надеюсь статья окажется вам полезной.
Весь проект можно найти по ссылке.
Автор: igor_ab