Pull to refresh

Google Maps v2 для Android: Всплывающее окно с полноценной перерисовкой и поддержкой событий ввода

Reading time 6 min
Views 17K
image

С объявлением библиотеки Google Maps v1 для Android устаревшей прекратилась и выдача ключей. Разработчикам была предложена новая версия — быстрее, выше, сильнее лучше, удобнее старой. Чего стоила попытка отобразить с ее использованием несколько точек на карте и несложное всплывающее окно с изображением, небольшим описанием и кнопкой – читайте под катом.

Google Maps v2 во многом более удобны, чем старая версия. Чего только стоит MapFragment, без которого встраивание карт в приложение, построенное на фрагментах, невозможно. Или, например, такая мелочь, как возможность указать ключ в единственном месте в AndroidManifest.xml, а не в каждом MapView, как в первой версии библиотеки. Новые фишки, вроде Fluent Interface. Красота.

Но заканчиваем восторгаться и переходим к делу. Добавление маркетов на карту — сущий пустяк, а вот при попытке отобразить всплывающее окно попадаем в засаду. Из коробки можно добавить только заголовок и подзаголовок. Есть еще InfoWindowAdapter, но он… эм… несколько ограничен. Документация говорит нам:
Note: The info window that is drawn is not a live view. The view is rendered as an image (using View.draw(Canvas)) at the time it is returned. This means that any subsequent changes to the view will not be reflected by the info window on the map. To update the info window later (for example, after an image has loaded), call showInfoWindow().

То есть, перерисовывать окно необходимо вручную, вызывая showInfoWindow(). Этого достаточно, чтобы, например, отобразить загруженное из сети изображение, но как быть, например, с ProgressBar’ом, который необходимо перерисовывать постоянно? Как отрисовывать состояния (pressed, focused и т.п.) View в окне?

Оттуда же:
Furthermore, the info window will not respect any of the interactivity typical for a normal view such as touch or gesture events. However you can listen to a generic click event on the whole info window as described in the section below.

Итак, никаких событий ввода. Вся интерактивность, которая у нас есть – реакция на нажатие целого окна, отловить которое можно с помощью OnInfoWindowClickListener.

Поиск решений обнаруживает вопрос на Stack Overflow. Автор принятого ответа предлагает обернуть MapView и перехватывать события ввода, вручную переключая состояния. По его же словам, это крайне муторно, и заставить все это нормально работать так и не удается. Казалось бы, на этом можно и сдаваться.

Но выход есть. Что если добавить над картой еще один слой, в котором будем рисовать всплывающее окно? При прокрутке карты будем соответственно изменять положение всплывающего окна, чтобы «отслеживать» маркер на карте. Для того, чтобы по координатам изменять положение некоторого View в контейнере, можно придумать какой-нибудь велосипед, но, к счастью, незачем — вспоминаем, что в нашем распоряжении есть устаревший, но не выпиленный окончательно AbsoluteLayout (да не испепелят меня молнии за его использование).

В итоге получаем примерно следующую разметку (здесь и далее для ясности опущен ряд подробностей):
<RelativeLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        >

    <FrameLayout
            android:id="@+id/container_map"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            >
        <!-- карта -->
    </FrameLayout>

    <AbsoluteLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            >

        <LinearLayout
                android:id="@+id/container_popup"
                android:layout_x="0dp"
                android:layout_y="0dp"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                >
            <!-- всплывающее окно -->
        </LinearLayout>

    </AbsoluteLayout>

</RelativeLayout>


В коде выглядит следующим образом:
public class MapFragment extends com.google.android.gms.maps.MapFragment {
    //длительность анимации перемещения камеры
    private static final int ANIMATION_DURATION = 500;

    //координаты отслеживаемой при прокрутке точки на карте
    private LatLng trackedPosition;
    
    //смещения всплывающего окна, позволяющее скорректировать его положение относительно маркера
    private int popupXOffset;
    private int popupYOffset;
    //высота маркера
    private int markerHeight;

    //слушатель, который будет обновлять смещения при изменении размеров окна
    private ViewTreeObserver.OnGlobalLayoutListener infoWindowLayoutListener;

    //контейнер всплывающего окна
    private View infoWindowContainer;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View rootView = inflater.inflate(R.layout.fragment, null);

        FrameLayout containerMap = (FrameLayout) rootView.findViewById(R.id.container_map);
        View mapView = super.onCreateView(inflater, container, savedInstanceState);
        containerMap.addView(mapView, new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT));

        infoWindowContainer= rootView.findViewById(R.id.container_popup);
        //подписываемся на изменения размеров всплывающего окна
        infoWindowLayoutListener = new InfoWindowLayoutListener();
        infoWindowContainer.getViewTreeObserver().addOnGlobalLayoutListener(infoWindowLayoutListener );

        return rootView;
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();

        //убираемся за собой
        infoWindowContainer.getViewTreeObserver().removeGlobalOnLayoutListener(infoWindowLayoutListener );
    }

    @Override
    public void onMapClick(LatLng latLng) {
        //клик за пределами всплывающего окна и не по одному из маркеров, прячем всплывающее окно
        infoWindowContainer.setVisibility(INVISIBLE);
    }

    @Override
    public boolean onMarkerClick(Marker marker) {
        //клик по маркеру на карте
        //устанавливаем отслеживаемую точку
        GoogleMap map = getMap();
        Projection projection = map.getProjection();
        trackedPosition = marker.getPosition();
        //перемещаем камеру
        Point trackedPoint = projection.toScreenLocation(trackedPosition);
        trackedPoint.y -= popupYOffset / 2;
        LatLng newCameraLocation = projection.fromScreenLocation(trackedPoint);
        map.animateCamera(CameraUpdateFactory.newLatLng(newCameraLocation), ANIMATION_DURATION, null);

        //заполняем и показываем окно
        //…
        infoWindowContainer.setVisibility(VISIBLE);

        return true;
    }

    private class InfoWindowLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener {
        @Override
        public void onGlobalLayout() {
            //размеры окна изменились, обновляем смещения
            popupXOffset = infoWindowContainer.getWidth() / 2;
            popupYOffset = infoWindowContainer.getHeight();
        }
    }
}


И наконец, самая важная и интересная часть — «отслеживание» маркера на карте. Первой мыслью, пришедшей в голову, был отлов событий ввода в onTouchEvent() и изменение положения окна там же. Как оказалось, способ совершенно негодный — из-за того, что события ввода группируются, прежде чем попасть в onTouchEvent(), вызывается он с произвольной периодичностью, поэтому всплывающее окно перемещается рывками. Выглядит отвратительно.

Появилась другая мысль — а почему бы не обновлять положение окна через постоянные интервалы времени? Handler и Runnable в помощь:

public class MapFragment extends com.google.android.gms.maps.MapFragment  {
    //интервал обновления положения всплывающего окна. 
    //для плавности необходимо 60 fps, то есть 1000 ms / 60 = 16 ms между обновлениями.
    private static final int POPUP_POSITION_REFRESH_INTERVAL = 16;
    
    //Handler, запускающий обновление окна с заданным интервалом
    private Handler handler;
    //Runnable, который обновляет положение окна
    private Runnable positionUpdaterRunnable;  

    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        handler = new Handler(Looper.getMainLooper());
        positionUpdaterRunnable = new PositionUpdaterRunnable();

        //запускаем периодическое обновление
        handler.post(positionUpdaterRunnable);
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();

        //очистка
        handler.removeCallbacks(positionUpdaterRunnable);
        handler = null;
    }
    
    private class PositionUpdaterRunnable implements Runnable {
        private int lastXPosition = Integer.MIN_VALUE;
        private int lastYPosition = Integer.MIN_VALUE;

        @Override
        public void run() {
            //помещаем в очередь следующий цикл обновления 
            handler.postDelayed(this, POPUP_POSITION_REFRESH_INTERVAL);

            //если всплывающее окно скрыто, ничего не делаем
            if (trackedPosition != null && infoWindowContainer.getVisibility() == VISIBLE) {
                Point targetPosition = getMap().getProjection().toScreenLocation(trackedPosition);

                //если положение окна не изменилось, ничего не делаем
                if (lastXPosition != targetPosition.x || lastYPosition != targetPosition.y) {
                    //обновляем положение
                    overlayLayoutParams = (AbsoluteLayout.LayoutParams) infoWindowContainer.getLayoutParams();
                    overlayLayoutParams.x = targetPosition.x - popupXOffset;
                    overlayLayoutParams.y = targetPosition.y - popupYOffset - markerHeight;
                    infoWindowContainer.setLayoutParams(overlayLayoutParams);

                    //запоминаем текущие координаты
                    lastXPosition = targetPosition.x;
                    lastYPosition = targetPosition.y;
                }
            }
        }
    }
}


Что из этого получилось — смотрите на видео ниже.


К сожалению, одновременно запись видео и эмулятор тянет плохо, так что видео не дает представление о производительности. Тесты на Nexus 7 2013 с 4.4.2 показали, что при отображении всплыващего окна плавность прокрутки немного ухудшается, но не слишком. В общем-то, юзабельно даже на стареньком Nexus S с 4.4.2.

Код выложен на GitHub: github.com/deville/info-window-demo.
Tags:
Hubs:
+10
Comments 3
Comments Comments 3

Articles