Интеграция маркеров Google Maps на Android. Кластеризация маркеров

Зачастую при разработке проектов возникает ситуация, когда поставленное задание сложно решить имеющимися стандартными инструментами разработчика. Тогда на помощь приходят сторонние ресурсы в виде библиотек и прочих вкусностей. Так случилось и с недавно разработанным нами приложением MegaSOS под платформу Android. Как оказалось использование маркеров в Google Maps при большом количестве точек приводит к сложностям их отображения. И тут нам поможет google maps кластеризация маркеров. Но обо всем по порядку.

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

Под мобильную платформу компания Google адаптировала множество своих сервисов, среди которых есть и Google Maps. О нем-то сегодня и пойдет речь.

Актуальным на данный момент является Android Google Maps APIv2. Думаю, о том, как подключить сервис, получить ключи и начать работу, говорить излишне — в Интернете с легкостью можно отыскать подобную информацию. Поэтому сразу перейдем к сути.

При разработке проекта стояла задача отображения на карте маркеров, которые делятся на несколько типов. Близстоящие маркеры при уменьшении масштаба накладывались друг на друга, да так, что все это выглядело одним сплошным пятном. Их необходимо было сгруппировать — кластеризировать — с учетом типа каждого маркера. Стандартный инструментарий выглядел скудно для решения такого рода задач. Конечно, можно было создать свой наследующий маркеры класс, при этом расширив его. Но зачем изобретать велосипед, если подобную проблему могли решить до тебя. После тщательных поисков были обнаружены две библиотеки, призванные если не полностью, то в значительной степени упростить нам жизнь. Это незамысловатый Android Maps Extensions и местами бесподобный Clusterkraft.

Android Maps Extensions

Эта библиотека расширяет возможности сервиса GoogleMap и связанных с ними объектов для Android API v2. Основным ее достоинством является так необходимая нам кластеризация накладывающихся маркеров, которую можно увидеть на следующих скринах:

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

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

  • Marker Clustering with GoogleMap.setClustering(ClusteringSettings)
  • ClusteringSettings.clusterOptionsProvider(ClusterOptionsProvider) is required for it all to work
  • ClusteringSettings.addMarkersDynamically when having too many markers
  • ClusteringSettings.clusterSize to control... cluster size
  • Object getData() and setData(Object) on Marker, Circle, GroundOverlay, Polygon, Polyline or TileOverlay
  • List GoogleMap.getMarkers() and
  • List GoogleMap.getCircles()
  • List GoogleMap.getGroundOverlays() etc.
  • boolean Circle.contains(LatLng)
  • Marker GoogleMap.getMarkerShowingInfoWindow()
  • List GoogleMap.getDisplayedMarkers()
  • float GoogleMap.getMinZoomLevelNotClustered(Marker)
  • Marker.animatePosition(LatLng target, AnimationSettings settings)
  • Marker.setClusterGroup(int)

На момент разработки нашего проекта, эта библиотека не имела возможности относить маркеры к разным группам. Это вело за собой кластеризацию абсолютно всех маркеров на карте, потому было принято решение отказаться от её использования. Мы сообщили об этой проблеме разработчикам библиотеки, которые в свою очередь не оставили нас без внимания, и теперь мы имеем замечательный и удобный метод 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 (при открытии/закрытии pop-up или слайд-меню на карте оставалась чёрная зона по форме этого окна).

Метод 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) {
                    /**
                     * if a restoreCameraPosition is available, move the camera
                     * there
                     */
                    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();
            }
        }
    }

Класс, который описывает правила кластеризации маркеров, применяет для них соответствующие пины-иконки. Если это кластер, то класс дописывает количество элементов в пине соответствующим цветом:

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;
    }
}

Без проблем не обошлось. При применении библиотеки перестал срабатывать ивент onCameraChangePosition. В качестве решения мы использовали View.OnTouchListener с некоторыми условиями.

И напоследок, небольшой совет по стандартным инфо-окнам («всплывашкам» над маркерами). Если Вы планируете отображать в них какой-нибудь процесс или назначить на их отдельные элементы определенные события, то используйте для этого pop-up, так как стандартное инфо-окно отображает «не живой» View (как скриншот, то есть никакой анимации внутри окна не будет), и обрабатывает только клик по всей его поверхности.

Об авторе

Руководитель отдела Mobile
Саша — тимлид мобильной команды студии. Он регулярно проводит встречи с разбором кода и анализом полученного опыта, благодаря чему сохраняется мотивация к улучшению результатов и виден четкий план развития каждого мобильного разработчика.

Похожие статьи

Вернуться к списку записей К списку записей