Fronteiras da interface do usuário

As complicações dos controles de toque

Charles Petzold

Baixar o código de exemplo

Charles PetzoldEu era o tipo de criança que desmontava torradeiras para descobrir como funcionavam. Mais tarde, me graduei em desmontagem de sistemas operacionais e programas aplicativos.

Eu não faço muita desmontagem atualmente. Mas, às vezes, quando vejo uma interface do usuário particularmente interessante, tento descobrir como posso codificá-la. É claro que isso é um exercício de engenharia, mas também gosto de me ver como um estudante de artes que vai ao museu para pintar cópias de obras-primas existentes. No meu próprio código, me esforço para obter simplicidade, é claro, e para fazer uso de elementos e controles já existentes. Mas, em geral, gosto de um desafio e espero aprender algo novo com ele.

Recentemente, estive explorando o Windows Phone 7 e fiquei intrigado com as páginas usadas para definir data e hora, como mostra a Figura 1. Esses controles (que não estão disponíveis publicamente) me chamaram a atenção como interfaces de toque interessantes. [Os controles foram lançados em um Kit de Ferramentas do Silverlight para Windows Phone após a conclusão deste artigo. Ele está disponível em silverlight.codeplex.com/releases/view/52297.—Ed.]

image: Windows Phone 7 Date Picker image: Windows Phone 7 Time Picker

Figura 1 Seletor de data e hora do Windows Phone 7

Fiquei imaginando: como eu codificaria esses controles? Nas últimas edições, estive explorando o multitoque no Windows Presentation Foundation (WPF), então decidi escolher essa plataforma para minha primeira tentativa de duplicá-los. Se tivesse êxito, eu poderia pensar em mover o código para o Silverlight.

Explorando os controles

Ao navegar pela primeira vez para uma das páginas de data ou hora no Windows Phone 7, você vê a configuração atual em quadrados cinzas centralizados na página. Toque um desses quadrados e uma lista de outras opções será exibida nas partes superior e inferior. Frequentemente, essa lista é circular; como você pode ver na Figura 1, o mês de dezembro é seguido por janeiro. As listas circulares também são usadas para os dias do mês, as horas e os minutos. As listas não circulares são usadas para o ano — a lista vai de 1601 a 3000 — ou (como você pode ver) para selecionar AM ou PM.

Que diferença de uma ListBox! Uma ListBox convencional exibe o item selecionado com um realce, mas, à medida que você rola a ListBox, esse item selecionado às vezes fica totalmente fora da exibição. Por outro lado, esses controles de data e hora sempre tendem a exibir o item selecionado no centro, e eu comecei a imaginar essas áreas centrais como um tipo de “janela”, com a lista funcionando de maneira semelhante à das rodas mecânicas das antigas máquinas caça-níqueis que, na minha mente, comecei a chamar de “bandas”.

Esses controles parecem responder ao toque de três maneiras:

  • Se você apenas tocar outro item visível na banda (como o mês de dezembro na Figura 1), o item ficará realçado quando o seu dedo sair da tela e passará para a área central da janela.
  • Em vez de tocar, você pode mover uma das bandas de itens para cima ou para baixo com o dedo. Durante esse tempo, nada nessa banda é realçado. Assim que você levanta o dedo, o item mais próximo da janela fica realçado e passa para o centro.
  • O terceiro tipo de interface envolve a inércia. Se a banda de itens estiver se movendo quando o seu dedo sair da tela, ela continuará se movendo e sua velocidade irá diminuindo. Quando ela estiver quase parando, o item mais próximo da janela ficará realçado e passará para o centro. Se necessário, às vezes a banda reverte a direção bem no fim, antes de parar. Esse pequeno efeito — bem natural, tenho que admitir — foi algo que eu sabia que seria um dos desafios mais “interessantes” na duplicação desses controles.

Arquitetura geral

Outro desafio “interessante” envolveu a lista de itens circular, onde o mês de dezembro é seguido pelo mês de janeiro e a hora 12:00 é seguida pela hora 1:00. Essas bandas circulares são aspectos cruciais do design — particularmente em combinação com a inércia. As bandas podem se mover rapidamente em ambas as direções e a inércia as leva para frente até qualquer item da banda, sem parar.

Depois de rolar ideias na minha cabeça durante vários dias, eu simplesmente não pude pensar em uma solução melhor para a lista circular além de um painel personalizado, e surgiu a sugestão do nome WrappableStackPanel. Esse painel às vezes posicionaria seus filhos de cima para baixo, e às vezes começaria com um filho diferente do primeiro, e posicionaria o primeiro filho depois do último. É claro que renderizar mais de uma versão de um filho em particular é proibido no WPF, então a pilha sempre teria um final definido.

O WrappableStackPanel precisaria de uma propriedade (chamada StartIndex, por exemplo) para indicar o índice do filho que deveria ser exibido na parte superior do painel, com o restante dos filhos seguindo em sequência e fazendo um loopback para o filho zero. No caso geral, nenhum filho seria alinhado precisamente na parte superior do painel. O filho que estivesse na posição superior geralmente ficaria parcialmente acima da parte superior do painel. Isso significava que a propriedade StartIndex seria um valor de ponto flutuante, em vez de um inteiro.

Mas isso significava que o controle geral provavelmente funcionaria de duas maneiras distintas. Um modo requer que um WrappableStackPanel hospede os itens, onde o WrappableStackPanel é fixado na posição relativa ao controle, e o painel manipula o posicionamento dos filhos em relação a ele mesmo. O outro modo é para os casos em que um StackPanel regular é adequado (como para a banda Ano ou AM/PM); nesse modo, os filhos são fixados em relação ao StackPanel e o StackPanel então é rolado em relação ao controle.

Esse controle seria derivado da ListBox? Eu decidi que não. A ListBox incorpora a sua própria lógica de seleção, e o controle que eu queria possui um tipo de lógica de seleção um tanto diferente. Se eu o derivasse da ListBox, ele provavelmente me atrapalharia em vez de ajudar no esforço com a lógica de seleção existente.

Mas eu queria que o controle mantivesse a sua própria coleção de itens e, particularmente, me permitisse definir um modelo em XAML para exibir esses itens. Essa necessidade sugeriu que um ItemsControl estaria envolvido. ItemsControl é a classe pai do Selector, do qual derivam a ListBox e a ComboBox, e é a opção óbvia para exibir coleções em que não há necessidade de uma lógica de seleção, ou em que o programador estará manipulando a lógica de seleção personalizada. Ou seja, eu.

O modelo de controle padrão para uma ListBox inclui um ScrollViewer; o ItemsControl não. Se precisar rolar os itens exibidos por um ItemsControl, você coloca o próprio ItemsControl inteiro em um ScrollViewer.

Eu sabia que não poderia usar o ScrollViewer existente para esse trabalho. Embora o ScrollViewer existente manipule a inércia, ele não tem nenhuma lógica para orientar um determinado item no centro. Minha primeira tentativa na codificação real foi criar uma alternativa para o ScrollViewer chamada WindowedScrollViewer, que hospedou um ItemsControl contendo os meses ou os dias dos meses ou os anos. Em seu método MeasureOverride, esse WindowedScrollViewer poderia facilmente obter o tamanho máximo do ItemsControl e o número de itens exibidos — e, portanto, a altura uniforme dos itens individuais — para poder usar essas informações para mover o ItemsControl em relação a si mesmo, com base nos eventos Manipulation.

Essa abordagem funcionou bem quando o ItemsControl não precisou exibir bandas de itens encapsuláveis. Para esses casos, eu precisei definir a propriedade ItemsPanel do ItemsControl como um ItemsPanelTemplate que referencia o WrappableStackPanel. O grande problema envolvia o WindowedScrollViewer que se comunica com o WrappableStackPanel através do ItemsControl que fica no meio da árvore visual.

Isso pareceu difícil. Além do mais, eu também fui percebendo que o meu WindowedScrollViewer era diferente do ScrollViewer regular, pois não tinha visuais próprios. Ele só estava deslizando o ItemsControl em relação a si mesmo.

Decidi abandonar essa abordagem e, em vez disso, derivar do ItemsControl e fazer tudo lá. Eu a chamei de classe WindowedItemsControl e ela implementa a lógica de seleção, a manipulação, a rolagem e a comunicação com um WrappableStackPanel opcional.

O código-fonte que pode ser baixado para este artigo é uma solução chamada TouchDatePickerDemo, com um projeto com esse nome e um projeto de biblioteca chamado Petzold.Controls. O projeto de biblioteca inclui o WindowedItemsControl (dividido em três arquivos), o WrappableStackPanel e um derivativo do UserControl chamado TouchDatePicker. O TouchDatePicker combina três WindowedItemsControls (para mês, dia e ano) com uma classe DatePresenter e uma propriedade chamada DateTime.

A Figura 2 mostra o TouchDatePicker em ação. Um TextBlock está associado à propriedade DateTime para exibir a data selecionada no momento.

image: The TouchDatePicker Control in Action

Figura 2 O controle TouchDatePicker em ação

O TouchDatePicker é basicamente um Grid com três colunas para o mês, o dia e o ano. A Figura 3 mostra o XAML para o primeiro WindowedItemsControl manipular o mês. O DataContext é um objeto do tipo DatePresenter, que possui as propriedades AllMonths e SelectedMonth referenciadas pela própria marca do WindowedItemsControl. A propriedade AllMonths é uma coleção de objetos MonthInfo, e SelectedMonth também é do tipo MonthInfo. A classe MonthInfo possui propriedades chamadas MonthNumber e MonthName que você verá referenciadas no DataTemplate. O WrappableStackPanel é referenciado na parte inferior.

Figura 3 Um terço do controle TouchDatePicker

<local:WindowedItemsControl x:Name="monthControl"
  Grid.Column="0"             
  ItemsSource="{Binding AllMonths}"
  SelectedItem="{Binding SelectedMonth, Mode=TwoWay}"
  IsActiveChanged="OnWindowedItemsControlIsActiveChanged">

  <local:WindowedItemsControl.ItemTemplate>
    <DataTemplate DataType="local:MonthInfo">
      <Border Width="60" Height="60"
        BorderThickness="1"
        BorderBrush="{Binding ElementName=monthControl,
        Path=Foreground}"
        Margin="2">
        <Grid>
          <Rectangle Fill="{DynamicResource 
            {x:Static SystemColors.ControlLightBrushKey}}">
             <Rectangle.Visibility>
              <MultiBinding Converter="{StaticResource multiConverter}">
               <Binding />
                 <Binding ElementName="monthControl" Path="SelectedItem" />
              </MultiBinding>
            </Rectangle.Visibility>
          </Rectangle>

          <TextBlock Text="{Binding MonthNumber, StringFormat=D2}"
            VerticalAlignment="Center"
            FontSize="24"
            FontWeight="Bold" />

          <TextBlock Text="{Binding MonthName}" 
            VerticalAlignment="Bottom"
            FontSize="10" />

          <Rectangle Fill="#80FFFFFF">
            <Rectangle.Visibility>
              <MultiBinding Converter="{StaticResource multiConverter}"
                ConverterParameter="True">
                <Binding />
                <Binding ElementName="monthControl" Path="SelectedItem" />
              </MultiBinding>
            </Rectangle.Visibility>
          </Rectangle>
        </Grid>
                        
       <Border.Visibility>
         <MultiBinding Converter="{StaticResource multiConverter}">
           <Binding />
           <Binding ElementName="monthControl" Path="SelectedItem" />
           <Binding ElementName="monthControl" Path="IsActive" />
         </MultiBinding>
       </Border.Visibility>
     </Border>
    </DataTemplate>
  </local:WindowedItemsControl.ItemTemplate>

  <local:WindowedItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <local:WrappableStackPanel IsItemsHost="True" />
    </ItemsPanelTemplate>
  </local:WindowedItemsControl.ItemsPanel>
</local:WindowedItemsControl>

Lógica de seleção

A coleção Items mantida pelo ItemsControl é definida para aceitar itens do tipo objeto. Cada um desses itens é renderizado com base em um DataTemplate definido para a propriedade ItemTemplate do ItemsControl.

A ListBox faz um pouco mais. A ListBox encapsula cada um de seus itens em um controle ListBoxItem com o objetivo de implementar a lógica de seleção. É o ListBoxItem que possui uma propriedade IsSelected juntamente com os eventos Selected e Unselected, e que é responsável por indicar visualmente que um item está selecionado.

Meu objetivo com o WindowedItemsControl foi implementar a lógica de seleção sem qualquer tipo de wrappers e implementar de tal forma que eu pudesse definir os visuais do item selecionado totalmente em XAML. Para ajudar, o WindowedItemsControl possui uma propriedade SelectedIndex (que ele utiliza internamente para determinar onde os itens devem ser posicionados) e uma propriedade SelectedItem, que é o objeto específico da sua coleção Items correspondente ao SelectedIndex.

No XAML da Figura 3, essa propriedade SelectedItem é referenciada na propriedade DateTemplate da seguinte forma:

<Binding ElementName="monthControl" Path="SelectedItem" />

No mesmo DataTemplate, o próprio item pode ser referenciado com uma expressão Binding bem mais simples:

<Binding />

Se os objetos referenciados por essas duas associações forem iguais, o DataTemplate deverá ter alguma marcação especial para indicar um item selecionado — nesse caso, sombreamento cinza do plano de fundo e texto não esmaecido.

Normalmente, no XAML você não pode determinar se duas associações referenciam o mesmo objeto, mas eu escrevi um conversor de várias associações especificamente com esse objetivo. Ele se chama EqualsToVisibilityMultiConverter e retorna Visibility.Visible ou Visibility.Hidden, com base na igualdade ou não de dois objetos. No DataTemplate ele é usado desta maneira para o plano de fundo cinza:

<Rectangle Fill="{DynamicResource {x:Static SystemColors.ControlLightBrushKey}}">
    <Rectangle.Visibility>
        <MultiBinding Converter="{StaticResource multiConverter}">
            <Binding />
            <Binding ElementName="monthControl" Path="SelectedItem" />
        </MultiBinding>
    </Rectangle.Visibility>
</Rectangle>

E funcionou!

Infelizmente, esse conversor de associação ficou mais complexo à medida que precisei dele para executar outras tarefas. Eu queria outro Rectangle para escurecer os itens não selecionados, e queria que esse Rectangle estivesse oculto para o item selecionado, então adicionei um parâmetro de conversor. Quando esse parâmetro é definido como “true”, os valores de retorno de Visibility do conversor de várias associações são trocados.

Mas eu também precisava alternar entre mostrar apenas o item selecionado e mostrar toda a banda de itens que associei à propriedade IsActive. Se IsActive for true, todos os itens precisarão ser exibidos; se IsActive for false, somente o item selecionado precisará ser exibido. Adicionei um recurso ao conversor de várias associações para um terceiro objeto do tipo bool. Se for true, o conversor de várias associações sempre retornará Visibility.Visible (a não ser que o parâmetro seja definido como “true”, e, nesse caso, retornará Visibility.Hidden). Este recurso foi usado para tornar o item inteiro visível ou oculto:

<Border.Visibility>
    <MultiBinding Converter="{StaticResource multiConverter}">
        <Binding />
        <Binding ElementName="monthControl" Path="SelectedItem" />
        <Binding ElementName="monthControl" Path="IsActive" />
    </MultiBinding>
</Border.Visibility>

Embora o conversor de várias associações real não seja complexo, ele é uma matriz de funcionalidade um tanto confusa. Mas eu consegui implementar os visuais de seleção inteiramente em XAML, sem o uso de quaisquer wrappers, o que me deixou satisfeito.

Comunicação com o painel

O WindowedItemsControl precisará implementar uma lógica de rolagem diferente se o painel definido por meio da sua propriedade ItemsPanel for um WrappableStackPanel. Como ele pode identificar isso? E como ele pode conduzir as informações para esse painel?

Determinar o tipo de painel é razoavelmente fácil: substituir a propriedade OnItemsPanelChanged. Sempre que a propriedade ItemsPanel for alterada, esse método será chamado com o ItemsPanelTemplate antigo e o novo ItemsPanelTemplate. Chame LoadContent nesse novo modelo e você obterá uma instância do painel no modelo.

No entanto, o painel retornado de LoadContent não é a mesma instância do painel que realmente está sendo usado pelo ItemsControl! Essa técnica é adequada somente para determinar se o painel é de um determinado tipo, não para comunicação com esse painel.

Se o ItemsControl desejar definir propriedades nesse painel, será necessária outra técnica. Ele pode localizar o painel real atravessando a árvore visual, ou pode usar um evento herdável.

Eu escolhi a segunda técnica. Em WindowedItemsControl, eu defini uma propriedade StartIndex apoiada por uma propriedade de dependência, para a qual defini o sinalizador Inherits de FrameworkPropertyMetadataOptions (sabe-se muito bem que, para persuadir esse sinalizador Inherits a funcionar, você deve registrar uma propriedade anexada em vez de uma propriedade de dependência normal).

Aqui está:

public static readonly DependencyProperty StartIndexProperty =
    DependencyProperty.RegisterAttached("StartIndex",
        typeof(double),
        typeof(WindowedItemsControl),
        new FrameworkPropertyMetadata(0.0, 
            FrameworkPropertyMetadataOptions.Inherits));

Quando o ItemsPanel for um WrappableStackPanel, o WindowedItemsControl implementará a rolagem por meio da simples definição de um valor para essa propriedade StartIndex.

O WrappableStackPanel adiciona um proprietário à propriedade anexada StartIndex e define o sinalizador AffectsArrange de FrameworkPropertyMetadataOptions. O método ArrangeOverride utiliza o valor da propriedade StartIndex herdada do ItemsControl para determinar qual item deve aparecer na parte superior do painel, e que parte dele deve estar acima do painel.

Os eventos de manipulação

Como eu suspeitei desde o princípio, a implementação de eventos Manipulation reais seria a parte mais difícil do trabalho. Minha análise dos três tipos de eventos de toque acabou acertando bem no alvo. Todos eles precisavam ser manipulados de forma bem separada. (O código está no arquivo WindowedItemsControl.Manipulation.cs.)

Boa parte da determinação dos três tipos de eventos de toque ocorre na substituição de OnManipulationInertiaStarting. Esse evento indica que o dedo do usuário saiu da tela.

Se o evento ManipulationInertiaStarting vier após o evento ManipulationStarting sem quaisquer eventos ManipulationDelta intermediários, isso será um toque. O código no evento ManipulationInertiaStarting determina até que ponto o item tocado precisa ir para se mover até o centro e, em seguida, determina a velocidade necessária para realizar isso em um período fixo de tempo (definido em 250 milissegundos). Ele então inicializa as propriedades DesiredDisplacement e InitialVelocity da propriedade TranslationBehavior dos argumentos do evento para essa quantidade de inércia.

Lembre-se de que o usuário acabou de tocar o item. O usuário não moveu o item, portanto, não há nenhuma velocidade ou inércia real! Porém, o evento ManipulationInertiaStarting permite definir parâmetros de inércia para forçar um objeto a se mover, mesmo que ele não estivesse se movendo. No contexto de manipulação de eventos Manipulation, essa abordagem é muito mais fácil do que usar animação ou CompositionTarget.Rendering para rolagem.

A lógica será bem semelhante se o usuário tiver movido a banda manualmente, mas o novo item selecionado é óbvio, pois a velocidade é insuficiente para mover a banda além desse item. Mais uma vez, tudo que precisa ser feito é definir as propriedades DesiredDisplacement e InitialVelocity para deslizá-lo até o local.

O código realmente confuso ocorre quando existe inércia real e o novo item selecionado não será conhecido até que a velocidade diminua e a rolagem quase pare. É aí que a velocidade deve ser analisada para examinar se o novo item selecionado realmente rolará além do centro, e para reverter a direção, se necessário. Algum código inicial realmente revertia a rolagem várias vezes, para trás e para frente, e isso não era desejável mesmo.

Qual foi o resultado?

Devo admitir que o controle resultante se parece mais com um “primeiro rascunho” do que com uma versão final. Ele não parece tão suave ou tão natural quanto a versão implementada no Windows Phone 7, e não tem algumas características. Por exemplo, quando você ativa o controle pressionando-o na versão do Windows Phone 7, o que ocorre com as bandas é fade in na exibição e, posteriormente, fade out. As minhas apenas são exibidas.

Mas estou satisfeito com a experiência e estou ainda mais convencido de que a boa codificação do multitoque é muito mais difícil do que pode parecer a princípio.

Charles Petzold é um editor colaborador da MSDN Magazine há muito tempo. Seu novo livro, “Programming Windows Phone 7”, será publicado como livro eletrônico que poderá ser baixado gratuitamente, no quarto trimestre de 2010.

Agradecemos ao seguinte especialista técnico pela revisão desta coluna: Doug Kramer