Робота з Google Maps в Android. Кластеризація маркерів

Часто під час розробки проєкту виникає ситуація, коли поставлені завдання важко вирішити за допомогою наявних стандартних інструментів розробки. Тоді на допомогу приходять сторонні ресурси у вигляді бібліотек та інших корисностей. Так сталося і з нашим новоствореним застосунком MegaSOS для платформи Android. Але давайте по порядку розглянемо кожну деталь.

Android — це найшвидше зростаюча мобільна операційна система, заснована на ядрі Linux, яка має власну реалізацію Java від Google. Це дітище пошукового гіганта, який не лише випустив платформу з відкритим вихідним кодом, але й створив Open Handset Alliance, завдяки якій у нас є SDK у нашому розпорядженні.

Google адаптував багато своїх сервісів під мобільну платформу, і Google Maps є одним з них. Саме про це ми сьогодні і говоритимемо.

API Google Maps для Android v2 є досить актуальним на даний момент. Думаю, немає сенсу говорити про те, як підключити сервіс, отримати ключі та почати працювати — таку інформацію легко знайти в Інтернеті. Тож перейдемо до суті.

При проєктуванні завдання полягало в тому, щоб відобразити маркери на карті, які діляться на кілька типів (за допомогою кластеризації маркерів Google Maps). Але, наприклад, сусідні маркери при зумі накладалися один на одного, і все виглядало як одне безперервне розмиття. Їх потрібно було згрупувати - кластеризувати - з огляду на тип кожного маркера. Стандартний інструментарій виглядав незадовільно для вирішення таких проблем. Звісно, можна було створити свій клас, який успадковує маркери, і одночасно розширити його. Але навіщо винаходити велосипед, якщо подібну проблему можна було вирішити раніше? Після ретельного пошуку ми знайшли дві бібліотеки, які розроблені, якщо не повністю, то в значній мірі, щоб спростити наше життя без читання величезних посібників. Це невибагливі Android Maps Extensions і іноді неперевершений Clusterkraft.

Android Maps Extensions

Ця бібліотека розширює можливості сервісу GoogleMap та супутніх функцій для Android API v2. Її головна перевага — це настільки необхідна для нас кластеризація перекриваючих маркерів. Це можна побачити на наступних скріншотах:

Робота з Google Maps в Android. Кластеризація маркерів

Розробники вказали цілий список класів і методів, які можна використовувати для згаданих завдань:

  • Кластеризація маркерів за допомогою GoogleMap.setClustering(ClusteringSettings)
  • ClusteringSettings.clusterOptionsProvider(ClusterOptionsProvider) є обов'язковим для коректної роботи
  • ClusteringSettings.addMarkersDynamically при наявності занадто великої кількості маркерів
  • ClusteringSettings.clusterSize для контролю... розміру кластеру
  • Object getData() та setData(Object) на Marker, Circle, GroundOverlay, Polygon, Polyline або TileOverlay
  • List GoogleMap.getMarkers() та
  • List GoogleMap.getCircles()
  • List GoogleMap.getGroundOverlays() тощо.
  • boolean Circle.contains(LatLng)
  • Marker GoogleMap.getMarkerShowingInfoWindow()
  • List GoogleMap.getDisplayedMarkers()
  • float GoogleMap.getMinZoomLevelNotClustered(Marker)
  • Marker.animatePosition(LatLng target, AnimationSettings settings)
  • Marker.setClusterGroup(int)

На момент розробки нашого проєкту ця бібліотека не могла посилатися на різні групи маркерів. Це призвело до кластеризації маркерів у Google Maps абсолютно скрізь, тому врешті-решт ми вирішили не використовувати її. Ми повідомили про проблему розробникам бібліотеки, які, в свою чергу, не залишили нас без уваги, і тепер у нас є чудовий і зручний метод Marker.setClusterGroup(int).

Бібліотеку легко використовувати, для її інтеграції потрібно просто імпортувати її як зовнішній модуль. Потім, використовуючи основну активність SupportMapFragment або MapView в xml-файлі, викликайте getExtendedMap замість getMap, який використовується в стандартній бібліотеці.

Clusterkraft

Цю бібліотеку було використано в цьому проєкті замість Maps Extensions, і вона одразу ж довела свою позитивну сторону. По-перше, її робота виявилася набагато більш стабільною, динамічне додавання маркерів працювало набагато швидше, а кластеризація виконувалася з приємною анімацією. По-друге, вона показала клас, який надає можливість замінити іконку кластеру маркерів залежно від його вмісту.

Робота з Google Maps в Android. Кластеризація маркерів

Для інтеграції бібліотеку імпортували як модуль.

Макет активності, що відображає карту, виглядає так:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:tools="http://schemas.android.com/tools"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                tools:context=".Map">
    <FrameLayout
            android:id="@+id/frame_map"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            class="com.stfalcon.megasos.views.MySupportMapFragment">
    </FrameLayout>
    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/transparent" />
</RelativeLayout>

У цьому випадку для карти був використаний FrameLayout - frame_map. Порожній FrameLayout з прозорим фоном виконував роль «магічного заклинання», яке вирішувало проблему з відображенням карти на пристроях з версіями до Ice Cream Sandwich (коли відкривається/закривається спливаюче меню або чорна область у формі вікна залишалася на карті).

Метод onCreate виглядає наступним чином:

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

        setContentView(R.layout.activity_map);
        SupportMapFragment mapFragment = SupportMapFragment.newInstance();
        getSupportFragmentManager().beginTransaction().add(R.id.frame_map, mapFragment, "MAP").commit();
    }

У методі onPostCreate ініціалізується карта, і ми можемо додати маркер:

@Override
    public void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        GoogleMap myMap = ((SupportMapFragment) getSupportFragmentManager().findFragmentByTag("MAP")).getMap();
        myMap.setMapType(GoogleMap.MAP_TYPE_NORMAL);
        initMap();
        MarkerOptions options = new MarkerOptions()
                .anchor(0.5f, 0.5f)
                .position(new LatLng(latitude, longitude))
                .draggable(false)
                .icon(BitmapDescriptorFactory
                        .fromResource(marker_resource));
        myMap.addMarker(options);
        updateMap();
       initMap();
    }

Ініціалізація карти:

private void initMap() {
        if (map == null) {
            SupportMapFragment mapFragment = ((SupportMapFragment) getSupportFragmentManager().findFragmentByTag("MAP"));
            if (mapFragment != null) {
                map = mapFragment.getMap();
                if (map != null) {
                    UiSettings uiSettings = map.getUiSettings();
                    uiSettings.setAllGesturesEnabled(false);
                    uiSettings.setScrollGesturesEnabled(true);
                    uiSettings.setZoomGesturesEnabled(true);
                    map.setOnCameraChangeListener(new GoogleMap.OnCameraChangeListener() {
                        @Override
                        public void onCameraChange(CameraPosition arg0) {
                            moveMapCameraToBoundsAndInitClusterkraf();
                            MapPointsProvider.getInstance().generate(mCallback, check_sos, check_company);
                            initMap();
                        }
                    });
                }
            }
        } else {
            moveMapCameraToBoundsAndInitClusterkraf();
        }
    }

Встановлення камери на попередню позицію при зміні орієнтації:

private void moveMapCameraToBoundsAndInitClusterkraf() {
        if (map != null && options != null && inputPoints != null) {
            try {
                if (restoreCameraPosition != null) {
                    /**
                     * якщо доступна restoreCameraPosition, перемістіть камеру
                     * туди
                     */
                    map.moveCamera(CameraUpdateFactory.newCameraPosition(restoreCameraPosition));
                    restoreCameraPosition = null;
                }
                initClusterkraf();
            } catch (IllegalStateException ise) {
            }
        }
    }

Ініціалізація Clusterkraft (з додаванням кожного маркера / групи маркерів вам потрібно повторно провести кластеризацію):

private void initClusterkraf() {
        if (map != null && inputPoints != null && inputPoints.size() > 0) {
            com.twotoasters.clusterkraf.Options options = new com.twotoasters.clusterkraf.Options();
            applyDemoOptionsToClusterkrafOptions(options);
            if (clusterkraf == null) {
                clusterkraf = new Clusterkraf(map, options, inputPoints);
            } else {
                clusterkraf.clear();
                //map.clear();
                clusterkraf.addAll(inputPoints);
            }
            if (!check_sos && !check_company) {
                clusterkraf.clear();
            }
        }
        if (map != null && inputPoints.size() == 0 && clusterkraf != null) {
            if (!check_sos && !check_company) {
                clusterkraf.clear();
            }
        }
    }

Клас, який описує правила кластеризації маркерів, використовує відповідні піктограми для них. Якщо це кластер, то клас додає кількість елементів у піктіограмі з відповідним кольором:

```html
public class MapMarkerOptionsChooser extends MarkerOptionsChooser {

    private final WeakReference contextRef;
    private final Paint clusterPaintLarge;
    private final Paint clusterPaintMedium;
    private final Paint clusterPaintSmall;

    public MapMarkerOptionsChooser(Context context) {
        this.contextRef = new WeakReference(context);

        Resources res = context.getResources();

        clusterPaintMedium = new Paint();
        clusterPaintMedium.setColor(Color.BLACK);
        clusterPaintMedium.setAlpha(255);
        clusterPaintMedium.setTextAlign(Paint.Align.CENTER);
        clusterPaintMedium.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL));
        clusterPaintMedium.setTextSize(res.getDimension(R.dimen.cluster_text_size_medium));

        clusterPaintSmall = new Paint(clusterPaintMedium);
        clusterPaintSmall.setTextSize(res.getDimension(R.dimen.cluster_text_size_small));

        clusterPaintLarge = new Paint(clusterPaintMedium);
        clusterPaintLarge.setTextSize(res.getDimension(R.dimen.cluster_text_size_large));
    }

    @Override
    public void choose(MarkerOptions markerOptions, ClusterPoint clusterPoint) {
        Context context = contextRef.get();
        if (context != null) {
            Resources res = context.getResources();
            boolean isCluster = clusterPoint.size() > 1;
            BitmapDescriptor icon;
            String title;
            if (isCluster) {
                int company = 0;
                int sos = 0;
                int clusterSize = clusterPoint.size();
                for (int i = 0; i < clusterSize; i++) {
                    if (((MarkerData) clusterPoint.getPointAtOffset(i).getTag()).getType() == MarkerData.SOS) {
                        sos++;
                    }
                    if (((MarkerData) clusterPoint.getPointAtOffset(i).getTag()).getType() == MarkerData.COMPANY) {
                        company++;
                    }
                }
                if (sos > 0 && company == 0) {
                    icon = BitmapDescriptorFactory.fromBitmap(getClusterBitmap(res, R.drawable.ic_map_group, clusterSize, Color.WHITE));
                } else if (sos == 0 && company > 0) {
                    icon = BitmapDescriptorFactory.fromBitmap(getClusterBitmap(res, R.drawable.ic_map_group_companies, clusterSize, Color.BLACK));
                } else {
                    icon = BitmapDescriptorFactory.fromBitmap(getClusterBitmap(res, R.drawable.ic_map_group_of_groups, clusterSize, Color.BLACK));
                }
            } else {
                MarkerData data = (MarkerData) clusterPoint.getPointAtOffset(0).getTag();
                if (data.getType() == MarkerData.SOS) {
                    icon = BitmapDescriptorFactory.fromResource(R.drawable.ic_map_sos_pin);
                } else {
                    icon = BitmapDescriptorFactory.fromResource(R.drawable.ic_map_company_pin);
                }
            }
            markerOptions.icon(icon);
            markerOptions.anchor(0.5f, 1.0f);
        }
    }

    @SuppressLint("NewApi")
    private Bitmap getClusterBitmap(Resources res, int resourceId, int clusterSize, int color) {
        BitmapFactory.Options options = new BitmapFactory.Options();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            options.inMutable = true;
        }
        Bitmap bitmap = BitmapFactory.decodeResource(res, resourceId, options);
        if (!bitmap.isMutable()) {
            bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true);
        }

        Canvas canvas = new Canvas(bitmap);

        Paint paint = null;
        float originY;
        if (clusterSize < 100) {
            paint = clusterPaintLarge;
            originY = bitmap.getHeight() * 0.64f;
        } else if (clusterSize < 1000) {
            paint = clusterPaintMedium;
            originY = bitmap.getHeight() * 0.6f;
        } else {
            paint = clusterPaintSmall;
            originY = bitmap.getHeight() * 0.56f;
        }

        paint.setColor(color);

        canvas.drawText(String.valueOf(clusterSize), bitmap.getWidth() * 0.5f, originY, paint);

        return bitmap;
    }
}
``` ```html

Проте нам не вдалося уникнути деяких проблем. При використанні бібліотеки подія onCameraChangePosition перестала працювати. Як рішення, ми використали View.OnTouchListener з певними умовами.

І нарешті, ось порада щодо стандартних інформаційних вікон («вспливаючих» вікон над маркерами). Якщо ви хочете відобразити будь-який процес у них або призначити певні події їх окремим елементам, ви можете використовувати вспливаючі вікна для цього, оскільки стандартне інформаційне вікно відображає «неживий» View (тобто, як скріншот, який не має анімації в межах вікна) і обробляє лише кліки на його поверхні.

```