Увлекся я в последнее время кодингом на 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-у все равно какие там контейнеры). Запускаем, щупаем результат, печалимся:
- Scale изменяет размер Image непропорционально (ожидаемо) и слишком резко, картинка практически мгновенно “улетает” из-под пальцев.
- Translate работает адекватно только в изначальном масштабе (то бишь, единичном). Затем изображение двигается либо слишком медленно, либо слишком быстро.
- Естественно, 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, исходный код прилагается для всех желающих. Фотографию Венеции любезно предоставил сайт стоковых фотографий :-)
Надеюсь, мой опыт кому-нибудь пригодится. Тем более, что в Сети я не нашел
ни одного примера с правильным зумом (везде какие-то косяки). Удачного кодинга!
Комментариев нет:
Отправить комментарий