Las fronteras de las UI

La inercia en el multitoque

Charles Petzold

Descargar el ejemplo de código

Parece que las UI en los equipos son más llamativas cuando enmascaran la naturaleza digital de la tecnología subyacente y, en su lugar, imitan la apariencia análoga del mundo real. Esta tendencia comenzó cuando las interfaces gráficas empezaron a reemplazar las líneas de comandos y después continuaron con el mayor uso de fotografías, sonido y otros medios.

La incorporación del multitoque en las pantallas de vídeo ha proporcionado un estímulo adicional al proceso de lograr que los controles de usuario sean más reales e intuitivos. Creo que usted sólo ha comenzado a vislumbrar el potencial de la revolución del toque para modernizar su relación con la pantalla del equipo.

Quizás algún día incluso eliminemos uno de los vestigios más feos de la tecnología digital conocidos por el hombre: el control de volumen de botón de comando. El dial básico de volumen (y otras configuraciones) de radios y televisores era encantadoramente sencillo, pero lo han reemplazado aparatosos botones de comando en controles remotos y en los aparatos propiamente tales.

Las interfaces de los equipos suelen ofrecer controles deslizantes para el volumen. Son casi tan buenos como los diales. Pero los controles de volumen de botón de comando siguen siendo persistentes. Incluso la pantalla multitoque de Zune HD desea que presione signos más y menos para subir o bajar el volumen. Sería preferible un dial que respondiera al toque.

Me gustan los diales para multitoque. Quizás es por eso que me centré en estimulantes diales en mi artículo “Estilo de un toque: Exploración de soporte técnico multitoque en Silverlight”, en el número de marzo de 2010 de MSDN Magazine (msdn.microsoft.com/magazine/ee336026) y por eso que volví a los diales para hablar acerca de la inercia en el multitoque de Windows Presentation Foundation (WPF).

El evento de la inercia

Una de las maneras en que una interfaz de multitoque intenta imitar al mundo real es al introducir la inercia: la tendencia para que los objetos mantengan la misma velocidad a menos que intervengan otras fuerzas, como la fricción. En interfaces de multitoque, la inercia puede mantener un objeto visual en movimiento cuando los dedos dejaron la pantalla. La aplicación más común de inercia en el toque se encuentra en las listas de navegación y ya está presente en ListBox de WPF.

Entre el código descargable para este artículo se encuentra un programa llamado IntertialListBoxDemo que simplemente completa un ListBox de WPF con varios elementos para que pueda probarlo en su pantalla multitoque.

En sus propios controles de WPF, tendrá que decidir cuánta inercia desea, si la hay. Administrar la inercia es lo más fácil si sólo desea que provoque la misma respuesta desde su programa que por manipulación directa. Lo difícil es comprender correctamente los valores numéricos involucrados.

Como habrá visto en artículos anteriores, se informa a un elemento WPF del inicio de manipulación multitoque con dos eventos: ManipulationStarting (el cual es una buena oportunidad para realizar un poco de inicialización) y ManipulationStarted. A estos dos eventos los siguen, a continuación, posiblemente muchos eventos ManipulationDelta, los cuales consolidan la acción de uno, dos o más dedos en la traslación, escalación y rotación de la información.

Cuando todos los dedos se han levantado del elemento que se está manipulando, se produce un evento ManipulationInertiaStarting. Esta es la oportunidad de su programa de especificar cuánta inercia desea. (Analizaré el cómo, brevemente.) El efecto de la inercia adquiere la forma de eventos ManipulationDelta adicionales hasta que el objeto deja de ejecutarse y un evento ManipulationCompleted señala el fin. Si no hace nada con ManipulationInertiaStarting, lo sigue inmediatamente ManipulationCompleted.

El uso del evento ManipulationDelta para indicar manipulaciones e inercia hace que todo este esquema sea extremadamente fácil de usar. Básicamente, puede implementar inercia con una instrucción única en el evento ManipulationInertiaStarting. Si es necesario, puede discernir la diferencia entre manipulación directa e inercia mediante la propiedad IsInertial de ManipulationDeltaEventArgs.

Como verá, usted especifica cuánta inercia desea de una de dos maneras diferentes, pero ambas implican desaceleración: una disminución en la velocidad por un período de tiempo hasta que llega a cero y el objeto deja de moverse. Esto involucra algunos conceptos de física con los que la mayoría de los programadores no se topa a menudo, de manera que quizás un pequeño recordatorio no está de más.

Recordatorio sobre aceleración

Si algo se mueve, se dice que tiene una rapidez o velocidad que se puede expresar en unidades de distancia por tiempo. Si la velocidad misma cambia en el curso del tiempo, entonces el objeto tiene aceleración. Una aceleración negativa (que indica una disminución de la velocidad) a menudo se denomina desaceleración.

Durante este análisis, supongamos que la aceleración o desaceleración misma es constante. Una aceleración constante significa que la velocidad cambia de manera lineal: una cantidad igual por unidad de tiempo. Si la aceleración es 0, la velocidad permanece constante.

Por ejemplo, los fabricante de automóviles a veces para publicitar sus vehículos aseguran que pueden acelerar de 0 a 60 mph en determinada cantidad de segundos, digamos 8 segundos. El vehículo está inicialmente detenido pero hacia el final de los 8 segundos va a 60 mph, como se muestra en la Figura 1.

Figura 1 Aceleración

Segundos Velocidad
0 0 km/h
1 7.5
2 15
3 22.5
4 30
5 37.5
6 45
7 52.5
8 60

Observe que la velocidad aumenta la misma cantidad cada segundo. La aceleración se expresa como el cambio en velocidad durante un período de tiempo determinado o, en este caso, 7,5 mph.

Ese valor de aceleración es un poco complicado porque hay dos unidades de tiempo involucradas: horas y segundos. Olvidémonos de las horas y usemos sólo los segundos. Como 60 mph corresponden a 88 pies por segundo, se podría decir que el vehículo de manera equivalente va de 0 a 88 pies por segundo en 8 segundos. La aceleración es de 11 pies por segundo por segundo o (como se suele decir) 11 pies por segundo al cuadrado.

Podemos volver a millas y horas, pero las unidades se tornan ridículas ya que la aceleración se calcula como 27.000 mph al cuadrado. Esto implica que al final de la hora, el vehículo está viajando a 27.000 mph. Desde luego no es así. En la vida real, tan pronto como el vehículo alcanza las 60 mph o un valor cercano, la velocidad se nivela y la aceleración baja a 0. Eso es un cambio en la aceleración, lo cual en círculos de ingeniería se denomina tirón.

Pero para este ejercicio, estamos suponiendo una aceleración constante. Partiendo a una velocidad de 0, como resultado de una aceleración a, la distancia x recorrida durante el tiempo t es:

image: equation, x equals one-half a t squared

½ y cuadrado son necesarios porque la velocidad v se calcula como el primer derivado de la distancia con respecto del tiempo:

image: equation, v equals d x over d t equals a t

Si a es 11 pies por segundo al cuadrado, entonces a tigual a 1 segundo, la velocidad es de 11 pies por segundo, pero el vehículo sólo ha recorrido 5,5 pies. En t igual a 2 segundos, la velocidad es de 22 pies por segundo y el vehículo ha recorrido un total de 22 pies. Durante cada segundo, el vehículo recorre una distancia basada en la velocidad promedio durante ese segundo. En tigual a 8 segundos, la velocidad es de 88 pies por segundo y el vehículo ha recorrido un total de 352 pies.

La inercia en el multitoque es como ver el vehículo en reversa: al momento en que los dedos se levantan de la pantalla, el objeto tiene cierta velocidad. La aplicación especifica una desaceleración que provoca que la velocidad del objeto disminuya de manera lineal a 0. Mientras mayor sea la desaceleración, más rápido se detiene el objeto. Una desaceleración de 0 causa que el objeto se siga moviendo a la misma velocidad para siempre.

Dos maneras para desacelerar

Los argumentos del evento ManipulationDelta incluye y una propiedad Velocities de tipo ManipulationVelocities, la cual en sí misma tiene propiedades correspondientes a los tres tipos de manipulación:

  • LinearVelocity de tipo Vector
  • ExpansionVelocity de tipo Vector
  • AngularVelocity de tipo doble

Las primeras dos propiedades se expresan como unidades independientes del dispositivo por milisegundo; la tercera está en ángulos (en grados) por milisegundo. Desde luego, el milisegundo no es un período de tiempo intuitivamente comprensible, así que si necesita mirar esos valores y obtener un sentido de lo que significan, le conviene multiplicarlos por 1.000 para convertirlos a segundos.

Las aplicaciones a menudo no hacen uso de las propiedades Velocity, pero proporcionan velocidades iniciales para la inercia. Cuando los dedos del usuario abandonan la pantalla, se produce un evento ManipulationInertiaStarting. Los argumentos del evento incluyen estas tres propiedades que le permiten especificar la inercia de manera separada para esos mismos tres tipos de manipulación:

  • TranslationBehavior de tipo InertiaTranslationBehavior
  • ExpansionBehavior de tipo InertiaExpansionBehavior
  • RotationBehavior de tipo InertiaRotationBehavior

Cada una de estas clases tiene una propiedad InitialVelocity, una propiedad DesiredDeceleration y una tercera propiedad denominada DesiredDisplacement, DesiredExpansion o DesiredRotation, dependiendo de la clase.

En el caso de la traslación y rotación, todas las propiedades Desired tienen valores predeterminados de Double.NaN, esa configuración especial de bits que indica “no es un número”. Para la expansión, las propiedades Desired son de tipo Vector con valores X e Y de Double.NaN, pero el concepto es el mismo.

Centrémonos primero en la inercia rotativa, porque es un efecto familiar del mundo real (como un carrusel) y no tiene que preocuparse por los objetos que se escapan de la pantalla.

Para el momento en que obtiene un evento ManipulationInertiaStarting, se ha creado el objeto InertiaRotationBehavior y se ha configurado el valor InitialVelocity a partir del evento anterior ManipulationDelta. Si InitialVelocity es 1,08, por ejemplo, eso es 1,08 grados por milisegundo; o 1.080 grados por segundo; o tres revoluciones por segundo; o 180 rpm.

Para que el objeto siga girando, debe configurar DesiredRotation o DesiredDeceleration, pero no ambos. Si intenta configurar ambos, el último que configure será válido y el otro será Double.NaN.

La primera opción es configurar DesiredRotation como un valor en grados. Es la cantidad de grados que el objeto girará antes de detenerse. S configura DesiredRotation en 360, por ejemplo, el objeto que gira hará sólo una revolución adicional, con lo cual disminuirá la velocidad y se detendrá en el proceso. La ventaja es que obtiene la misma cantidad de actividad de inercia independientemente de la velocidad inicial, de manera que es fácil predecir lo que sucederá. La desventaja es que no es del todo natural.

La alternativa es configurar DesiredDeceleration en un valor en unidades de grados por milisegundo al cuadrado y aquí es donde se vuelve complicado porque es difícil adivinar un buen valor.

Si InitialVelocity es 1, 08 grados por milisegundo y configura DesiredDeceleration en 0,01 grados por milisegundo al cuadrado, entonces la velocidad disminuirá en 0,01 grados por milisegundo: a 1,07 grados por milisegundo al final del primer milisegundo; 1,06 grados por milisegundo al final del segundo milisegundo y así sucesivamente linealmente hasta que la velocidad llegue a 0. Todo ese proceso tomará 108 ms o un poco más que una décima de segundo.

Probablemente dese configurar DesiredDeceleration en algo menos que eso, quizás 0,001 grados por milisegundo al cuadrado, lo cual provocará que el objeto siga girando por 1,08 segundos.

Cuidado si desea convertir las unidades de desaceleración en algo un poco más sencillo para la mente humana. Una velocidad de 1,08 grados por milisegundo es lo mismo que 1.080 grados por segundo, pero una desaceleración de 0,001 grados por milisegundo al cuadrado es 1.000 grados por segundo al cuadrado. Para convertir la desaceleración a segundos, debe multiplicar por 1.000 dos veces porque el tiempo está al cuadrado.

Si combina las dos fórmulas mostradas anteriormente y elimina t, obtiene:

Image: equation, a equals v squared over 2 x

Esto implica que los dos métodos para configurar la desaceleración son equivalentes y se pueden convertir de uno al otro. Si InitialVelocity es 1, 08 grados por milisegundo y configura DesiredDeceleration en 0,001 grados por milisegundo al cuadrado, eso equivale a configurar DesiredRotation en 583,2 grados. En cualquier caso, la rotación se detiene después de 1.080 ms.

Experimentación

Para obtener una sensación de la inercia rotativa, creé el programa RotationalInertiaDemo que aparece en la Figura 2.

image: RotationalInertiaDemo Program in Action

Figura 2 Programa RotationalInertiaDemo en acción

La rueda a la izquierda es lo que usted gira con el dedo, ya sea a la derecha o a la izquierda. Es muy sencillo: simplemente una derivada UserControl con dos elementos Ellipse en una Grid con todos los eventos Manipulation administrador en MainWindow.

El evento ManipulationStarting realiza inicialización al restringir la manipulación a rotación únicamente, lo cual permite una rotación de un solo dedo y configurar el centro de la rotación:

args.IsSingleTouchEnabled = true;
args.Mode = ManipulationModes.Rotate;
args.Pivot = new ManipulationPivot(
             new Point(ctrl.ActualWidth / 2, 
                       ctrl.ActualHeight / 2), 50);

El acelerómetro a la derecha es una clase llamada ValueMeter y muestra la velocidad actual de la rueda. Si parece vagamente familiar, es sólo porque es una versión mejorada de una plantilla ProgressBar de un artículo que escribí para esta revista hace más de tres años. Las mejoras implicaron algo de flexibilidad con las etiquetas para poder usarla a fin de mostrar la velocidad en cuatro unidades distintas. GroupBox en el medio de la ventana le permite seleccionar esas unidades.

Cuando el dedo gira el dial, el medidor muestra la velocidad angular actual disponible desde la subpropiedad Velocities.AngularVelocity de los argumentos del evento ManipulationDelta. Pero descubrí que era imposible encauzar la velocidad del dial directamente a ValueMeter. El resultado era demasiado impreciso. Tuve que escribir una pequeña clase ValueSmoother para obtener un promedio ponderado de todos los valores del segundo pasado. El controlador del evento ManipulationDelta también configura un objeto RotateTransform que en realidad gira el dial.

rotate.Angle += args.DeltaManipulation.Rotation;

Por último, el control deslizante de la parte inferior le permite seleccionar un valor de desaceleración. El valor del control deslizante sólo se lee cuando el dedo abandona el dial y se activa un evento ManipulationInertiaStarted:

args.RotationBehavior.DesiredDeceleration = slider.Value;

Ese es todo el cuerpo del controlador de eventos ManipulationInertiaStarted. Durante la fase inicial de manipulación, los valores de velocidad son mucho más regulares y no es necesario emparejarlos, de manera que el controlador ManipulationDelta usa la propiedad IsInertial para determinar cuándo pasar los valores de velocidad directamente al medidor.

Límites y rebote

El uso más común de la inercia con multitoque es mover objetos por la pantalla, como desplazarse por una lista larga o mover elementos hacia un costado. ¡El gran problema es que es muy fácil ocasionar que un elemento salga volando de la pantalla!

Pero en el proceso de manejar ese pequeño problema, descubrirá que WPF cuenta con una característica integrada que hace que la inercia por manipulación sea incluso más realista. Es posible que haya observado antes en el programa ListBoxDemo que cuando dejaba que la inercia se desplazara al final o al principio de la lista, toda la ventana rebotaba un poco cuando ListBox llegaba al final. Ese también es un efecto que puede obtener en sus propias aplicaciones (si lo desea).

El programa BoundaryDemo consta de sólo una elipse con un MatrixTransform de identidad configurado en su propiedad RenderTransform localizada en una cuadrícula denominada mainGrid. Sólo la traslación se activa durante la anulación de OnManipulationStarting. El método OnManipulationInertiaStarting configura la desaceleración de inercia más o menos así:

args.TranslationBehavior.DesiredDeceleration = 0.0001;

Eso equivale a 0,0001 unidades independientes del dispositivo por milisegundo al cuadrado o 100 unidades independientes del dispositivo por segundo al cuadrado o 1 pulgada por segundo al cuadrado.

La anulación de OnManipulationDelta se muestra en la Figura 3. Tenga en cuenta la manipulación especial cuando IsInertial es verdadero. La idea detrás del código es que los factores de traslación se deben atenuar cuando la elipse se ha apartado parcialmente de la pantalla. El factor de atenuación es 0 si la elipse está dentro de mainGrid y sube a 1 cuando la elipse está medio camino más allá del límite de mainGrid. El factor de atenuación se aplica entonces al vector de traslación entregado al método (denominado totalTranslate) para calcular usableTranslate, lo cual proporciona los valores aplicados en realidad a la matriz de transformación.

Figura 3 El evento OnManipulationDelta en BoundaryDemo

protected override void OnManipulationDelta(
  ManipulationDeltaEventArgs args) {

  FrameworkElement element = 
    args.Source as FrameworkElement;
  MatrixTransform xform = 
    element.RenderTransform as MatrixTransform;
  Matrix matx = xform.Matrix;
  Vector totalTranslate = 
    args.DeltaManipulation.Translation;
  Vector usableTranslate = totalTranslate;

  if (args.IsInertial) {
    double xAttenuation = 0, yAttenuation = 0, attenuation = 0;

    if (matx.OffsetX < 0)
      xAttenuation = -matx.OffsetX;
    else
      xAttenuation = matx.OffsetX + 
        element.ActualWidth - mainGrid.ActualWidth;

    if (matx.OffsetY < 0)
      yAttenuation = -matx.OffsetY;
    else
      yAttenuation = matx.OffsetY + 
        element.ActualHeight - mainGrid.ActualHeight;

    xAttenuation = Math.Max(0, Math.Min(
      1, xAttenuation / (element.ActualWidth / 2)));

    yAttenuation = Math.Max(0, Math.Min(
      1, yAttenuation / (element.ActualHeight / 2)));

    attenuation = Math.Max(xAttenuation, yAttenuation);

    if (attenuation > 0) {
      usableTranslate.X = 
        (1 - attenuation) * totalTranslate.X;
      usableTranslate.Y = 
        (1 - attenuation) * totalTranslate.Y;

      if (totalTranslate != usableTranslate)
        args.ReportBoundaryFeedback(
          new ManipulationDelta(totalTranslate – 
          usableTranslate, 0, new Vector(), new Vector()));

      if (attenuation > 0.99)
        args.Complete();
    }
  }
  matx.Translate(usableTranslate.X, usableTranslate.Y);
  xform.Matrix = matx;

  args.Handled = true;
  base.OnManipulationDelta(args);
}

El efecto resultante es que la elipse no se detiene inmediatamente cuando alcanza el límite, sino que disminuye la velocidad drásticamente como si se topara con un fango espeso.

La anulación OnManipulationDelta también llama al método ReportBoundaryFeedback y le pasa la porción sin usar del vector de traslación, el cual corresponde al vector totalTranslate menos usableTranslate. De manera predeterminada, esto se procesa para que la ventana rebote un poco cuando la elipse disminuye la velocidad, lo cual prueba el principio de la física que señala que para cada acción existe una reacción igual y opuesta.

Este efecto funciona de la mejor manera cuando la velocidad de impacto es bastante grande. Si la elipse ha disminuido la velocidad considerablemente, hay un efecto de vibración menos satisfactorio, pero es algo que quizás le conviene controlar en más detalle. Si no desea este efecto en absoluto (o sólo lo desea en algunos casos), simplemente puede evitar llamar a ReportBoundaryFeedback. O puede manipular el evento ManipulationBoundaryFeedback usted mismo. Puede inhibir el procesamiento predeterminado al configurar la propiedad Handled de los argumentos del evento en verdadero o bien puede elegir otro enfoque. Dejé un método OnManipulationBoundaryFeedback vacío en el programa para que experimente.

Charles Petzold  ha sido editor colaborador durante largo tiempo de MSDN Magazine*. Actualmente escribe “Programming Windows Phone 7” (Microsoft Press), que se publicará como libro electrónico de descarga gratuita durante el otoño de 2010. Una edición de vista previa actualmente se encuentra disponible a través de su sitio web, charlespetzold.com.*

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: Doug Kramer y Robert Levy