Una delle cose che “pretendo” è che la UX offerta sia consistente con quella dell’ecosistema che ospita l’applicazione, questo per un’applicazione Windows si traduce in moltissime cose, e moltissimo lavoro, tra cui ad esempio:
  • Supporto per la navigazione/gestione comandi con la tastiera;
  • Supporto per la gestione dei comandi tramite shortcut;
  • Comunicazione con l’utente;
  • Async, Async, Async (more to come…);
  • Disposizione, aspetto e layout dei controlli consistente con quello del sistema;
  • etc..
Tra queste una delle particolarità, è un dettaglio, ma è un dettaglio che in termini di UX è vincente, che non vedo mai utilizzate sono i CueBanner. Al tempo di Windows Forms, ormai legacy :-D, ho scritto anche un articolo su come implementarli via P/Invoke.
Un esempio su tutti:
image
Adesso che lo switch tecnologico verso WPF è ormai definitivo mi sono posto lo stesso problema. La soluzione P/Invoke è comunque utilizzabile anche se con qualche “giro” in più, ma WPF offre una soluzione decisamente più figosa.
Adorner e AdornerLayer
Rimando alla spiegazione ufficiale per i dettagli: Adorners Overview
CueBanner Adorner
Partiamo dal risultato:
image
Nulla di speciale, semplicemente abbiamo quel testo, a mo da hint per l’utente, che nel momento in cui il focus entra nella TextBox scompare e quando la TextBox perde il focus, se è ancora vuota, ricompare. Il tutto è un ottimo supporto per l’utente durante l’uso dell’applicazione. Garantito.
Abbiamo già parlato di attached properties e scopriamo che anche in questo caso sono l’entry point per la soluzione al nostro problema:
<TextBox local:CueBannerService.CueBanner="Questo è il testo del CueBanner" ... />
Sfortunatamente, si fa per dire, in questo caso sono solo l’entry point, quello di cui abbiamo bisogno è poter disegnare in overlay su un controllo del testo (e volendo qualsiasi cosa ci venga mente… questo è WPF!), la soluzione sono gli Adorner: un adorner è una “sorta” di layer (come se fosse un foglio di acetato) che viene renderizzato sopra l’elemento a cui è associato, l’AdornedElement. Un adorner per poter essere visibile ha bisogno di un AdornerLayer in cui essere “ospitato”, ogni Window (non proprio…, ho un caso che sto cercando di debuggare in cui questo non succede…) ha implicitamente un AdornerLayer anche se non lo inseriamo noi nello xaml. Ogni Window può contenere n AdornerLayer. L’inghippo fastidioso è che non è possibile dichiarare una Adorner via xaml perchè l’unica possibilità è scrivere questo:
AdornerLayer layer = AdornerLayer.GetAdornerLayer( control );
layer.Add( ... );
Ecco perchè una attached property ci viene in aiuto permettendoci di continuare ad utilizzare la potenza espressiva di xaml senza scrivere una “,” di codice nel code-behind, vediamo come:
public static readonly DependencyProperty CueBannerProperty = DependencyProperty.RegisterAttached(
                              "CueBanner",
                              typeof( String ), 
                              typeof( CueBannerService ),
                              new FrameworkPropertyMetadata( String.Empty, OnCueBannerPropertyChanged ) );
Partiamo con la solita definizione della proprietà, tralascio la parte Get/Set perchè triviale, e passo direttamente all’handler agganciato all’evento che ci notifica della variazione del valore della proprietà:
static RoutedEventHandler onShowCueBanner;
static RoutedEventHandler onHideCueBanner;
static RoutedEventHandler onUnloaded;

static CueBannerService()
{
    onShowCueBanner = ( s, e ) => 
    {
        var control = ( Control )s;
        if( ShouldShowCueBanner( control ) )
        {
            ShowCueBanner( control );
        }
    };

    onHideCueBanner = ( s, e ) => 
    {
        var c = ( Control )s;
        if( ShouldShowCueBanner( c ) )
        {
            RemoveCueBanner( c );
        }
    };

    onUnloaded = ( s, e ) => 
    {
        var control = ( Control )s;
        control.Loaded -= onShowCueBanner;
        control.LostFocus -= onShowCueBanner;
        control.GotFocus -= onHideCueBanner;
    };
}
Cominciamo con il prepararci, a monte nel costruttore statico, gli handler che ci serviranno.
static void OnCueBannerPropertyChanged( DependencyObject sender, DependencyPropertyChangedEventArgs e )
{
    Ensure.That( sender.GetType() )
        .IsTrue( t => t.Is<ComboBox>() || t.Is<TextBox>() );

    var control = ( Control )sender;

    control.Loaded += onShowCueBanner;
control.Unloaded += onUnloaded; control.LostFocus += onShowCueBanner; control.GotFocus += onHideCueBanner; }
Che cosa facciamo, non molto a dire il vero:
  • abbiamo bisogno di sapere quando il controllo viene caricato/scaricato: Loaded e Unloaded;
  • Ci interessano poi gli eventi GotFocus e LostFocus per sapere quando nascondere e visualizzare il CueBanner;
Negli handler di Got/LostFocus altro non facciamo che cercare di capire se dobbiamo visualizzare un CueBanner:
static Boolean ShouldShowCueBanner( Control c )
{
    var dp = GetTextProperty( c );
    var value = c.GetValue( dp );

    return ( value is String && String.IsNullOrEmpty( ( String )value ) );
}

static DependencyProperty GetTextProperty( Control control )
{
    if( control is ComboBox )
    {
        return ComboBox.TextProperty;
    }
    else if( control is TextBoxBase )
    {
        return TextBox.TextProperty;
    }
    else
    {
        throw new NotSupportedException();
    }
}
e nel caso semplicemente visualizzarlo o nasconderlo:
static void RemoveCueBanner( Control control )
{
    AdornerLayer layer = AdornerLayer.GetAdornerLayer( control );
    Debug.WriteIf( layer == null, "CueBannerService: cannot find any AdornerLayer" );

    if( layer != null )
    {
        var adorners = layer.GetAdorners( control );
        if( adorners != null )
        {
            adorners.OfType<CueBannerAdorner>()
                .ForEach( adorner =>
                {
                    adorner.Visibility = Visibility.Hidden;
                    layer.Remove( adorner );
                } );
        }
    }
}

static void ShowCueBanner( Control control )
{
    var layer = AdornerLayer.GetAdornerLayer( control );
    Debug.WriteIf( layer == null, "CueBannerService: cannot find any AdornerLayer" );

    if( layer != null )
    {
        layer.Add( new CueBannerAdorner( control, control.GetValue( CueBannerProperty ) ) );
    }
}
Il CueBannerAdorner è l’elemento responsabile di renderizzare il vero e proprio banner:
sealed class CueBannerAdorner : OverlayAdorner
{
    readonly ContentPresenter userContent;

    public CueBannerAdorner( UIElement adornedElement, Object content ) :
        base( adornedElement )
    {
        this.IsHitTestVisible = false;
        this.userContent = new ContentPresenter();

        var cueBannerText = content as String;
        if( cueBannerText != null )
        {
            this.userContent.Content = new TextBlock()
            {
                FontStyle = FontStyles.Italic,
                Text = cueBannerText,
                VerticalAlignment = VerticalAlignment.Center,
                Margin = new Thickness( 4, 0, 0, 0 ),
                TextTrimming = TextTrimming.CharacterEllipsis
            };
        }
        else
        {
            this.userContent.Content = content;
        }

        this.userContent.Opacity = 0.7;
    }

    protected override UIElement Content
    {
        get { return this.userContent; }
    }
}
Per prima cosa rendiamo l’adorner invisibile all’HitTest in questo modo il click del mouse non verrà intercettato dai controlli presenti nell’adorner ma solo da quelli che stanno sotto; poi creiamo il contenuto vero e proprio facendo una sola distinzione: se è testo lo wrappiamo noi in un TextBlock che abbia un aspetto decente altrimenti ci limitiamo a prendere quello che arriva…e questo apre a scenari interessanti:
<TextBox>
    <local:CueBannerService.CueBanner>
        <StackPanel Orientation="Horizontal">
            <Image Margin="2,0,0,0" Source="/Samples;component/BalloonTip.gif" Stretch="None" />
            <TextBlock Text="Try setting focus here..." FontStyle="Italic" VerticalAlignment="Center" Margin="2,0,0,0" />
        StackPanel>
    local:CueBannerService.CueBanner>
TextBox>
Che produce questo:
image
Io adoro WPF!
Manca solo un dettaglio: OverlayAdorner, la classe base da cui deriviamo il nostro adorner per il CueBanner:
abstract class OverlayAdorner : Adorner
{
    public OverlayAdorner( UIElement adornedElement )
        : base( adornedElement )
    {

    }

    protected abstract UIElement Content
    {
        get;
    }

    protected override Visual GetVisualChild( int index )
    {
        return this.Content;
    }

    protected override int VisualChildrenCount
    {
        get { return 1; }
    }

    protected override Size MeasureOverride( Size constraint )
    {
        this.Content.Measure( this.AdornedElement.RenderSize );

        return this.AdornedElement.RenderSize;
    }

    protected override Size ArrangeOverride( Size finalSize )
    {
        this.Content.Arrange( new Rect( finalSize ) );

        return finalSize;
    }

}
Facciamo un paio di cose interessanti:
  1. L’override di VisualChildrenCount informando il sistema che abbiamo 1 child, e l’override di GetVisualChild ritornando il nostro content;
  2. Facciamo poi l’override di MeasureOverride e ArrangeOverride perchè ci interessa partecipare nel processo di Rendering al fine di pemettere al nostro contenuto di disegnarsi correttamente, in questo modo supportiamo anche il resize del controllo su cui siamo “adorned”;
Happy WPFing!
.m