Fronteiras da interface do usuário

Inércia do multitoque

Charles Petzold

Baixar o código de exemplo

As interfaces de usuário nos computadores parecem ser mais interessantes quando mascaram a natureza digital da tecnologia subjacente e, em vez disso, imitam a sensação analógica do mundo real. Essa tendência teve início quando as interfaces gráficas começaram a substituir linhas de comando e continuou com o uso cada vez maior de fotografias, som e outras mídias.

A incorporação de multitoque aos monitores deu um impulso extra ao processo de tornar os controles de usuário mais naturais e intuitivos. Acho que você só começou a vislumbrar o potencial da revolução do toque para renovar a sua relação com a tela do computador.

Talvez um dia até eliminemos um dos vestígios mais críticos da tecnologia digital conhecida do homem: o controle de volume por botão de ação. O mostrador básico que controlava o volume (e outras configurações) em rádios e TVs era agradavelmente simples, mas foi substituído por deselegantes botões de ação em controles remotos e nos próprios aparelhos.

As interfaces de computador muitas vezes oferecem controles deslizantes para controlar o volume. Eles são quase tão bons quanto os mostradores. Mas os controles de volume por botões de ação ainda persistem. Até mesmo a tela multitoque do Zune HD pede para você pressionar os sinais de adição e subtração para aumentar e diminuir o volume. Seria mais preferível um mostrador que respondesse ao toque.

Gosto do mostrador para multitoque. Talvez seja por isso que me concentrei em simular mostradores no meu artigo “Estilo digital: Explorando o suporte a multitoque no Silverlight”, na edição de março de 2010 da MSDN Magazine (msdn.microsoft.com/magazine/ee336026), e que estou voltando aos mostradores para falar sobre como lidar com a inércia do multitoque no Windows Presentation Foundation (WPF).

O evento de inércia

Uma das maneiras pelas quais uma interface multitoque tenta imitar o mundo real é introduzindo inércia — a tendência de os objetos manterem a mesma velocidade, a menos que influenciados por outras forças, como fricção. Nas interfaces multitoque, a inércia pode manter um objeto visual em movimento depois que os dedos se afastam da tela. A aplicação mais comum da inércia de toque é a navegação em listas, sendo que a inércia já está incorporada ao controle ListBox do WPF.

Entre o código para download relacionado a este artigo está um programinha chamado IntertialListBoxDemo, que preenche um ListBox do WPF com vários itens para que você possa testá-lo em um monitor multitoque.

Nos seus próprios controles do WPF, você precisará definir quanta inércia deseja, se aplicável. Lidar com a inércia é mais fácil se você só deseja que ela gere a mesma resposta do seu programa que a manipulação direta. A parte difícil é ter um bom entendimento dos valores numéricos envolvidos.

Como você viu em artigos anteriores, um elemento do WPF é informado do começo da manipulação multitoque através de dois eventos: ManipulationStarting (que é uma boa oportunidade para executar alguma inicialização) e ManipulationStarted. Esses dois eventos são então seguidos potencialmente por muitos eventos ManipulationDelta, que consolidam a ação de um, dois ou mais dedos nas informações de conversão, escala e rotação.

Quando todos os dedos são retirados do elemento que está sendo manipulado, ocorre um evento ManipulationInertiaStarting. Esta é a oportunidade para o seu programa especificar quanta inércia você deseja. (Em breve falarei sobre como fazer isso). O efeito da inércia assume a forma de eventos ManipulationDelta adicionais até o objeto “parar”, e um evento ManipulationCompleted sinaliza o término. Se você não fizer nada com ManipulationInertiaStarting, ele será seguido imediatamente por ManipulationCompleted.

O uso do evento ManipulationDelta para indicar manipulações diretas e inércia torna todo esse esquema extremamente fácil de usar. Basicamente, é possível implementar inércia com uma única instrução no evento ManipulationInertiaStarting. Se necessário, você pode observar a diferença entre manipulação direta e inércia através da propriedade IsInertial de ManipulationDeltaEventArgs.

Como você verá, é possível especificar o quanto de inércia deseja em uma de duas formas diferentes, mas ambas envolvem desaceleração — uma diminuição da velocidade por um período até atingir zero e o objeto parar de se mover. Isso implica alguns conceitos de física com os quais a maioria dos programadores não se depara com frequência, por isso talvez um cursinho de atualização possa ajudar.

Uma atualização sobre aceleração

Se algum objeto se move, diz-se que ele tem uma rapidez ou velocidade que pode ser expressa em unidades de distância por tempo. Se a própria velocidade muda ao longo do tempo, então o objeto tem aceleração. Uma aceleração negativa (que indica redução da velocidade) normalmente é chamada de desaceleração.

Durante toda esta discussão, vamos supor que a aceleração ou a desaceleração é constante. Uma aceleração constante significa que a velocidade muda linearmente — uma quantidade igual por unidade de tempo. Se a aceleração é 0, a velocidade permanece constante.

Por exemplo, os fabricantes de automóveis às vezes fazem comerciais de seus carros que dizem que eles são capazes de acelerar de 0 a 60 mph em um certo número de segundos, digamos, 8 segundos. Inicialmente o carro está em repouso, mas até o final dos 8 segundos ele chega a 60 mph, como ilustrado na Figura 1.

Figura 1 Aceleração

Segundos Velocidade
0 0 mph
1 7,5
2 15
3 22,5
4 30
5 37,5
6 45
7 52,5
8 60

Observe que a velocidade aumenta na mesma proporção a cada segundo. A aceleração é expressa como a mudança de velocidade durante um determinado período ou, nesse caso, 7,5 mph por segundo.

Esse valor de aceleração é um pouco estranho porque há duas unidades de tempo envolvidas: horas e segundos. Vamos deixar as horas de lado e usar apenas os segundos. Como 60 mph corresponde a 88 pés por segundo, poderíamos dizer que o carro vai de 0 a 88 pés por segundo em 8 segundos. A aceleração é de 11 pés por segundo, por segundo ou, como se costuma dizer, 11 pés por segundo ao quadrado.

Podemos voltar para milhas e horas, mas as unidades ficam ridículas, pois a aceleração é calculada como 27.000 mph ao quadrado. Isso implica que, ao final da hora, o carro estará viajando a 27.000 mph. É claro que ele não faz isso. Na vida real, assim que o carro chega a 60 mph ou mais ou menos isso, a velocidade cai e a aceleração chega a 0. Isso é uma mudança de aceleração, o que em engenharia se chama de puxão.

Neste exercício, porém, vamos presumir a aceleração constante. Começando com uma velocidade de 0, como resultado da aceleração a, a distância x percorrida durante o tempo t is:

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

O ½ e o quadrado são necessários porque a velocidade v é calculada como o primeiro derivado da distância em relação ao tempo:

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

Se a corresponde a 11 pés por segundo ao quadrado, em t igual a 1 segundo, a velocidade é de 11 pés por segundo, mas o carro percorreu somente 5,5 pés. Em t igual a 2 segundos, a velocidade é de 22 pés por segundo, e o carro percorreu um total de 22 pés. Durante cada segundo, o carro percorre uma distância baseada na velocidade média durante esse segundo. Em t igual a 8 segundos, a velocidade é de 88 pés por segundo, e o carro percorreu um total de 352 pés.

A inércia multitoque é como ver o carro em marcha à ré: no momento em que os dedos deixam de tocar a tela, o objeto tem uma certa velocidade. O aplicativo especifica uma desaceleração que faz com que a velocidade do objeto diminua linearmente até 0. Quanto mais alta a desaceleração, mais rapidamente o objeto para. Uma desaceleração de 0 faz com que o objeto continue a se movimentar com a mesma velocidade incessantemente.

Duas formas de desacelerar

Os argumentos do evento ManipulationDelta incluem uma propriedade Velocities do tipo ManipulationVelocities, que por sua vez tem três propriedades correspondentes aos três tipos de manipulação:

  • LinearVelocity do tipo Vector
  • ExpansionVelocity do tipo Vector
  • AngularVelocity do tipo double

As duas primeiras propriedades são expressas como unidades independentes de dispositivo por milissegundo; a terceira é expressa em ângulos (em graus) por milissegundo. É claro que milissegundo não é um período intuitivamente compreensível, por isso, se você precisa entender o que esses valores significam, deve multiplicá-los por 1.000 para convertê-los em segundos.

Os aplicativos não usam essas propriedades Velocity com muita frequência, mas fornecem velocidades iniciais para inércia. Quando os dedos do usuário se afastam da tela, ocorre um evento ManipulationInertiaStarting. Os argumentos do evento incluem estas três propriedades, que permitem especificar a inércia separadamente para esses mesmos três tipos de manipulação:

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

Cada uma dessas classes tem uma propriedade InitialVelocity, uma propriedade DesiredDeceleration e uma terceira propriedade, chamada DesiredDisplacement, DesiredExpansion ou DesiredRotation, dependendo da classe.

Para conversão e rotação, todas as propriedades Desired têm valores padrão de Double.NaN, a configuração de bit especial que indica “não é um número”. Para expansão, as propriedades Desired são do tipo Vector com valores X e Y de Double.NaN, mas o conceito é o mesmo.

Vamos nos concentrar primeiro na inércia rotacional porque ela é um efeito conhecido do mundo real (como um carrossel do playground), e você não precisa se preocupar com objetos se soltando da tela.

Até ocorrer um evento ManipulationInertiaStarting, o objeto InertiaRotationBehavior terá sido criado, e o valor de InitialVelocity definido com base no evento ManipulationDelta anterior. Se InitialVelocity é 1,08, por exemplo, são 1,08 graus por milissegundo ou 1.080 graus por segundo, ou três revoluções por segundo ou 180 rpm.

Para manter o objeto girando, defina DesiredRotation ou DesiredDeceleration, mas não ambas. Caso você tente definir as duas propriedades, a última será válida e a outra será Double.NaN.

A primeira opção é definir DesiredRotation com um valor em graus. Esse valor é o número de graus que o objeto girará antes de parar. Se você definir DesiredRotation como 360, por exemplo, o objeto girando fará apenas uma revolução adicional, reduzindo a velocidade e parando no processo. A vantagem é que você obtém o mesmo volume de atividade de inércia, independentemente da velocidade inicial, por isso é fácil prever o que acontecerá. A desvantagem é que isso não é totalmente natural.

A alternativa: definir DesiredDeceleration com um valor em unidades de graus por milissegundo ao quadrado, e aqui as coisas ficam um pouco incertas porque é difícil adivinhar um valor adequado.

Se InitialVelocity for 1,08 graus por milissegundo e você definir DesiredDeceleration como 0,01 graus por milissegundo ao quadrado, a velocidade diminuirá em 0,01 graus a cada milissegundo: até 1,07 graus por milissegundo ao final do primeiro milissegundo, 1,06 graus por milissegundo ao final do segundo milissegundo e assim por diante, de maneira linear, até a velocidade chegar a 0. Todo esse processo levará 108 ms ou um pouco mais de um décimo de segundo.

Provavelmente você definirá DesiredDeceleration com um valor inferior a esse, talvez 0,001 grau por milissegundo ao quadrado, o que fará com que o objeto continue a girar por 1,08 segundos.

Cuidado se você deseja converter as unidades de desaceleração em algo um pouco mais fácil para a mente humana contemplar. Uma velocidade de 1,08 graus por milissegundo é o mesmo que 1.080 graus por segundo, mas uma desaceleração de 0,001 grau por milissegundo ao quadrado é igual a 1.000 graus por segundo ao quadrado. Para converter a desaceleração em segundos, é preciso multiplicar por 1.000 duas vezes porque o tempo está ao quadrado.

Se você combinar as duas fórmulas mostradas anteriormente e eliminar t, obterá o seguinte:

Image: equation, a equals v squared over 2 x

Isso implica que os dois métodos para definir a desaceleração são equivalentes e podem ser convertidos de um para o outro. Se InitialVelocity for 1,08 graus por milissegundo e você definir DesiredDeceleration como 0,001 grau por milissegundo ao quadrado, isso será o equivalente a definir DesiredRotation como 583,2 graus. Em ambos os casos, a rotação para após 1.080 ms.

Experimento

Para ter uma noção do que é a inércia rotacional, criei o programa RotationalInertiaDemo mostrado na Figura 2.

image: RotationalInertiaDemo Program in Action

Figura 2 O programa RotationalInertiaDemo em ação

A roda à esquerda é o que você gira com o dedo, seja no sentido horário ou anti-horário. É muito simples: apenas um derivado de UserControl com dois elementos Ellipse em um Grid com todos os eventos Manipulation manipulados em MainWindow.

O evento ManipulationStarting executa a inicialização restringindo a manipulação para somente rotação. permitindo a rotação com um único dedo e definindo o centro da rotação:

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

O velocímetro à direita é uma classe chamada ValueMeter e mostra a velocidade atual da roda. Se ele parece vagamente familiar, é só porque se trata de uma versão aprimorada de um modelo ProgressBar retirado de um artigo que escrevi para esta revista mais de três anos atrás. Os aprimoramentos incluíram uma certa flexibilidade com os rótulos, por isso pude usá-lo para mostrar a velocidade em quatro unidades diferentes. O GroupBox no centro da janela permite selecionar essas unidades.

Quando seu dedo gira o mostrador, o medidor mostra a velocidade angular atual disponível da subpropriedade Velocities.AngularVelocity dos argumentos do evento ManipulationDelta. Mas descobri que não era possível afunilar a velocidade do mostrador diretamente para ValueMeter. o resultado ficou muito trêmulo. Tive de criar uma pequena classe ValueSmoother para fazer uma média ponderada de todos os valores do quarto de segundo passado. O manipulador de eventos ManipulationDelta também define um objeto RotateTransform que na verdade gira o mostrador:

rotate.Angle += args.DeltaManipulation.Rotation;

Por fim, o controle deslizante na parte inferior permite selecionar um valor de desaceleração. O valor do controle deslizante só é lido quando você tira o dedo do mostrador e um evento ManipulationInertiaStarted é disparado:

args.RotationBehavior.DesiredDeceleration = slider.Value;

Esse é o corpo inteiro do manipulador de eventos ManipulationInertiaStarted. Durante a fase de inércia da manipulação, os valores de velocidade são muito mais regulares e não precisam ser suavizados, por isso o manipulador ManipulationDelta usa a propriedade IsInertial para determinar quando deve passar valores de velocidade diretamente para o medidor.

Limites e devolução

O uso mais comum da inércia com multitoque é a movimentação de objetos pela tela, como rolar uma longa lista ou mover elementos para a lateral. O grande problema é que é muito fácil fazer com que um elemento se solte da tela!

Mas no processo de lidar com esse pequeno problema, você descobrirá que o WPF tem um recurso interno que torna a inércia da manipulação ainda mais realística. Você deve ter percebido antes no programa ListBoxDemo que, quando permite que a inércia role até o final ou início da lista, a janela inteira pula um pouco quando ListBox chega ao final. Esse também é um efeito que você pode obter nos seus próprios aplicativos (se assim desejar).

O programa BoundaryDemo é formado apenas por uma elipse com uma identidade MatrixTransform definida para a respectiva propriedade RenderTransform, localizada em um Grid chamado mainGrid. Somente a conversão é habilitada durante a substituição de OnManipulationStarting. O método OnManipulationInertiaStarting define a desaceleração da inércia da seguinte forma:

args.TranslationBehavior.DesiredDeceleration = 0.0001;

Isso representa 0,0001 unidades independentes de dispositivo por milissegundo ao quadrado ou 100 unidades independentes de dispositivo por segundo ao quadrado ou cerca de 1 polegada por segundo ao quadrado.

A substituição de OnManipulationDelta é mostrada na Figura 3. Observe a manipulação especial quando IsInertial é verdadeiro. A ideia por detrás do código é que os fatores de conversão deveriam ser atenuados quando a elipse se desviar parcialmente da tela. O fator de atenuação é 0 se a elipse está dentro de mainGrid e aumenta para 1 quando ela está na metade do limite de mainGrid. O fator de atenuação então é aplicado ao vetor de conversão entregue ao método (chamado totalTranslate) para calcular usableTranslate, que fornece os valores efetivamente aplicados à matriz de transformação.

Figura 3 O evento OnManipulationDelta em 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);
}

O efeito resultante é que a elipse não para imediatamente quando atinge o limite, mas reduz a velocidade drasticamente como se tivesse encontrado uma lama espessa.

A substituição de OnManipulationDelta também chama o método ReportBoundaryFeedback, passando-o para a parte não utilizada do vetor de conversão, que é o vetor totalTranslate menos usableTranslate. Por padrão, isso é processado para fazer com que a janela pule um pouco à medida que a velocidade da elipse diminui, demonstrando o princípio da física de que para cada ação há uma reação oposta e igual.

Este efeito funciona melhor quando a velocidade no impacto é razoavelmente grande. Se a velocidade da elipse diminuiu apreciavelmente, há um efeito de vibração menos satisfatório, mas é algo que você deve controlar com mais detalhes. Se você não quiser o efeito (ou se deseja obtê-lo somente em alguns casos), só precisa evitar chamar ReportBoundaryFeedback. Como alternativa, você mesmo pode manipular o evento ManipulationBoundaryFeedback. É possível impedir o processamento padrão definindo a propriedade Handled dos argumentos do evento como verdadeiro ou, se preferir, você pode escolher outra abordagem. Deixei um método OnManipulationBoundaryFeedback vazio no programa para você experimentar.

Charles Petzold é editor colaborador da MSDN Magazine há muito tempo. Atualmente ele está escrevendo o livro “Programming Windows Phone 7” (Microsoft Press), que será publicado ainda em 2010 no formato de livro eletrônico, que poderá ser baixado gratuitamente. Uma edição de demonstração está disponível em seu site charlespetzold.com.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Doug Kramer e Robert Levy