Windows Phone: разное

Сенсорный интерфейс для ориентирования карты

Чарльз Петцольд

Исходный ккод можно скачать по ссылке.

Чарльз ПетцольдКогда я слегка теряюсь в торговом центре или музее, я ищу карту и в то же время часто ощущаю некоторые опасения насчет того, что я на ней найду. Я совершенно уверен, что на карте будет стрелка с меткой «вы находитесь здесь», но как следует ориентировать эту карту? Если карта смонтирована вертикально, действительно ли ее правая часть соответствует правой стороне от меня, а нижняя — тому, что находится позади меня? Или же карту надо мысленно снять и развернуть в пространстве так, чтобы выровнять ее с реальным окружением?

Карты, установленные под углом или параллельно полу, намного лучше — при условии, что они правильно ориентированы, конечно. Независимо от чьих-либо умственных способностей к пространственному воображению карты легче читаются, когда они параллельны земле или их можно повернуть, чтобы они совпадали с горизонтальной линией. До эпохи GPS вы наверняка часто видели, как люди возятся в бумажными дорожными картами, со злостью крутя их то вправо, то влево, то вверх тормашками, пытаясь отыскать правильную ориентацию.

Карты, реализованные в виде программного обеспечения на смартфонах и других мобильных устройствах, способны ориентировать сами себя на основе показаний компаса. Это и послужило толчком к моим изысканиям в отображении карты на устройстве с Windows Phone, которая поворачивается относительно самого смартфона. Такая карта должна уметь автоматически разворачиваться в соответствии с окружающим ландшафтом и потенциально быть более полезной тем, кто заблудился среди нас.

Ориентирование карты

Изначально я хотел несколько большего, чем поворачивания карты. Программа, которую я задумывал, делала бы карту плавающей в трехмерном пространстве, чтобы та была всегда параллельной поверхности Земли, а также была сориентирована по компасу.

Немного экспериментов, и я убедился, что этот подход несколько экстравагантнее того, что мне было нужно. Хотя поворачиваемая карта с перспективой хорошо подходит для автомобилей с GPS-навигаторами, думаю, все дело в том, что карта всегда наклоняется на один и тот же угол и в какой-то мере имитирует то, что видно из лобового окна машины. В случае карта на мобильном устройстве наклон дает эффект сжатия визуальных элементов карты, не предоставляя никакой дополнительной информации. Похоже, что простого поворачивания в двухмерной плоскости вполне достаточно.

В своей ноябрьской рубрике за 2012 г. я рассмотрел, как использовать Bing Maps SOAP Services для скачивания и сборки 256-пиксельных тайлов (плиток) в карту («Assembling Bing Map Tiles on Windows Phone», https://msdn.microsoft.com/en-us/magazine/jj721603.aspx). Тайлы, доступные от этого веб-сервиса, организуются в уровни масштабирования (zoom levels), где каждый более высокий уровень имеет в два раза большее разрешение, чем предыдущий, т. е. каждый тайл покрывает ту же область, что и четыре тайла на следующем, более высоком уровне.

Моя программа в рубрике за прошлый месяц содержала кнопки панели приложения, помеченные знаками «плюс» и «минус» для увеличения и уменьшения уровня масштабирования дискретными шагами. Этот тип интерфейса адекватен для карты на веб-сайте, но в случае смартфона его можно описать только одной фразой: «полный отстой».

Это означает, что пришла пора реализовать действительно сенсорный интерфейс, позволяющий плавно масштабировать карту безо всяких скачков.

Начав добавлять сенсорный интерфейс (один палец — панорамирования, два пальца — увеличение и уменьшение масштаба), я испытал чувство глубокого уважения к приложению Maps в Windows Phone и к элементу управления Map в Silverlight. Эти карты явно реализуют куда более изощренный сенсорный интерфейс, чем тот, который получался у меня.

Например, я что-то не припомню, чтобы в приложении Maps хотя раз появлялась черная дыра из-за нехватки какого-либо тайла. Экран всегда полностью покрывается картой, хотя изредка один из тайлов может оказаться растянут до неузнаваемости. Тайлы низкого разрешения заменяются тайлами более высокого разрешения с использованием анимации постепенного гашения. Инерция реализована очень натурально, а UI никогда не прыгает в процессе загрузки тайлов.

Моя программа OrientingMap (которую вы можете скачать) и близко не стояла рядом с приложением Maps. Панорамирование и расширение в ней зачастую происходит скачками, инерции нет и часто появляются пустые области, если тайлы загружаются недостаточно быстро.

Несмотря на все эти недостатки, моя программа все же успешно поддерживает необходимое ориентирование карты.

Основная проблема

Bing Maps SOAP Services предоставляет программе доступ к 256-пиксельным квадратным тайлам карты, из которых можно конструировать более крупные карты. Для вида с дороги и с воздуха Bing Maps предлагает 21 уровень масштабирования, где уровень 1 покрывает Землю четырьмя тайлами, уровень 2 — 16 тайлами, уровень 3 — 64 тайлами и т. д. На каждом уровне разрешение удваивается по горизонтали и вертикали.

Тайлы связаны отношением «предок-потомок»: кроме тайлов на уровне 21, у каждого тайла имеются четыре дочерних тайла на следующем более высоком уровне, которые вместе покрывают ту же область, что и тайл-предок, но с удвоенным разрешением.

Когда программа придерживается цельных уровней масштабирования (как программа, представленная в прошлом месяце), индивидуальные тайлы можно отображать в их реальном размере в пикселях. Предыдущая программа всегда показывала 25 тайлов в массиве 5 × 5 с общим квадратом в 1280 пикселей. Она всегда позиционировала этот массив тайлов так, чтобы центр экрана соответствовал местоположению смартфона на карте, которое находится примерно в центре тайла. Теперь выполните математические операции и вы найдете, что, даже если угол центрального тайла расположен в центре экрана, то этот квадрат размером 1280 пикселей соответствует экрану размером 480 × 800 независимо от того, как он поворачивается.

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

С сенсорным интерфейсом этот простой подход больше неприемлем.

Масштабирование определенно является трудной частью: например, программа начинает с отображения тайлов карты уровня 12 с их размером в пикселях. Теперь пользователь касается экрана двумя пальцами и раздвигает их, чтобы растянуть изображение на экране. Программа должна отреагировать масштабированием тайлов за пределы их размеров по 256 пикселей. Это можно сделать либо применением ScaleTransform к самим тайлам, либо применением ScaleTransform к Canvas, на которой собраны тайлы.

Но вы не захотите неопределенно масштабировать эти тайлы! В какой-то момент вы предпочтете заменить каждый тайл четырьмя дочерними тайлами следующего, более высокого уровня и уменьшить коэффициент масштаба в 2 раза. Этот процесс замены был бы сравнительно тривиальным, если бы дочерние тайлы были мгновенно доступны, но это, конечно же, не так. Их нужно скачивать, а значит, дочерние тайлы должны визуально размещаться поверх предка, и лишь потом, когда будут скачаны все четыре дочерних тайла, этот предок может быть убран с Canvas.

При уменьшении изображения должен происходить обратный процесс. Когда пользователь сводит пальцы вместе, весь массив тайлов может быть уменьшен по масштабу (scaled down), но в некий момент каждая группа из четырех тайлов должна заменяться родительским тайлом, визуально расположенным под этими четырьмя тайлами. Только после того как скачан родительский тайл, четыре дочерних тайла можно убирать.

Дополнительные классы

Как я описывал в рубрике за прошлый месяц, Bing Maps использует для уникальной идентификации тайлов карты систему нумерации, называемую счетверенным ключом (quadkey). Счетверенный ключ — это число по основанию base-4: количество цифр в этом ключе указывает уровень масштабирования, а сами цифры кодируют чередующиеся долготу и широту.

Чтобы помочь программе OrientingMap в работе со счетверенными ключами, проект включает класс QuadKey, который определяет свойства для получения родительского и дочернего счетверенных ключей.

В проекте OrientingMap также имеется новый класс MapTile, производный от UserControl. XAML-файл для этого элемента управления показан на рис. 1. У него есть элемент Image со свойством Source, которому присваивается объект BitmapImage для отображения тайла битовой карты, а также ScaleTransform для масштабирования всего тайла с уменьшением или увеличением. (На практике индивидуальные тайлы масштабируются только положительными или отрицательными целыми степенями 2.) Для отладки я поместил в XAML-файл TextBlock, который показывает счетверенный ключ, и оставил его там: просто измените атрибут Visibility на Visible, чтобы увидеть его.

Рис. 1. Файл MapTile.xaml из OrientingMap

<UserControl x:Class="OrientingMap.MapTile" ... >
  <Grid>
    <Image Stretch="None">
      <Image.Source>
        <BitmapImage x:Name="bitmapImage"
                     ImageOpened="OnBitmapImageOpened" />
      </Image.Source>
    </Image>
    <!-- Отображаем счетверенный ключ для отладки -->
    <TextBlock Name="txtblk"
               Visibility="Collapsed"
               Foreground="Red" />
  </Grid>
  <UserControl.RenderTransform>
    <ScaleTransform x:Name="scale" />
  </UserControl.RenderTransform>
</UserControl>

В файле отделенного кода для MapTile определено несколько удобных свойств: QuadKey (позволяет самому классу MapTile получать URI для доступа к тайлу карты), Scale (позволяет внешнему коду устанавливать коэффициент масштаба), IsImageOpened (указывает, когда битовая карта была скачана) и ImageOpened (предоставляет доступ извне к событию ImageOpened объекта BitmapImage). Последние два свойства помогают программе определять, когда было загружено изображение, чтобы можно было удалить любые тайлы, заменяемые этим изображением.

Разрабатывая эту программу, я поначалу следовал схеме, где каждый объект MapTile использовал бы собственное свойство Scale для определения того, когда его нужно заменить группой из четырех дочерних объектов MapTile или родительским MapTile. Сам MapTile обрабатывал бы создание и позиционирование этих новых объектов, устанавливая обработчики для событий ImageOpened, а также отвечал бы за свое удаление из Canvas.

Но мне не удалось заставить эту схему толком работать. Возьмите массив из 25 тайлов карты, который пользователь расширяет через сенсорный интерфейс. Эти 25 тайлов заменяются 100 тайлами, а те в свою очередь — 400 тайлами. Имеет это смысл? Нет, потому что масштабирование в конечном счете сдвигало множество этих потенциально новых тайлов слишком далеко за границы экрана, чтобы они были видимы. Большую их часть вообще не следовало бы создавать или скачивать!

Тогда я сменил логику и переместил ее в MainPage. Этот класс поддерживает поле currentMapTiles типа Dictionary<QuadKey, MapTile>. В нем хранятся все объекты MapTile, присутствующие в данный момент на экране, даже если они пока в процессе скачивания. Метод RefreshDisplay использует текущее местоположение на карте и коэффициент масштабирования для сборки содержимого поля validQuadKeys типа List<QuadKey>. Если объект QuadKey существует в validQuadKeys, но отсутствует в currentMapTiles, создается новый MapTile и добавляется как в Canvas, так и в currentMapTiles.

RefreshDisplay не удаляет больше ненужные объекты MapTile, потому что они либо смещены за границы экрана при панорамировании, либо заменены родительскими или дочерними объектами. Это обязанность второго важного метода — Cleanup. Этот метод сравнивает набор validQuadKeys с currentMapTiles. Если он находит элемент в currentMapTiles, которого нет в validQuadKeys, то удаляет соответствующий MapTile, только если в validQuadKeys нет дочерних объектов, все дочерние объекты в validQuadKeys загружены или validQuadKeys содержит предок этого MapTile и этот предок был загружен.

Сделать методы RefreshDisplay и Cleanup более эффективными и вызывать их менее часто — один из подходов к повышению производительности OrientingMap.

Вложенные объекты Canvas

UI в программе OrientingMap требует двух типов графических преобразований: трансляции для панорамирования одним пальцем и масштабирования для операций двумя пальцами. Кроме того, ориентирование карты в направлении севера требует преобразования вращения. Чтобы реализовать это на основе эффективных преобразований Silverlight, в файле MainPage.xaml содержатся три уровня панелей Canvas, как показано на рис. 2.

Рис. 2. Большая часть файла MainPage.xaml для OrientingMap

<phone:PhoneApplicationPage x:Class="OrientingMap.MainPage"... >
  <Grid x:Name="LayoutRoot" Background="Transparent">
    <Grid x:Name="ContentPanel" Grid.Row="1" Margin="12">
      <TextBlock Name="errorTextBlock"
                 HorizontalAlignment="Center"
                 VerticalAlignment="Top"
                 TextWrapping="Wrap" />
      <!-- Вращение Canvas относительно центра экрана -->
      <Canvas HorizontalAlignment="Center"
              VerticalAlignment="Center">
        <!-- Трансляция Canvas для панорамирования -->
        <Canvas>
          <!-- Отмасштабированный Canvas для изображений -->
          <Canvas Name="imageCanvas"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center">
              <Canvas.RenderTransform>
                <ScaleTransform x:Name="imageCanvasScale" />
              </Canvas.RenderTransform>
          </Canvas>
          <!-- Окружность для отметки местоположения -->
          <Ellipse Name="locationDisplay"
                   Width="24"
                   Height="24"
                   Stroke="Red"
                   StrokeThickness="3"
                   HorizontalAlignment="Center"
                   VerticalAlignment="Center"
                   Visibility="Collapsed">
            <Ellipse.RenderTransform>
              <TranslateTransform x:Name="locationTranslate" />
            </Ellipse.RenderTransform>
          </Ellipse>
          <Canvas.RenderTransform>
            <TranslateTransform x:Name="imageCanvasTranslate"/>
          </Canvas.RenderTransform>
        </Canvas>
        <Canvas.RenderTransform>
          <RotateTransform x:Name="imageCanvasRotate" />
        </Canvas.RenderTransform>
      </Canvas>
      <!-- Стрелка, указывающая на север -->
      <Border HorizontalAlignment="Left"
              VerticalAlignment="Top"
              Background="Black"
              Width="36"
              Height="36"
              CornerRadius="18">
        <Path Stroke="White"
              StrokeThickness="3"
              Data="M 18 4 L 18 24 M 12 12 L 18 4 24 12">
          <Path.RenderTransform>
            <RotateTransform x:Name="northArrowRotate"
                             CenterX="18"
                             CenterY="18" />
          </Path.RenderTransform>
        </Path>
      </Border>
      <Border Background="Black"
              HorizontalAlignment="Center"
              VerticalAlignment="Bottom"
              CornerRadius="12"
              Padding="3">
        <StackPanel Name="poweredByDisplay"
                    Orientation="Horizontal"
                    Visibility="Collapsed">
          <TextBlock Text=" powered by "
                     Foreground="White"
                     VerticalAlignment="Center" />
          <Image Stretch="None">
            <Image.Source>
              <BitmapImage x:Name="poweredByBitmap" />
            </Image.Source>
          </Image>
        </StackPanel>
      </Border>
    </Grid>
  </Grid>
  ...
</phone:PhoneApplicationPage>

Grid с именем ContentPanel содержит внешний Canvas, а также три элемента, которые всегда отображаются в фиксированных участках экрана: TextBlock (для отчета об ошибках инициализации), Border (содержит поворачивающуюся стрелку, которая указывает направление на север) и еще один Border (для отображения эмблемы Bing).

Свойства HorizontalAlignment и VerticalAlignment внешнего Canvas установлены в Center, что сжимает Canvas до нулевого размера с расположением в центре Grid. Таким образом, координаты (0, 0) этого Canvas находятся в центре экрана. Такое центрирование удобно for позиционирования тайлов, а также позволяет выполнять масштабирование и вращение вокруг начала координат.

Внешним Canvas является тот, который поворачивается в зависимости от направления на север. В этот Canvas вложен второй Canvas, содержащий TranslateTransform. Это необходимо для панорамирования. Всякий раз, когда один палец скользит по экрану, всю карту можно перемещать простым присваиванием свойств X и Y этого TranslateTransform.

Во втором Canvas находится Ellipse, указывающий текущее положение смартфона относительно центра карты. Когда пользователь панорамирует карту, этот Ellipse тоже перемещается. Но если GPS смартфона сообщает об изменении в местонахождении, отдельный TranslateTransform этого Ellipse перемещает его относительно карты.

Внутренний Canvas называется imageCanvas, и именно здесь происходит сборка тайлов карты. К этому Canvas применяется ScaleTransform, чтобы программа могла увеличивать или уменьшать всю сборку тайлов карты в зависимости от того, в какую сторону масштабирует карту пользователь движением двух пальцев.

Чтобы обеспечить непрерывное масштабирование, программа поддерживает поле zoomFactor типа double. Этот zoomFactor имеет тот же диапазон, что и уровни масштабирования (1–21), т. е. на самом деле он содержит значение, которое является логарифмом по основанию 2 коэффициента масштабирования всей карты. Когда zoomFactor увеличивается на 1, масштаб карты удваивается.

При первом запуске программы zoomFactor инициализируется значением 12, но, когда пользователь первый раз касается экрана двумя пальцами, значение этого поля становится дробным, и весьма вероятно, что оно останется таковым и впоследствии. Программа сохраняет zoomFactor, как пользовательскую настройку и заново загружает ее при следующем запуске. Начальный целый baseLevel вычисляется простым усечением:

baseLevel = (int)zoomFactor;

Этот baseLevel всегда является целочисленным значением в диапазоне от 1 до 21 и, следовательно, непосредственно подходит для получения тайлов. По этим двум числам программа вычисляет коэффициент нелогарифмического масштабирования типа double:

canvasScale = Math.Pow(2, zoomFactor - baseLevel);

Это коэффициент масштабирования, применяемый к внутреннему Canvas. Например, если zoomFactor равен 10.5, то для получения тайлов используется baseLevel, равный 10, а canvasScale равен 1.414.

Если начальный zoomFactor составляет 10.9, то, возможно, имеет смысл baseLevel установить в 11, а canvasZoom — в 0.933. Программа этого не делает, но, очевидно, что это не помешало бы.

Сенсорный ввод одним и двумя пальцами

В случае сенсорного ввода мне удобнее пользоваться XNA TouchPanel, а не событиями Silverlight Manipulation. Конструктор MainPage поддерживает четыре типа XNA-жестов: FreeDrag (панорамирование), DragComplete, Pinch и PinchComplete. TouchPanel проверяется на ввод в обработчике события CompositionTarget.Rendering, как показано на рис. 3. Из-за его сложности здесь приведена лишь небольшая часть обработки Pinch.

Рис. 3. Обработка сенсорного ввода в OrientingMap

void OnCompositionTargetRendering(object sender, EventArgs args)
{
  while (TouchPanel.IsGestureAvailable)
  {
    GestureSample gesture = TouchPanel.ReadGesture();
    switch (gesture.GestureType)
    {
      case GestureType.FreeDrag:
        // Подстраиваем дельту для поворачивания холста
        Vector2 delta = TransformGestureToMap(gesture.Delta);
        // Транслируем холст
        imageCanvasTranslate.X += delta.X;
        imageCanvasTranslate.Y += delta.Y;
        // Подстраиваем долготу и широту центра
        centerRelativeLongitude -= delta.X / (1 << baseLevel + 8) / canvasScale;
        centerRelativeLatitude -= delta.Y / (1 << baseLevel + 8) / canvasScale;
        // Суммируем расстояние панорамирования
        accumulatedDeltaX += delta.X;
        accumulatedDeltaY += delta.Y;
        // Проверяем, достаточно ли этого,
        // чтобы гарантировать обновление экрана
        if (Math.Abs(accumulatedDeltaX) > 256 ||
            Math.Abs(accumulatedDeltaY) > 256)
        {
          RefreshDisplay();
          accumulatedDeltaX = 0;
          accumulatedDeltaY = 0;
        }
        break;
      case GestureType.DragComplete:
        Cleanup();
        break;
      case GestureType.Pinch:
        // Получаем старую и новую позиции пальца
        // относительно начала координат холста
        Vector2 newPoint1 = gesture.Position - canvasOrigin;
        Vector2 oldPoint1 = newPoint1 - gesture.Delta;
        Vector2 newPoint2 = gesture.Position2 - canvasOrigin;
        Vector2 oldPoint2 = newPoint2 - gesture.Delta2;
        // Поворачиваем в соответствии с текущим углом поворота
        oldPoint1 = TransformGestureToMap(oldPoint1);
        newPoint1 = TransformGestureToMap(newPoint1);
        oldPoint2 = TransformGestureToMap(oldPoint2);
        newPoint2 = TransformGestureToMap(newPoint2);
        ...
        RefreshDisplay();
        break;
      case GestureType.PinchComplete:
        Cleanup();
        break;
    }
  }
}

Ввод FreeDrag сопровождается значениями Position и Delta (оба имеют тип Vector2), указывающими текущую позицию пальца и то, как палец двигался с момента последнего события TouchPanel. Ввод Pinch дополняет эти данные значениями Position2 и Delta2 для второго пальца.

Однако учтите, что эти Vector2-значения — экранные координаты! Поскольку карта поворачивается относительно экрана и пользователь ожидает, что карта будет панорамироваться в направлении движения пальца, эти значения должны вычисляться на основе текущего поворота карты, что происходит в небольшом методе TransformGestureToMap.

При обработке FreeDrag значение дельты применяется к TranslateTransform в XAML-файле, а также к двум полям с плавающей точкой: centerRelativeLongitude и centerRelativeLatitude. Эти значения варьируются от 0 до 1 и указывают долготу и широту, соответствующую центру экрана.

В некий момент пользователь может панорамировать карту настолько, что потребуется загрузка новых тайлов. Чтобы избежать проверки на эту возможность при каждом событии касания, программа поддерживает два поля с именами accumulatedDeltaX и accumulatedDeltaY и вызывает RefreshDisplay, только когда любое из значений превышает 256 (размер тайлов карты в пикселях).

Поскольку RefreshDisplay вынужден выполнять большой объем работы — определять, какие тайлы должны быть видимы на экране на основе centerRelativeLongitude и centerRelativeLatitude и текущего canvasScale, а также создавать новые тайлы при необходимости, — лучше всего не вызывать его при каждом изменении в сенсорном вводе. Например, явно следует ограничить вызовы RefreshDisplay при вводе Pinch.

При обработке касания метод Cleanup вызывается, только когда палец или пальцы перестают касаться экрана. Cleanup также вызывается всякий раз, когда заканчивается загрузка тайла карты.

Критерий изменения baseLevel (а значит, и инициация замены родительского тайла дочерними или дочерних тайлов родительским) очень нестрогий. Значение baseLevel приращивается, только когда canvasScale превышает значение 2, и уменьшается, когда canvasScale падает ниже 0.5. Задание более точных точек перехода было бы еще одним очевидным усовершенствованием программы.

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

Теперь осталось выяснить, как эта программа будет помогать мне не заблудиться в торговых центрах и музеях.


Чарльз Петцольд (Charles Petzold) — давний «пишущий» редактор MSDN Magazine и автор книги «Programming Windows, 6th edition» (O’Reilly Media, 2012) о написании приложений для Windows 8. Его веб-сайт находится по адресу is charlespetzold.com.

Выражаю благодарность за рецензирование статьи эксперту Томасу Петчелу (Thomas Petchel).