Sempre in tema di UX ritengo fondamentale che un’applicazione Windows sia fortemente basata su multithreading perchè se c’è una cosa che mi da veramente fastidio è vedere l’applicazione freezata, con quel laconico (Not responding) nella barra del titolo, solo perchè l’operazione “lunga” viene eseguita nello stesso thread della UI.

In effetti se dato uno sguardo al task manager scoprite cose interessanti:

image

Un mondo multithreaded è sicuramente più difficile da dominare ma è anche vero che una segretaria incazz*ta è forse peggio di una suocera logorroica, inoltre se cerchiamo scopriamo che il framework .net ci mette a disposizion, da molte versioni, validi strumenti per domare la situazione. Uno su tutti il BackgroundWorker ma anche, e perchè no, strumenti di più basso livello come AsyncOperationManager.

Se vogliamo possiamo poi renderci la vita facile, e avere in mano qualcosa di fortemente tipizzato (il background worker conosce solo Object), possiamo wrappare il tutto e, sfruttando un po’ di fluent interface design, giungere a qualcosa del genere:

this.Browse = DelegateCommand.Create( "Cerca..." )
    .OnCanExecute( o =>
    {
        return !this.IsBusy && this.Query.AsKeywords( ';' ).Any();
    } )
    .OnExecute( o =>
    {
        var keywords = this.Query.AsKeywords( ';' );

        Worker.UsingAsArgument( keywords )
            .AndExpectingAsResult<IEnumerable<ISubject>>()
            .WhenExecutedDo( arg =>
            {
                var query = new SubjectsByKeywordsQuery( arg.Argument );
                arg.Result = this.subjectsRepository.GetByQuery( query );
            } )
            .ButBeforeDo( arg =>
            {
                this.IsBusy = true;
                this.SelectedItems.DataSource.Clear();
            } )
            .AndAfterDo( arg =>
            {
                this.AvailableItems.DataSource
                    .CastTo<IEntityCollection<ISubject>>()
                    .BulkLoad( arg.Result );

                this.IsBusy = false;
            } )
            .Execute();
    } );

Cosa abbiamo:

  • La definizione di un ICommand, e la gestione dei relativi delegate per la CanExecute e la Execute;
  • La definizione di workflow asincrono per l’esecuzione della richiesta dell’utente, la cosa interessante qui è:
    • ButBeforeDo() e AndAfterDo() sono eseguiti nel thread della UI, quindi niente plumbing code per la sincronizzazione;
    • WhenExecutedDo() viene eseguito in un background thread;
  • Un po’ di pattern vari applicati…

Ma cosa succede nella UI, durante l’esecuzione? questo:

image

Grazie, “semplicemente” a questo:

<ListView local:BusyStatusManager.Status="{Binding Path=IsBusy, Converter={StaticResource boolBusyStatusConverter}}">
    <local:BusyStatusManager.Content>
        <dropShadow:SystemDropShadowChrome HorizontalAlignment="Center" VerticalAlignment="Center">
            <Border BorderBrush="Black" Background="LightGray" BorderThickness="1" >
                <StackPanel Margin="5">
                    <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" Text="Attendere..." FontStyle="Italic" />
                    <ProgressBar Margin="0,2,0,0" IsIndeterminate="True" HorizontalAlignment="Stretch" Height="5" />
                </StackPanel>
            </Border>
        </dropShadow:SystemDropShadowChrome>
    </local:BusyStatusManager.Content>
</ListView>

Ci sono 2 cose che non compaiono sulla ListView normale:

  1. BusyStatusManager.Status: è un’enumerazione che può assumere i valori Idle o Busy ed è l’entry point del sistema;
  2. BusyStatusManager.Content: in perfetto stile WPF è di tipo System.Object ed è il vero e proprio contenuto che deve essere visualizzato durante l’operazione asincrona, la visualizzazione è triggherata dalla proprietà Status, come è facile immaginare quando è Idle non viene visualizzato nulla mentre è Busy viene visualizzato il contenuto della proprietà Content;

Nell’esempio non facciamo nulla diparticolare, ma nulla ci vieterebbe di mettere in binding anche il contenuto del “Busy Panel” con, ad esempio, delle proprietà sul VM al fine di mostrare uno stato di avanzamento più consistente.

Inoltre sottolineo che quel blocco di xaml è applicabile a qualsiasi controllo, del resto dipende solo dal VM con cui è in binding e non dal controllo su cui è applicato. Il controllo è importante solo in termini di Rendering, per l’esattezza di Measure e Arrange.

Naturalmente il tutto per funzionare sfrutta un immancabile, e tra un po’ inflazionato, Adorner; ma iniziamo come al solito con il behavior, la classe statica che fa da ponte ed espone le attached properties:

public static readonly DependencyProperty ContentProperty = DependencyProperty.RegisterAttached(
                              "Content",
                              typeof( Object ),
                              typeof( BusyStatusManager ),
                              new FrameworkPropertyMetadata( null, OnPropertyChanged ) );

public static readonly DependencyProperty StatusProperty = DependencyProperty.RegisterAttached(
                      "Status",
                      typeof( BusyStatus ),
                      typeof( BusyStatusManager ),
                      new FrameworkPropertyMetadata( BusyStatus.Idle, OnPropertyChanged ) );

static readonly DependencyProperty handledProperty = DependencyProperty.RegisterAttached(
                      "handled",
                      typeof( Boolean ),
                      typeof( BusyStatusManager ),
                      new FrameworkPropertyMetadata( false ) );

Dichiariamo le attached property:

  • Content: semplicemente il contenuto da visualizzare;
  • Status: l’enumerazione (Idle o Busy) che trigghera la visualizzazione di quello che c’è in Content, o se non c’è nulla si limita a disabilitare/abilitare il controllo;
  • handled: una proprietà privata che ci serve per capire a che punto siamo: il problema è che abbiamo più di una attached property e la notifica della variazione del valore della proprietà ci arriva in 2 casi:
    • Quando effettivamente il valore cambia;
    • Quando il valore viene settato la prima volta durante l’initialize del controllo;

Avendo noi 2 proprietà riceviamo “troppe” notifiche e dobbiamo iniziare a reagire solo quando il controllo è effettivamente Loaded, ci agganciamo quindi all’evento Loaded ma dobbiamo falro una sola volta per ogni controllo, quindi ci agganciamo all’evento e ci segnamo che ci siamo agganciati, dopodiche restiamo in attesa che il controllo abbia finito l’inizializzazione:

static void OnPropertyChanged( DependencyObject d, DependencyPropertyChangedEventArgs e )
{
    var control = ( FrameworkElement )d;
    if( !control.IsLoaded && !Gethandled( control ) )
    {
        control.Loaded += ( s, rea ) => HandleStatusChanged( control );
        Sethandled( control, true );
    }
    else if( control.IsLoaded && e.Property == StatusProperty )
    {
        HandleStatusChanged( control );
    }
    else if( control.IsLoaded && e.Property == ContentProperty )
    {
        HandleContentChanged( control );
    }
}

Quando il controllo termina la fase di inizializzazione impostiamo il nostro mondo, stessa cosa facciamo se il controllo è completamente caricato e cambia una delle 2 proprietà (in realtà solo una perchè per ora non mi interessa supportare la variazione di content a runtime):

static void HandleContentChanged( FrameworkElement element )
{
    throw new NotSupportedException( "BusyStatusManager: Content property cannot be changed at runtime." );
}
static void HandleStatusChanged( FrameworkElement element )
{
    var layer = AdornerLayer.GetAdornerLayer( element );
    Debug.WriteLineIf( layer == null, "BusyStatusManager: cannot find any AdornerLayer on the given element." );

    if( layer != null )
    {
        var content = GetContent( element );
        var status = GetStatus( element );

        switch( status )
        {
            case BusyStatus.Idle:

                element.IsEnabled = true;

                if( content != null )
                {
                    var adorners = layer.GetAdorners( element );
                    Debug.WriteLineIf( adorners == null, "BusyStatusManager: cannot find any Adorner on the given element." );

                    if( adorners != null )
                    {
                        var la = adorners.Where( a => a is BusyAdorner ).SingleOrDefault();
                        if( la != null )
                        {
                            layer.Remove( la );
                        }
                    }
                }

                break;

            case BusyStatus.Busy:

                element.IsEnabled = false;

                if( content != null )
                {
                    layer.Add( new BusyAdorner( element, GetContent( element ) ) );
                }
                break;

            default:
                throw new NotSupportedException();
        }
    }
}

Cosa facciamo:

  • Recuperiamo un riferimento all’AdornerLayer;
  • Recuperiamo un riferimento al contenuto da visualizzare e allo stato attuale;
  • In base allo stato decidiamo il da farsi;

Q: Perchè impostamo comunque il nostro target a “IsEnabled == false”?

A: Perchè è il sistema più semplice per evitare che l’utente durante l’operazione asincrona possa ciclare sui controlli “coperti” dal nostro adorner spostando il focus con la tastiera. ndr: Non c’è nessun controllo sul fatto che il controllo target sia disabilitato prima dell’operazione e quindi non debba essere riabilitato, non sarebbe un problema aggiungerlo.

Infine vediamo le parti salienti del nostro BusyAdorner, che deriva da OverlayAdorner di cui abbiamo già parlato:

sealed class BusyAdorner : OverlayAdorner
{
    private readonly ContentPresenter userContent;

    public BusyAdorner( UIElement adornedElement, Object userContent )
        : base( adornedElement )
    {
        this.userContent = new ContentPresenter() { Content = userContent };
    }

    protected override UIElement Content
    {
        get { return this.userContent; }
    }

    protected override void OnRender( DrawingContext drawingContext )
    {
        var brush = new SolidColorBrush( Color.FromArgb( 100, 220, 220, 220 ) );
        var rect = new Rect( new Point( 0, 0 ), this.DesiredSize );

        drawingContext.DrawRectangle( brush, null, rect );

        base.OnRender( drawingContext );
    }
}

Unica cosa degna di nota è che senza chiedere nulla a nessuno facciamo l’override di OnRender e disegnamo un rettangolo grigio, con un canale alpha, come sfondo.

Q: Perchè lo facciamo così e non usiamo un Border con Opacity?

A: Perchè l’Opacity ha lo spiacevole side effect che viene propagata, senza possibilità di modificare questo comportamento (o almeno io non l’ho trovato), anche ai child controls del controllo su cui è impostata… Una cosa che sto pensando è di esporre una nuova attached property “Options” per permettere di variare a design time sia il colore di sfondo che la percentuale di trasparenza.

.m

Technorati Tags: ,