5 января 2014 г.

Pinch To Zoom на Windows Phone 7 и 8

Увлекся я в последнее время кодингом на Windows Phone, и дело дошло до того, что пару дней назад отправил в Windows Phone Store свой, как говорится, додаток, и теперь нахожусь в волнительном созидании статуса PENDING CERTIFICATION. Но я не об этом. В процессе написания приложеньица столкнулся с одной проблемой (хотя нет, одной из многих): мне нужен был контрол для отображения в нем картинки как в стандартной галерее на телефоне, чтобы двумя пальцами можно было зумить, одним пальцем двигать без вылетов за границы экрана, а двойным тапом по экрану эту картинку приближать и отдалять. Казалось бы, стандартная операция для всех современных смартфонов. Да что уж там, даже обычных телефонов. Ан нет. И тут нас обделили. Такого контрола нет ни в одном бесплатном Toolkit-е. Нет его и на Android, хотя там народ уже нарукоделил разных альтернатив. Что делать, отправляемся в плавание по просторам Всемирной сети.

И ничего толком не находим. Хотя не совсем, в Windows Phone 8 есть PinchManipulation, но его тоже придется вручную оборачивать в контрол, то есть не безнадежно. Но если вы хороший разработчик, и делаете приложение одновременно на Windows Phone 7 и 8, то это не вариант, т.к. на первой платформе такого свойства нет. В Windows Phone 7 нам предлагали воспользоваться GestureService, входящим в состав WP Toolkit, но народ в этой самой сети постоянно жалуется на его нестабильную работу, а Windows Phone 8 он вообще официально уже не поддерживается. Придется как-то выкручиваться. Первое на что вы, возможно, набредете – это так называемые Manipulation Events, которые достались Windows Phone в наследство от WPF. Это три события, которые есть в любом контроле, унаследованном от UIElement, то есть да, в любом.

Секрет успеха предлагается следующий: нашему Image присваиваем RenderTransform в обличии CompositeTransform, далее хватаем событие ManipultionDelta и практически не прилагая никаких усилий, увеличиваем/уменьшаем и двигаем наш Image на дельту, которая в вышеупомянутом событии магическим образом возникает.
<Grid x:Name="LayoutRoot">
    <Image x:Name="Image" Source="/Assets/Venice.jpg"
           ManipulationDelta="Image_OnManipulationDelta">
        <Image.RenderTransform>
            <CompositeTransform x:Name="CompositeTransform"/>
        </Image.RenderTransform>
    </Image>
</Grid>
private void Image_OnManipulationDelta(object sender, ManipulationDeltaEventArgs e)
{
    // Scale
    if (e.DeltaManipulation.Scale.X != 0)
        CompositeTransform.ScaleX *= e.DeltaManipulation.Scale.X;
    if (e.DeltaManipulation.Scale.Y != 0)
        CompositeTransform.ScaleY *= e.DeltaManipulation.Scale.Y;

    // Translate
    CompositeTransform.TranslateX += e.DeltaManipulation.Translation.X;
    CompositeTransform.TranslateY += e.DeltaManipulation.Translation.Y;

    e.Handled = true;
}
Такой подход предлагается, например, тут (хотя автор предлагает использовать Canvas вместо стандартного Grid и фиксированные размеры Image, все это не влияет на результат, так как RenderTransform-у все равно какие там контейнеры). Запускаем, щупаем результат, печалимся:
  1. Scale изменяет размер Image непропорционально (ожидаемо) и слишком резко, картинка практически мгновенно “улетает” из-под пальцев.
  2. Translate работает адекватно только в изначальном масштабе (то бишь, единичном). Затем изображение двигается либо слишком медленно, либо слишком быстро.
  3. Естественно, scale и translate ничем не ограничены, поэтому картинка рискует улететь за пределы экрана и не вернуться :-(
Начнем по порядку. Для пропорционального масштабирования картинки нужно, чтобы CompositeTransform.ScaleX и CompositeTransform.ScaleY были всегда равны. Но на вход мы получаем равные e.DeltaManipulation.Scale.X и e.DeltaManipulation.Scale.Y только в том случае, если двигаем пальцами по диагонали. Во всех остальных случаях картинку плющит с разных сторон, а это совсем не то, что нам нужно. Сначала мне пришло в голову взять только одну координату, скажем Scale.X, но тогда при движении пальцами по вертикали картинка вообще масштабироваться не будет. Тут, как говорится, закроем глаза и представим себе вектор. Если e.DeltaManipulation.Scale.X и e.DeltaManipulation.Scale.Y это его координаты, то длина такого вектора – как раз то, что нам нужно. А найти длину вектора дело нехитрое: корень из суммы квадратов обеих координат. Теперь еще нужно не забыть, что мы имеем дело с дельта-значениями, а изначальный масштаб не 0,0, а 1,1, поэтому получившуюся длину вектора нужно поделить на корень из двух (длина вектора с координатами 1,1), получив, таким образом, нужный нам множитель.

Проблему с translate решить еще проще: нужно перемножить дельта-координаты с уже действующими значениями CompositeTransform.ScaleX и CompositeTransform.ScaleY. Manipulation Events ведь не знают, что мы там уже намасштабировали. И вот, что получится:

private void Image_OnManipulationDelta(object sender, ManipulationDeltaEventArgs e)
{
    const double epsilon = 0.01;

    var uniformScale = Math.Sqrt(Math.Pow(e.DeltaManipulation.Scale.X, 2) +
                                 Math.Pow(e.DeltaManipulation.Scale.Y, 2));

    if (Math.Abs(uniformScale) > epsilon)
    {
        // Scale
        var scaleFactor = uniformScale / Math.Sqrt(2);
        CompositeTransform.ScaleX *= scaleFactor;
        CompositeTransform.ScaleY *= scaleFactor;
    }

    // Translate
    CompositeTransform.TranslateX += e.DeltaManipulation.Translation.X * 
                                     CompositeTransform.ScaleX;
    CompositeTransform.TranslateY += e.DeltaManipulation.Translation.Y *
                                     CompositeTransform.ScaleY;

    e.Handled = true;
}
В добавок ко всему, я избавился от прямых сравнений double с нулем, о чем нас любезно предупредит ReSharper. Запускаем, проверяем, недолго радуемся. Не долго, потому что, во первых, изображение все еще ускользает из-под пальцев во время масштабирования, а оно должно к ним “прилипать”, а во вторых… об этом чуть позже.

Дело в том, что мы масштабируем картинку относительно координат 0,0, в то время как классический Pinch To Zoom работает иначе: он масштабирует относительно точки, которая находится ровно между пальцами. Иначе говоря, пиксель между двумя пальцами должен всегда находиться на своем месте. К счастью, для нас уже предусмотрели e.ManipulationOrigin, которым мы и воспользуемся. Если взять в руки бумажку и ручку, провести некоторые инженерные расчеты (нарисовать два квадратика), то можно прийти к следующей формуле: сразу после масштабирования картинку нужно сдвинуть на разницу между первоначальной точкой e.ManipulationOrigin и ней же, только умноженной на scaleFactor. И не забыть все это перемножить на текущие CompositeTransform.ScaleX и CompositeTransform.ScaleY, ведь Manipulation Events их не запоминают. В результате, у нас получится следующий код:

private void Image_OnManipulationDelta(object sender, ManipulationDeltaEventArgs e)
{
    const double epsilon = 0.01;

    var uniformScale = Math.Sqrt(Math.Pow(e.DeltaManipulation.Scale.X, 2) +
                                 Math.Pow(e.DeltaManipulation.Scale.Y, 2));

    if (Math.Abs(uniformScale) > epsilon)
    {
        // Scale
        var scaleFactor = uniformScale / Math.Sqrt(2);
        CompositeTransform.ScaleX *= scaleFactor;
        CompositeTransform.ScaleY *= scaleFactor;
        
        // Consider scale origin
        var originX = e.ManipulationOrigin.X;
        var originY = e.ManipulationOrigin.Y;
        var dx = originX - originX * scaleFactor;
        var dy = originY - originY * scaleFactor;
        CompositeTransform.TranslateX += dx * CompositeTransform.ScaleX;
        CompositeTransform.TranslateY += dy * CompositeTransform.ScaleY;
    }

    // Translate
    CompositeTransform.TranslateX += e.DeltaManipulation.Translation.X *
                                     CompositeTransform.ScaleX;
    CompositeTransform.TranslateY += e.DeltaManipulation.Translation.Y *
                                     CompositeTransform.ScaleY;

    e.Handled = true;
}
Дело осталось за малым: ограничить уровень масштабирования от 1 до, скажем, 4, сделать так, что бы картинка не вылетала за границы LayoutRoot, при чем ограничить ее передвижение нужно как бы “изнутри”, как это сделано в галерее. А в этом нам поможет волшебный метод TransformToVisual, который безвозмездно и вполне успешно конвертирует одни координаты в другие, и наоборот. И изменение масштаба по двойному тапу, но это уже довольно тривиальные задачи (смотрим в сторону события DoubleTap). По хорошему, конечно, нужно вынести масштабирование и перемещение в отдельные методы, что я, в общем-то, и сделал в примерах ниже.

И еще один важный момент, о котором часто забывают. Настоятельно рекомендую в XAML к нашему дорогому Image добавить атрибут CacheMode="BitmapCache", который тесно подружит нашу картинку с GPU. Задержки станут меньше, картинка станет двигаться шустрее.

А теперь, как и обещал, о плохом. Если кто использовал Manipulation Events на Windows Phone, то наверное заметил, что ведут они себя “слегка” неадекватно. А именно: e.DeltaManipulation.Scale очень часто выдает рандомные значения. Чтобы окончательно его свести с ума, достаточно взять два пальца и (что бы вы могли подумать) очертить ими окружность по экрану телефона. А иногда достаточно просто свести пальцы близко друг к другу, как этот самый Scale начинает выдавать то отрицательные, то положительные значения, никак не связанные друг с другом. Вот пример (спасибо Debug.WriteLine-у):

X: 0,00, Y: 0,00
X: -2,01, Y: -0,05
X: 1,03, Y: 0,80
X: 1,02, Y: 0,50
X: 1,02, Y: 0,25
X: 1,05, Y: -22,00
X: 1,03, Y: 2,05
X: 1,03, Y: 1,42

Буквально за один шаг, то есть доли секунды, масштаб становится то в 2 раза больше, то в минус 22 раза меньше. Откуда эти цифры берутся – для меня загадка. Если их фильтровать (хотя как, это еще вопрос), то масштабирование получается рваным. Пробовал интерполировать, картинка опять “отлипает” от пальцев, и получается не так плавно, как в галерее (даже в браузере, и то лучше). В общем, для меня стало очевидным (как говорится, необъяснимо но факт), что сама Microsoft не использует эти события ни в одном из своих приложений. Я уверен, что не используют их и сторонние (платные) компоненты. И я не буду. И вы не используйте :-)

Но не все так плохо! И большая часть кода выше еще пригодится. Дело в том, что мы можем опуститься на уровень ниже и получать экранные координаты пальцев прямо с сенсора. Не, не ассемблер. Touch.FrameReported!

Забегая вперед скажу, что работает он отлично.

Учитывая, что событие это – статическое, то главное не забыть от него в нужный момент отписаться. Идеальным для этого местом станет другое событие – Unloaded, которое присутствует в любом FrameworkElement, коим, кончено, и является наш с вами Image. Обработчик самого FrameReported выглядит следующим образом:

private void Touch_OnFrameReported(object sender, TouchFrameEventArgs e)
{
    var points = e.GetTouchPoints(Image);
    if (points.Count == 1)
    {
        // Translate...
    }
    else if (points.Count == 2)
    {
        // Zoom...
    }
}
К счастью, разработчики не оставили нас наедине с абсолютными координатами, и предоставили метод e.GetTouchPoints, готовый в любой момент вернуть локальные координаты для любого IInputElement, которым, по счастливому стечению обстоятельств, и является Image. Если точка одна – двигаем картинку за пальцем, если две – масштабируем. Кроме того, нам понадобятся две вспомогательные функции, одна из которых посчитает расстояние между двумя точками (раз плюнуть), а другая – координаты точки между двумя пальцами. Я обозвал их Distance и Lerp соответственно:
public static double Distance(Point p1, Point p2)
{
    var dx = p1.X - p2.X;
    var dy = p1.Y - p2.Y;
    return Math.Sqrt(dx * dx + dy * dy);
}
public static Point Lerp(Point p1, Point p2, double blend = 0.5)
{
    return new Point(p1.X + blend * (p2.X - p1.X),
                     p1.Y + blend * (p2.Y - p1.Y));
}
Дальше дело техники. Реюзаем наш код, написанный выше для Manipulation Events, и вуаля, получаем плавное масштабирование картинки!

Для большей наглядности я оформил все вышесказанное в приложение на Windows Phone 7 (которое невозбранно переносится и на WP8). В нем можно воочию оценить ту необъятную пропасть между Manipulaton Events и Touch.FrameReported, исходный код прилагается для всех желающих. Фотографию Венеции любезно предоставил сайт стоковых фотографий :-)

Надеюсь, мой опыт кому-нибудь пригодится. Тем более, что в Сети я не нашел ни одного примера с правильным зумом (везде какие-то косяки). Удачного кодинга!

Комментариев нет:

Отправить комментарий